diff --git a/Cargo.lock b/Cargo.lock index 7580912..fe76deb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,15 +10,24 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" dependencies = [ "cipher", "cpubits", "cpufeatures 0.3.0", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -37,6 +46,17 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -50,9 +70,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64" @@ -68,9 +88,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "block-buffer" @@ -83,9 +103,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -101,9 +121,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" @@ -134,9 +154,9 @@ dependencies = [ [[package]] name = "cbc" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98db6aeaef0eeef2c1e3ce9a27b739218825dae116076352ac3777076aa22225" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" dependencies = [ "cipher", ] @@ -155,40 +175,40 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core 0.10.1", + "rand_core", ] [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "num-traits", ] [[package]] name = "cipher" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer 0.12.0", - "crypto-common 0.2.1", + "block-buffer 0.12.1", + "crypto-common 0.2.2", "inout", ] [[package]] name = "cmov" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -199,6 +219,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cpubits" version = "0.1.1" @@ -232,6 +261,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -244,18 +279,18 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", ] [[package]] name = "ctr" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17469f8eb9bdbfad10f71f4cfddfd38b01143520c0e717d8796ccb4d44d44e42" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ "cipher", ] @@ -279,7 +314,22 @@ dependencies = [ "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", - "fiat-crypto", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "fiat-crypto 0.3.0", "rustc_version", "subtle", "zeroize", @@ -334,16 +384,16 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", - "crypto-common 0.2.1", + "block-buffer 0.12.1", + "crypto-common 0.2.2", "ctutils", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -352,9 +402,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "encoding_rs" @@ -371,6 +421,42 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "fdeflate" version = "0.3.7" @@ -386,6 +472,18 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -447,7 +545,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "rand_core 0.10.1", + "rand_core", "wasip2", "wasip3", "wasm-bindgen", @@ -473,9 +571,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashify" @@ -539,9 +637,9 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "typenum", ] @@ -596,7 +694,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -628,13 +726,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -656,11 +753,17 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "md-5" @@ -674,9 +777,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "miniz_oxide" @@ -698,6 +801,12 @@ dependencies = [ "pxfm", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "num-traits" version = "0.2.19" @@ -713,6 +822,17 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -725,7 +845,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "crc32fast", "fdeflate", "flate2", @@ -764,19 +884,36 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", ] +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prost", + "prost-types", + "regex", + "tempfile", +] + [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools", @@ -785,6 +922,15 @@ dependencies = [ "syn", ] +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + [[package]] name = "pxfm" version = "0.1.29" @@ -820,20 +966,43 @@ checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom", - "rand_core 0.10.1", + "rand_core", ] [[package]] name = "rand_core" -version = "0.6.4" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] -name = "rand_core" -version = "0.10.1" +name = "regex" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustc_version" @@ -844,6 +1013,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -935,9 +1117,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -991,6 +1173,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1129,6 +1320,18 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1175,9 +1378,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" @@ -1197,15 +1400,15 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common 0.2.1", + "crypto-common 0.2.2", "ctutils", ] [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "getrandom", "js-sys", @@ -1220,13 +1423,14 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wacore-appstate" -version = "0.5.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#88a0fe074fdc09d73be95cdd23743fba22ef172e" +version = "0.6.0" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#a03672ee5af62568e1960fa90e8f0ae0ecf15050" dependencies = [ "anyhow", "bytemuck", "hex", "hkdf 0.13.0", + "hmac 0.13.0", "log", "prost", "serde", @@ -1241,8 +1445,8 @@ dependencies = [ [[package]] name = "wacore-binary" -version = "0.5.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#88a0fe074fdc09d73be95cdd23743fba22ef172e" +version = "0.6.0" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#a03672ee5af62568e1960fa90e8f0ae0ecf15050" dependencies = [ "bytes", "compact_str", @@ -1251,23 +1455,25 @@ dependencies = [ "itoa", "serde", "serde_json", + "smallvec", "stable_deref_trait", "yoke", ] [[package]] name = "wacore-libsignal" -version = "0.5.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#88a0fe074fdc09d73be95cdd23743fba22ef172e" +version = "0.6.0" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#a03672ee5af62568e1960fa90e8f0ae0ecf15050" dependencies = [ "aes", "arrayref", + "async-lock", "async-trait", "bytes", "cbc", "chrono", "ctr", - "curve25519-dalek", + "curve25519-dalek 5.0.0-rc.0", "derive_more", "displaydoc", "ghash", @@ -1289,8 +1495,8 @@ dependencies = [ [[package]] name = "wacore-noise" -version = "0.5.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#88a0fe074fdc09d73be95cdd23743fba22ef172e" +version = "0.6.0" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#a03672ee5af62568e1960fa90e8f0ae0ecf15050" dependencies = [ "anyhow", "bytes", @@ -1307,18 +1513,22 @@ dependencies = [ [[package]] name = "waproto" -version = "0.5.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#88a0fe074fdc09d73be95cdd23743fba22ef172e" +version = "0.6.0" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#a03672ee5af62568e1960fa90e8f0ae0ecf15050" dependencies = [ + "heck", "prost", + "prost-build", + "prost-types", "serde", + "sha2 0.11.0", ] [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen 0.57.1", ] @@ -1334,9 +1544,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -1347,9 +1557,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" dependencies = [ "js-sys", "wasm-bindgen", @@ -1357,9 +1567,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1367,9 +1577,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", @@ -1380,9 +1590,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] @@ -1415,7 +1625,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -1423,9 +1633,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", @@ -1437,7 +1647,7 @@ version = "0.1.0" dependencies = [ "async-trait", "base64", - "curve25519-dalek", + "curve25519-dalek 4.1.3", "getrandom", "hashify", "hkdf 0.12.4", @@ -1468,6 +1678,21 @@ dependencies = [ "web-sys", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -1532,7 +1757,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.0", "indexmap", "log", "serde", @@ -1564,21 +1789,20 @@ dependencies = [ [[package]] name = "x25519-dalek" -version = "2.0.1" +version = "3.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +checksum = "f17b575e04fcdb37e5509a85a14ff08116678a1c9724befb8b571db742dbdbb0" dependencies = [ - "curve25519-dalek", - "rand_core 0.6.4", - "serde", + "curve25519-dalek 5.0.0-rc.0", + "rand_core", "zeroize", ] [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -1599,9 +1823,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -1620,23 +1844,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zlib-rs" diff --git a/src/legacy_session.rs b/src/legacy_session.rs new file mode 100644 index 0000000..270a8ad --- /dev/null +++ b/src/legacy_session.rs @@ -0,0 +1,408 @@ +//! Baileys (libsignal-node) on-disk session JSON <-> wacore record. +//! +//! wacore stores the post-HKDF message-key split and HKDF is one-way, so the +//! skipped-key seeds Baileys needs can't be recovered from a record alone — they +//! ride in a local `SessionSeeds` sidecar (prost-derived to avoid a `protoc` +//! build dep) rather than changing the upstream proto. + +use base64::prelude::*; +use js_sys::{Array, Object, Reflect, Uint8Array}; +use std::collections::HashMap; +use wasm_bindgen::prelude::*; + +use prost::Message; +use waproto::whatsapp::session_structure::chain::MessageKey; +use waproto::whatsapp::{RecordStructure, SenderKeyRecordStructure, SessionStructure}; + +use wacore_libsignal::protocol::MessageKeyGenerator; + +/// Trust a captured seed only if it re-derives to the split wacore stored. +fn seed_matches_record(seed: &[u8; 32], index: u32, record: &MessageKey) -> bool { + let derived = MessageKeyGenerator::new_from_seed(seed, index).into_pb(); + derived.cipher_key == record.cipher_key + && derived.mac_key == record.mac_key + && derived.iv == record.iv +} + +// libsignal-node enum values (base_key_type.js / chain_type.js). +const BASE_KEY_TYPE_OURS: f64 = 1.0; +const BASE_KEY_TYPE_THEIRS: f64 = 2.0; +const CHAIN_TYPE_SENDING: f64 = 1.0; +const CHAIN_TYPE_RECEIVING: f64 = 2.0; + +/// libsignal-node session details the wacore proto can't carry (skipped-key +/// seeds + per-session meta), kept alongside the record. +#[derive(Clone, PartialEq, Message)] +pub struct SessionSeeds { + #[prost(message, repeated, tag = "1")] + pub chains: Vec, + #[prost(message, repeated, tag = "2")] + pub sessions: Vec, +} + +/// Per-session libsignal-node fields missing from wacore, keyed by base key. +/// `base_key_type`: 1=OURS 2=THEIRS 0=unknown. `has_index_info`: imported +/// (timestamps preserved) vs bridge-native (synthesized). +#[derive(Clone, PartialEq, Message)] +pub struct SessionMeta { + #[prost(bytes = "vec", tag = "1")] + pub base_key: Vec, + #[prost(uint32, tag = "2")] + pub base_key_type: u32, + #[prost(bytes = "vec", tag = "3")] + pub last_remote_ephemeral: Vec, + #[prost(bool, tag = "4")] + pub has_index_info: bool, + #[prost(double, tag = "5")] + pub used: f64, + #[prost(double, tag = "6")] + pub created: f64, + #[prost(double, tag = "7")] + pub closed: f64, +} + +#[derive(Clone, PartialEq, Message)] +pub struct ChainSeeds { + #[prost(bytes = "vec", tag = "1")] + pub ratchet_key: Vec, + #[prost(message, repeated, tag = "2")] + pub seeds: Vec, +} + +#[derive(Clone, PartialEq, Message)] +pub struct MessageKeySeed { + #[prost(uint32, tag = "1")] + pub index: u32, + #[prost(bytes = "vec", tag = "2")] + pub seed: Vec, +} + +#[inline] +fn b64(bytes: &[u8]) -> String { + BASE64_STANDARD.encode(bytes) +} + +#[inline] +fn set(obj: &Object, key: &str, value: &JsValue) -> Result<(), JsValue> { + Reflect::set(obj, &JsValue::from_str(key), value)?; + Ok(()) +} + +#[inline] +fn set_str(obj: &Object, key: &str, value: &str) -> Result<(), JsValue> { + set(obj, key, &JsValue::from_str(value)) +} + +#[inline] +fn set_num(obj: &Object, key: &str, value: f64) -> Result<(), JsValue> { + set(obj, key, &JsValue::from_f64(value)) +} + +/// JS counter is last-used (fresh -1); wacore index is next-to-derive (fresh 0). +#[inline] +fn rust_index_to_js_counter(index: u32) -> f64 { + index as f64 - 1.0 +} + +/// wacore record + sidecar -> libsignal-node `{_sessions, version}` JSON. +#[wasm_bindgen(js_name = exportLegacySession)] +pub fn export_legacy_session(record: &[u8], seeds: &[u8]) -> Result { + let record = RecordStructure::decode(record) + .map_err(|e| JsValue::from_str(&format!("exportLegacySession: bad record: {e}")))?; + let seeds = SessionSeeds::decode(seeds) + .map_err(|e| JsValue::from_str(&format!("exportLegacySession: bad seeds: {e}")))?; + record_to_legacy_json(&record, &seeds) +} + +/// Non-wasm core of `exportLegacySession`, reused by the store write path. +pub fn record_to_legacy_json( + record: &RecordStructure, + seeds: &SessionSeeds, +) -> Result { + let mut seed_map: HashMap, HashMap>> = HashMap::new(); + for chain in &seeds.chains { + let entry = seed_map.entry(chain.ratchet_key.clone()).or_default(); + for s in &chain.seeds { + entry.insert(s.index, s.seed.clone()); + } + } + let meta_map: HashMap<&[u8], &SessionMeta> = seeds + .sessions + .iter() + .map(|m| (m.base_key.as_slice(), m)) + .collect(); + + let sessions = Object::new(); + + if let Some(current) = record.current_session.as_ref() { + let (base_key, entry) = session_to_entry(current, &seed_map, &meta_map, -1.0)?; + set(&sessions, &base_key, &entry)?; + } + + // Fallback `closed` (descending, newest first) for removeOldSessions order + // when meta carries none. + for (i, prev) in record.previous_sessions.iter().enumerate() { + let (base_key, entry) = session_to_entry( + prev, + &seed_map, + &meta_map, + (record.previous_sessions.len() - i) as f64, + )?; + if !Reflect::has(&sessions, &JsValue::from_str(&base_key)).unwrap_or(false) { + set(&sessions, &base_key, &entry)?; + } + } + + let out = Object::new(); + set(&out, "_sessions", &sessions)?; + set_str(&out, "version", "v1")?; + Ok(out.into()) +} + +/// One libsignal-node session entry; returns `(baseKeyBase64, entry)`. +fn session_to_entry( + session: &SessionStructure, + seed_map: &HashMap, HashMap>>, + meta_map: &HashMap<&[u8], &SessionMeta>, + closed: f64, +) -> Result<(String, Object), JsValue> { + let empty = Vec::new(); + let base_key = session.alice_base_key.as_deref().unwrap_or(&empty); + let meta = meta_map.get(base_key).copied(); + + let sender_chain = session.sender_chain.as_ref(); + let sender_pub = sender_chain + .and_then(|c| c.sender_ratchet_key.as_deref()) + .unwrap_or(&empty); + let sender_priv = sender_chain + .and_then(|c| c.sender_ratchet_key_private.as_deref()) + .unwrap_or(&empty); + + // Current peer ratchet = tail receiver chain (stays correct after a bridge + // ratchet); meta fallback only for an initiator with no receiver chain yet. + let last_remote = session + .receiver_chains + .last() + .and_then(|c| c.sender_ratchet_key.as_deref()) + .filter(|b| !b.is_empty()) + .or_else(|| { + meta.map(|m| m.last_remote_ephemeral.as_slice()) + .filter(|b| !b.is_empty()) + }) + .unwrap_or(&empty); + + let ratchet = Object::new(); + let ekp = Object::new(); + set_str(&ekp, "pubKey", &b64(sender_pub))?; + set_str(&ekp, "privKey", &b64(sender_priv))?; + set(&ratchet, "ephemeralKeyPair", &ekp)?; + set_str(&ratchet, "lastRemoteEphemeralKey", &b64(last_remote))?; + set_num( + &ratchet, + "previousCounter", + session.previous_counter.unwrap_or(0) as f64, + )?; + set_str( + &ratchet, + "rootKey", + &b64(session.root_key.as_deref().unwrap_or(&empty)), + )?; + + let index_info = Object::new(); + set_str(&index_info, "baseKey", &b64(base_key))?; + // Captured value first (only source after an ack clears pendingPreKey); + // else the pendingPreKey discriminator (present => we initiated => OURS). + let base_key_type = match meta.map(|m| m.base_key_type) { + Some(1) => BASE_KEY_TYPE_OURS, + Some(2) => BASE_KEY_TYPE_THEIRS, + _ if session.pending_pre_key.is_some() => BASE_KEY_TYPE_OURS, + _ => BASE_KEY_TYPE_THEIRS, + }; + set_num(&index_info, "baseKeyType", base_key_type)?; + // Preserve imported timestamps (drive libsignal-node attempt order + pruning); + // synthesize for a bridge-native session. + let with_meta = meta.filter(|m| m.has_index_info); + set_num( + &index_info, + "closed", + with_meta.map(|m| m.closed).unwrap_or(closed), + )?; + set_num( + &index_info, + "used", + with_meta.map(|m| m.used).unwrap_or(0.0), + )?; + set_num( + &index_info, + "created", + with_meta.map(|m| m.created).unwrap_or(0.0), + )?; + set_str( + &index_info, + "remoteIdentityKey", + &b64(session.remote_identity_public.as_deref().unwrap_or(&empty)), + )?; + + let chains = Object::new(); + if let Some(c) = sender_chain { + let chain = chain_to_js(c, CHAIN_TYPE_SENDING, sender_pub, seed_map, true)?; + set(&chains, &b64(sender_pub), &chain)?; + } + // Only the tail chain stays live; close older ones (libsignal-node drops their + // key on a ratchet) while still emitting their cached messageKeys. + let last_rc = session.receiver_chains.len().saturating_sub(1); + for (i, rc) in session.receiver_chains.iter().enumerate() { + let ratchet_key = rc.sender_ratchet_key.as_deref().unwrap_or(&empty); + let chain = chain_to_js( + rc, + CHAIN_TYPE_RECEIVING, + ratchet_key, + seed_map, + i == last_rc, + )?; + set(&chains, &b64(ratchet_key), &chain)?; + } + + let entry = Object::new(); + set_num( + &entry, + "registrationId", + session.remote_registration_id.unwrap_or(0) as f64, + )?; + set(&entry, "currentRatchet", &ratchet)?; + set(&entry, "indexInfo", &index_info)?; + set(&entry, "_chains", &chains)?; + + if let Some(ppk) = session.pending_pre_key.as_ref() { + let pending = Object::new(); + if let Some(id) = ppk.pre_key_id { + set_num(&pending, "preKeyId", id as f64)?; + } + set_num( + &pending, + "signedKeyId", + ppk.signed_pre_key_id.unwrap_or(0) as f64, + )?; + set_str( + &pending, + "baseKey", + &b64(ppk.base_key.as_deref().unwrap_or(&empty)), + )?; + set(&entry, "pendingPreKey", &pending)?; + } + + Ok((b64(base_key), entry)) +} + +fn chain_to_js( + chain: &waproto::whatsapp::session_structure::Chain, + chain_type: f64, + ratchet_key: &[u8], + seed_map: &HashMap, HashMap>>, + live: bool, +) -> Result { + let ck = Object::new(); + let index = chain.chain_key.as_ref().and_then(|c| c.index).unwrap_or(0); + set_num(&ck, "counter", rust_index_to_js_counter(index))?; + // Omit the key unless the chain is live and non-empty — absence keeps it closed. + let chain_key_bytes = chain.chain_key.as_ref().and_then(|c| c.key.as_deref()); + if let Some(key) = chain_key_bytes.filter(|k| live && !k.is_empty()) { + set_str(&ck, "key", &b64(key))?; + } + + // Emit a seed only if it re-derives to wacore's stored split — drops a + // stale/forged/wrong-session seed (peer retries) instead of bad key material. + let message_keys = Object::new(); + let seeds = seed_map.get(ratchet_key); + for mk in &chain.message_keys { + let index = mk.index.unwrap_or(0); + let Some(seed) = seeds.and_then(|m| m.get(&index)) else { + continue; + }; + let Ok(seed_arr) = <[u8; 32]>::try_from(seed.as_slice()) else { + continue; + }; + if seed_matches_record(&seed_arr, index, mk) { + set_str(&message_keys, &index.to_string(), &b64(seed))?; + } + } + + let chain = Object::new(); + set(&chain, "chainKey", &ck)?; + set_num(&chain, "chainType", chain_type)?; + set(&chain, "messageKeys", &message_keys)?; + Ok(chain) +} + +/// Node `Buffer.toJSON()` shape `{type:'Buffer', data:[…]}` — what Baileys' +/// sender-key store writes (plain `JSON.stringify`, no BufferJSON replacer). +fn buffer_json(bytes: &[u8]) -> Result { + let o = Object::new(); + set_str(&o, "type", "Buffer")?; + let data = Array::new(); + for &b in bytes { + data.push(&JsValue::from_f64(b as f64)); + } + set(&o, "data", &data)?; + Ok(o.into()) +} + +/// Reverse of `migrate_legacy_sender_key` — wacore sender-key record -> Baileys' +/// on-disk JSON, so a group session can revert. +pub fn sender_key_record_to_legacy_json(record: &[u8]) -> Result, JsValue> { + let rec = SenderKeyRecordStructure::decode(record) + .map_err(|e| JsValue::from_str(&format!("exportLegacySenderKey: bad record: {e}")))?; + + let states = Array::new(); + for s in &rec.sender_key_states { + let state = Object::new(); + set_num(&state, "senderKeyId", s.sender_key_id.unwrap_or(0) as f64)?; + + let chain = Object::new(); + if let Some(ck) = s.sender_chain_key.as_ref() { + set_num(&chain, "iteration", ck.iteration.unwrap_or(0) as f64)?; + set( + &chain, + "seed", + &buffer_json(ck.seed.as_deref().unwrap_or(&[]))?, + )?; + } + set(&state, "senderChainKey", &chain)?; + + let signing = Object::new(); + if let Some(sk) = s.sender_signing_key.as_ref() { + set( + &signing, + "public", + &buffer_json(sk.public.as_deref().unwrap_or(&[]))?, + )?; + if let Some(private_key) = sk.private.as_deref() { + set(&signing, "private", &buffer_json(private_key)?)?; + } + } + set(&state, "senderSigningKey", &signing)?; + + let mks = Array::new(); + for mk in &s.sender_message_keys { + let m = Object::new(); + set_num(&m, "iteration", mk.iteration.unwrap_or(0) as f64)?; + set(&m, "seed", &buffer_json(mk.seed.as_deref().unwrap_or(&[]))?)?; + mks.push(&m); + } + set(&state, "senderMessageKeys", &mks)?; + + states.push(&state); + } + + let json = js_sys::JSON::stringify(&states)?; + let s = json + .as_string() + .ok_or_else(|| JsValue::from_str("exportLegacySenderKey: stringify produced no string"))?; + Ok(s.into_bytes()) +} + +#[wasm_bindgen(js_name = exportLegacySenderKey)] +pub fn export_legacy_sender_key(record: &[u8]) -> Result { + let bytes = sender_key_record_to_legacy_json(record)?; + Ok(Uint8Array::from(bytes.as_slice())) +} diff --git a/src/lib.rs b/src/lib.rs index 241d6c8..7c4b8a9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod group_types; #[cfg(feature = "image")] pub mod image_utils; pub mod key_helper; +pub mod legacy_session; pub mod logger; pub mod noise_session; pub mod protocol_address; diff --git a/src/session_cipher.rs b/src/session_cipher.rs index 2e49ac0..a45dafc 100644 --- a/src/session_cipher.rs +++ b/src/session_cipher.rs @@ -7,7 +7,7 @@ use crate::{ protocol_address::ProtocolAddress, storage_adapter::{JsStorageAdapter, SignalStorage}, }; -use wacore_libsignal::protocol::{self as libsignal, SessionStore, UsePQRatchet}; +use wacore_libsignal::protocol::{self as libsignal, PreKeyStore, SessionStore, UsePQRatchet}; #[inline] fn bytes_to_uint8array(bytes: &[u8]) -> Uint8Array { @@ -78,12 +78,23 @@ impl SessionCipher { JsValue::from_str(&msg) })?; + // Snapshot before decrypt advances the chain (no-op for a fresh session). + let inner = prekey_message.message(); + let skip_snapshot = self + .storage_adapter + .snapshot_skip( + &self.remote_address.0, + inner.sender_ratchet_key(), + inner.counter(), + ) + .await; + let mut session_store = self.storage_adapter.clone(); let mut identity_store = session_store.clone(); let mut prekey_store = session_store.clone(); let signed_prekey_store = session_store.clone(); - let plaintext = libsignal::message_decrypt_prekey( + let result = libsignal::message_decrypt_prekey( &prekey_message, &self.remote_address.0, &mut session_store, @@ -99,7 +110,18 @@ impl SessionCipher { JsValue::from_str(&msg) })?; - Ok(bytes_to_uint8array(&plaintext)) + // v0.6 reports the consumed prekey instead of deleting it. Best-effort: + // the message is already decrypted, so a removal failure must not drop the + // delivered plaintext (a redelivered pkmsg reuses the promoted session). + if let Some(prekey_id) = result.consumed_prekey_id { + let _ = prekey_store.remove_pre_key(prekey_id).await; + } + + self.storage_adapter + .commit_skipped(&self.remote_address.0, skip_snapshot) + .await; + + Ok(bytes_to_uint8array(&result.plaintext)) } #[wasm_bindgen(js_name = decryptWhisperMessage)] @@ -115,10 +137,20 @@ impl SessionCipher { JsValue::from_str(&msg) })?; + // Snapshot before decrypt advances the chain (seeds committed post-auth). + let skip_snapshot = self + .storage_adapter + .snapshot_skip( + &self.remote_address.0, + signal_message.sender_ratchet_key(), + signal_message.counter(), + ) + .await; + let mut session_store = self.storage_adapter.clone(); let mut identity_store = session_store.clone(); - let plaintext = libsignal::message_decrypt_signal( + let result = libsignal::message_decrypt_signal( &signal_message, &self.remote_address.0, &mut session_store, @@ -131,7 +163,12 @@ impl SessionCipher { JsValue::from_str(&msg) })?; - Ok(bytes_to_uint8array(&plaintext)) + // MAC checked out → commit the skipped seeds (see commit_skipped). + self.storage_adapter + .commit_skipped(&self.remote_address.0, skip_snapshot) + .await; + + Ok(bytes_to_uint8array(&result.plaintext)) } #[wasm_bindgen(js_name = hasOpenSession)] diff --git a/src/session_record.rs b/src/session_record.rs index 36f5f7b..a8a54d3 100644 --- a/src/session_record.rs +++ b/src/session_record.rs @@ -1,4 +1,4 @@ -use js_sys::{Array, Reflect, Uint8Array}; +use js_sys::{Array, Object, Reflect, Uint8Array}; use wacore_libsignal::protocol::SessionRecord as CoreSessionRecord; use wasm_bindgen::prelude::*; @@ -6,6 +6,12 @@ const INVALID_INPUT_ERROR: &str = "SessionRecord.deserialize: Invalid input type const SESSIONS_KEY: &str = "_sessions"; const DATA_KEY: &str = "data"; +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(extends = Object, typescript_type = "{ baseKey: Uint8Array; registrationId: number }")] + pub type SessionInfo; +} + #[wasm_bindgen(js_name = SessionRecord)] pub struct SessionRecord { pub(crate) serialized_data: Vec, @@ -57,6 +63,38 @@ impl SessionRecord { .map(|record| record.session_state().is_some()) .unwrap_or(false) } + + // The X3DH base key indexes the session and is shared by both peers, so it's a + // stable per-session id; remote registration id identifies the peer device. + // libsignal-node exposed both via getOpenSession().indexInfo.baseKey/registrationId, + // which Baileys' retry protections read. `undefined` when there's no open session. + #[wasm_bindgen(js_name = sessionInfo)] + pub fn session_info(&self) -> Result, JsValue> { + let record = CoreSessionRecord::deserialize(&self.serialized_data) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + if record.session_state().is_none() { + return Ok(None); + } + let base_key = record + .alice_base_key() + .map_err(|e| JsValue::from_str(&e.to_string()))?; + let registration_id = record + .remote_registration_id() + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + let obj = Object::new(); + Reflect::set( + &obj, + &JsValue::from_str("baseKey"), + &Uint8Array::from(base_key), + )?; + Reflect::set( + &obj, + &JsValue::from_str("registrationId"), + &JsValue::from(registration_id), + )?; + Ok(Some(obj.unchecked_into())) + } } fn create_empty_session_record() -> Result { diff --git a/src/storage_adapter.rs b/src/storage_adapter.rs index 31e4928..0481797 100644 --- a/src/storage_adapter.rs +++ b/src/storage_adapter.rs @@ -1,7 +1,82 @@ use async_trait::async_trait; use base64::prelude::*; +use hmac::{Hmac, Mac}; use js_sys::{Promise, Uint8Array}; use prost::Message; +use sha2::Sha256; + +// Mirror wacore's skipped-key bounds (libsignal consts.rs). +const MAX_FORWARD_JUMPS: u32 = 25_000; +const MAX_MESSAGE_KEYS: usize = 2000; +// wacore holds up to MAX_MESSAGE_KEYS + its prune threshold (50) before evicting; +// keep the sidecar at the same ceiling so it never drops a key the record holds. +const MAX_SIDECAR_KEYS: usize = MAX_MESSAGE_KEYS + 50; +// Above wacore's worst case (5 receiver chains × ~41 sessions) so a legit +// multi-session record never has valid seeds evicted. +const MAX_CACHED_CHAINS: usize = 256; + +/// Enough pre-decrypt state to compute a message's skipped seeds, captured cheaply +/// and only consumed post-auth — so a forged message drives no DH/stepping/write. +pub(crate) enum SkipSnapshot { + /// Gap within an existing receiver chain: step forward from its chain key. + Existing { + ratchet: Vec, + start_key: libsignal::ChainKey, + counter: u32, + }, + /// Gap on a brand-new DH ratchet: derive the chain (index 0) post-auth via + /// `RootKey::create_chain`, then step. + NewChain { + ratchet: Vec, + ratchet_key: libsignal::PublicKey, + root: libsignal::RootKey, + our_priv: libsignal::PrivateKey, + counter: u32, + }, +} + +/// The skipped-key seed: `HMAC-SHA256(chainKey, [0x01])` — what libsignal-node +/// stores, so re-emitting it round-trips losslessly. +fn message_key_seed(chain_key: &[u8; 32]) -> Vec { + let mut mac = Hmac::::new_from_slice(chain_key).expect("HMAC accepts any key length"); + mac.update(&[0x01]); + mac.finalize().into_bytes().to_vec() +} + +/// A skip-seed candidate from one session state. `allow_new_chain` lets a +/// new-DH-ratchet gap be captured (the export self-validation drops it if the +/// session was the wrong one). +fn skip_candidate( + state: &libsignal::SessionState, + sender_ratchet_key: &libsignal::PublicKey, + ratchet_bytes: &[u8], + counter: u32, + allow_new_chain: bool, +) -> Option { + match state.get_receiver_chain_key(sender_ratchet_key) { + Ok(Some(start_key)) => { + let start = start_key.index(); + if counter <= start || counter - start > MAX_FORWARD_JUMPS { + return None; + } + Some(SkipSnapshot::Existing { + ratchet: ratchet_bytes.to_vec(), + start_key, + counter, + }) + } + Ok(None) if allow_new_chain && counter > 0 && counter <= MAX_FORWARD_JUMPS => { + Some(SkipSnapshot::NewChain { + ratchet: ratchet_bytes.to_vec(), + ratchet_key: *sender_ratchet_key, + root: state.root_key().ok()?, + our_priv: state.sender_ratchet_private_key().ok()?, + counter, + }) + } + _ => None, + } +} use serde::Deserialize; use serde::de::DeserializeOwned; use serde_bytes::ByteBuf; @@ -12,10 +87,13 @@ use waproto::whatsapp::{ RecordStructure, SenderKeyRecordStructure, SenderKeyStateStructure, SessionStructure, sender_key_state_structure::{SenderChainKey, SenderMessageKey, SenderSigningKey}, session_structure::{ - Chain, + Chain, PendingPreKey, chain::{ChainKey, MessageKey}, }, }; + +use crate::legacy_session::{ChainSeeds, MessageKeySeed, SessionMeta, SessionSeeds}; +use crate::session_record::SessionRecord; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; @@ -33,13 +111,21 @@ use wacore_libsignal::protocol::SignalProtocolError; use wacore_libsignal::protocol::Timestamp; use wacore_libsignal::store::sender_key_name::SenderKeyName as CoreSenderKeyName; -use crate::session_record::SessionRecord; - #[wasm_bindgen(typescript_custom_section)] const TS_SIGNAL_STORAGE: &str = r#" export interface SignalStorage { - loadSession(address: string): Uint8Array | null | undefined | Promise; - storeSession(address: string, record: SessionRecord): void | Promise; + // Set `dropInBaileysFormat: true` to opt into the revertible Baileys-JSON + // on-disk format: `storeSession` then receives the libsignal-node session + // object (and `storeSenderKey` its JSON bytes), and a Baileys session object + // returned from `loadSession` is migrated transparently. Default and + // `storeSessionRaw` keep the native format. + dropInBaileysFormat?: boolean; + // May return native proto bytes OR (drop-in) a Baileys session object. + loadSession(address: string): Uint8Array | object | null | undefined | Promise; + // Default: a `SessionRecord` (call `.serialize()`). Drop-in: the Baileys + // session JSON object. `storeSessionRaw`: native proto bytes (no SessionRecord). + storeSession(address: string, session: SessionRecord | any): void | Promise; + storeSessionRaw?(address: string, record: Uint8Array): void | Promise; getOurIdentity(): KeyPair | Promise; getOurRegistrationId(): number | Promise; isTrustedIdentity(name: string, identityKey: Uint8Array, direction: number): boolean | Promise; @@ -114,9 +200,13 @@ pub struct JsStorageAdapter { cached_identity_key_pair: Rc>>, cached_registration_id: Rc>>, cached_sessions: Rc>>, + // Skipped-key seeds the wacore record can't carry, per address, so the drop-in + // write path can re-emit them in the Baileys JSON. + cached_seeds: Rc>>, cached_sender_keys: Rc>>, cached_identities: Rc>>>, has_store_session_raw: Rc>>, + is_drop_in: Rc>>, last_address_cache: Rc>>, last_sender_key_cache: Rc>>, } @@ -128,9 +218,11 @@ impl JsStorageAdapter { cached_identity_key_pair: Rc::new(RefCell::new(None)), cached_registration_id: Rc::new(RefCell::new(None)), cached_sessions: Rc::new(RefCell::new(HashMap::new())), + cached_seeds: Rc::new(RefCell::new(HashMap::new())), cached_sender_keys: Rc::new(RefCell::new(HashMap::new())), cached_identities: Rc::new(RefCell::new(HashMap::new())), has_store_session_raw: Rc::new(RefCell::new(None)), + is_drop_in: Rc::new(RefCell::new(None)), last_address_cache: Rc::new(RefCell::new(None)), last_sender_key_cache: Rc::new(RefCell::new(None)), } @@ -147,6 +239,22 @@ impl JsStorageAdapter { has_raw } + /// Drop-in (Baileys JSON) mode is OPT-IN via a truthy `dropInBaileysFormat` + /// on the store. Default (and `storeSessionRaw`) keep the native proto path, + /// so an existing consumer whose `storeSession` calls `record.serialize()` + /// isn't silently handed a plain object. + fn is_drop_in(&self) -> bool { + if let Some(v) = *self.is_drop_in.borrow() { + return v; + } + let v = js_sys::Reflect::get(&self.js_storage, &JsValue::from_str("dropInBaileysFormat")) + .ok() + .and_then(|x| x.as_bool()) + .unwrap_or(false); + self.is_drop_in.borrow_mut().replace(v); + v + } + #[inline] fn get_address_string(&self, address: &libsignal::ProtocolAddress) -> String { let name = address.name(); @@ -188,225 +296,246 @@ impl JsStorageAdapter { key_id } - async fn migrate_legacy_json(&self, value: JsValue) -> SignalResult>> { - let has_reg_id = - js_sys::Reflect::has(&value, &JsValue::from_str("registrationId")).unwrap_or(false); - let has_ratchet = - js_sys::Reflect::has(&value, &JsValue::from_str("currentRatchet")).unwrap_or(false); - - let session_data = if has_reg_id && has_ratchet { - value - } else { - let has_sessions = - js_sys::Reflect::has(&value, &JsValue::from_str("_sessions")).unwrap_or(false); - if !has_sessions { - return Ok(None); - } - - let sessions = get_object(&value, "_sessions") - .ok_or_else(|| invalid_js_data("migrate", "Missing _sessions"))?; - let sessions_obj = sessions - .dyn_ref::() - .ok_or_else(|| invalid_js_data("migrate", "Invalid _sessions object"))?; - let keys = js_sys::Object::keys(sessions_obj); + async fn migrate_legacy_json( + &self, + value: JsValue, + ) -> SignalResult, SessionSeeds)>> { + let local_identity = self.get_identity_key_pair().await?; + let local_identity_public: Vec = local_identity.public_key().serialize().into(); + let local_reg_id = self.get_local_registration_id().await?; - if keys.length() == 0 { - return Ok(None); - } + // Seeds + meta ride alongside the record for the drop-in write path. + match legacy_value_to_record(&value, local_identity_public, local_reg_id)? { + Some((record, seeds)) => Ok(Some((record.encode_to_vec(), seeds))), + None => Ok(None), + } + } - let key = keys.get(0); - js_sys::Reflect::get(&sessions, &key).map_err(js_to_signal_error)? + /// Phase 1 (pre-decrypt, cheap): a skip candidate per session. wacore may + /// decrypt via the current session or a promoted archived one, so snapshot all; + /// the export's `seed_matches_record` later drops the wrong-session candidates. + pub(crate) async fn snapshot_skip( + &self, + address: &libsignal::ProtocolAddress, + sender_ratchet_key: &libsignal::PublicKey, + counter: u32, + ) -> Vec { + if !self.is_drop_in() { + return Vec::new(); + } + let address_str = self.get_address_string(address); + // Populate the cache once, then read by reference (no per-message clone). + if !self.cached_sessions.borrow().contains_key(&address_str) { + let _ = SessionStore::load_session(self, address).await; + } + let cache = self.cached_sessions.borrow(); + let Some(record) = cache.get(&address_str) else { + return Vec::new(); }; + let ratchet_bytes = sender_ratchet_key.serialize().to_vec(); - let has_reg_id_inner = - js_sys::Reflect::has(&session_data, &JsValue::from_str("registrationId")) - .unwrap_or(false); - if !has_reg_id_inner { - return Ok(None); + let mut snaps = Vec::new(); + // Current session: an existing chain OR a brand-new ratchet. + if let Some(state) = record.session_state() + && let Some(snap) = + skip_candidate(state, sender_ratchet_key, &ratchet_bytes, counter, true) + { + snaps.push(snap); } - - let local_identity = self.get_identity_key_pair().await?; - let local_identity_public = local_identity.public_key().serialize().into(); - - let registration_id = get_number(&session_data, "registrationId").unwrap_or(0.0) as u32; - - let current_ratchet = get_object(&session_data, "currentRatchet") - .ok_or_else(|| invalid_js_data("migrate", "Missing currentRatchet"))?; - let root_key_b64 = get_string(¤t_ratchet, "rootKey").unwrap_or_default(); - let root_key = BASE64_STANDARD.decode(root_key_b64).unwrap_or_default(); - - let previous_counter = - get_number(¤t_ratchet, "previousCounter").unwrap_or(0.0) as u32; - - let ephemeral_key_pair = get_object(¤t_ratchet, "ephemeralKeyPair") - .ok_or_else(|| invalid_js_data("migrate", "Missing ephemeralKeyPair"))?; - let sender_ratchet_pub_b64 = get_string(&ephemeral_key_pair, "pubKey").unwrap_or_default(); - let sender_ratchet_priv_b64 = - get_string(&ephemeral_key_pair, "privKey").unwrap_or_default(); - - let sender_ratchet_pub = BASE64_STANDARD - .decode(sender_ratchet_pub_b64) - .unwrap_or_default(); - let sender_ratchet_priv = BASE64_STANDARD - .decode(sender_ratchet_priv_b64) - .unwrap_or_default(); - - let index_info = get_object(&session_data, "indexInfo") - .ok_or_else(|| invalid_js_data("migrate", "Missing indexInfo"))?; - let remote_identity_b64 = get_string(&index_info, "remoteIdentityKey").unwrap_or_default(); - let remote_identity = BASE64_STANDARD - .decode(remote_identity_b64) - .unwrap_or_default(); - - let base_key_b64 = get_string(&index_info, "baseKey").unwrap_or_default(); - let base_key = BASE64_STANDARD.decode(base_key_b64).unwrap_or_default(); - - let chains = get_object(&session_data, "_chains") - .ok_or_else(|| invalid_js_data("migrate", "Missing _chains"))?; - let chains_obj = chains - .dyn_ref::() - .ok_or_else(|| invalid_js_data("migrate", "_chains expected to be an object"))?; - let chain_keys = js_sys::Object::keys(chains_obj); - - let mut sender_chain = None; - let mut receiver_chains = Vec::new(); - - for i in 0..chain_keys.length() { - let key = chain_keys.get(i); - let chain = js_sys::Reflect::get(&chains, &key).map_err(|err| { - invalid_js_data( - "migrate", - format!( - "Failed to read chain entry {:?}: {:?}", - key.as_string(), - err - ), - ) - })?; - let chain_type = get_number(&chain, "chainType").unwrap_or(0.0) as u32; - - let chain_key_obj = get_object(&chain, "chainKey").ok_or_else(|| { - invalid_js_data("migrate", "Missing chainKey for legacy chain entry") - })?; - let counter = get_number(&chain_key_obj, "counter").unwrap_or(0.0) as u32; - let key_b64 = get_string(&chain_key_obj, "key").unwrap_or_default(); - let key_bytes = BASE64_STANDARD.decode(key_b64).unwrap_or_default(); - - let message_keys_obj = get_object(&chain, "messageKeys").ok_or_else(|| { - invalid_js_data("migrate", "Missing messageKeys for legacy chain entry") - })?; - let message_keys_object = message_keys_obj - .dyn_ref::() - .ok_or_else(|| invalid_js_data("migrate", "Invalid messageKeys object"))?; - let msg_keys_list = js_sys::Object::keys(message_keys_object); - let mut message_keys = Vec::new(); - - for j in 0..msg_keys_list.length() { - let idx_val = msg_keys_list.get(j); - let idx = idx_val.as_f64().ok_or_else(|| { - invalid_js_data("migrate", "Message key index is not a number") - })? as u32; - let msg_key_b64 = js_sys::Reflect::get(&message_keys_obj, &idx_val) - .map_err(|err| { - invalid_js_data( - "migrate", - format!("Missing message key {}: {:?}", idx, err), - ) - })? - .as_string() - .unwrap_or_default(); - let msg_key_bytes = BASE64_STANDARD.decode(msg_key_b64).unwrap_or_default(); - message_keys.push((idx, msg_key_bytes)); - } - - if chain_type == 1 { - sender_chain = Some(( - sender_ratchet_pub.clone(), - sender_ratchet_priv.clone(), - key_bytes, - counter, - message_keys, - )); - } else if chain_type == 2 { - let sender_ratchet_key_b64 = key.as_string().unwrap_or_default(); - let sender_ratchet_key = BASE64_STANDARD - .decode(sender_ratchet_key_b64) - .unwrap_or_default(); - receiver_chains.push((sender_ratchet_key, key_bytes, counter, message_keys)); + // Archived sessions: wacore can decrypt a gap (even via a new DH ratchet) + // through a promoted previous session, so snapshot those too. Wrong-session + // candidates are dropped later by the export's `seed_matches_record`. Gated + // on the count so the common (no-archive) case skips the cloning iterator. + if record.previous_session_count() > 0 { + for prev in record.previous_session_states() { + if let Ok(state) = prev + && let Some(snap) = + skip_candidate(&state, sender_ratchet_key, &ratchet_bytes, counter, true) + { + snaps.push(snap); + } } } + snaps + } - let mut sender_chain_struct = None; - - if let Some((pub_key, priv_key, chain_key, counter, msg_keys)) = sender_chain { - let mut message_keys_vec = Vec::new(); - for (idx, key) in msg_keys { - message_keys_vec.push(MessageKey { - index: Some(idx), - cipher_key: Some(key.into()), - mac_key: Some(vec![0u8; 32].into()), - iv: Some(vec![0u8; 16].into()), - }); + /// Phase 2 (post-auth): derive a snapshot's seeds (running the DH ratchet / + /// stepping only now) and merge them. Returns whether anything was captured. + pub(crate) fn commit_skip_snapshot( + &self, + address: &libsignal::ProtocolAddress, + snap: SkipSnapshot, + ) -> bool { + let (ratchet, mut chain_key, start, counter) = match snap { + SkipSnapshot::Existing { + ratchet, + start_key, + counter, + } => { + let start = start_key.index(); + (ratchet, start_key, start, counter) } + SkipSnapshot::NewChain { + ratchet, + ratchet_key, + root, + our_priv, + counter, + } => match root.create_chain(&ratchet_key, &our_priv) { + Ok((_, ck)) => (ratchet, ck, 0, counter), + Err(_) => return false, + }, + }; - sender_chain_struct = Some(Chain { - sender_ratchet_key: Some(pub_key), - sender_ratchet_key_private: Some(priv_key), - chain_key: Some(ChainKey { - index: Some(counter), - key: Some(chain_key.into()), - }), - message_keys: message_keys_vec, + let mut seeds = Vec::with_capacity((counter - start) as usize); + for index in start..counter { + seeds.push(MessageKeySeed { + index, + seed: message_key_seed(chain_key.key()), }); + chain_key = match chain_key.next_chain_key() { + Ok(next) => next, + Err(_) => break, + }; } - let mut receiver_chains_vec = Vec::new(); - for (sender_ratchet, chain_key, counter, msg_keys) in receiver_chains { - let mut message_keys_vec = Vec::new(); - for (idx, key) in msg_keys { - message_keys_vec.push(MessageKey { - index: Some(idx), - cipher_key: Some(key.into()), - mac_key: Some(vec![0u8; 32].into()), - iv: Some(vec![0u8; 16].into()), - }); - } + let captured = !seeds.is_empty(); + self.merge_seeds(&self.get_address_string(address), ratchet, seeds); + captured + } - receiver_chains_vec.push(Chain { - sender_ratchet_key: Some(sender_ratchet), - sender_ratchet_key_private: None, - chain_key: Some(ChainKey { - index: Some(counter), - key: Some(chain_key.into()), - }), - message_keys: message_keys_vec, - }); + /// Commit every skip snapshot and, if any seeds were captured, rewrite the + /// session JSON so they survive a revert. Shared by both decrypt paths. + pub(crate) async fn commit_skipped( + &self, + address: &libsignal::ProtocolAddress, + snapshots: Vec, + ) { + let mut captured = false; + for snapshot in snapshots { + captured |= self.commit_skip_snapshot(address, snapshot); } + if captured { + self.repersist_session_json(address).await; + } + } - let local_reg_id = self.get_local_registration_id().await?; - - let session = SessionStructure { - session_version: Some(3), - local_identity_public: Some(local_identity_public), - remote_identity_public: Some(remote_identity), - root_key: Some(root_key), - previous_counter: Some(previous_counter), - sender_chain: sender_chain_struct, - receiver_chains: receiver_chains_vec, - pending_key_exchange: None, - pending_pre_key: None, - remote_registration_id: Some(registration_id), - local_registration_id: Some(local_reg_id), - needs_refresh: None, - alice_base_key: Some(base_key), + /// Merge captured seeds into the sidecar, keyed by chain. Bounded by + /// `MAX_MESSAGE_KEYS` per chain and `MAX_CACHED_CHAINS` per address. + fn merge_seeds(&self, address: &str, ratchet_key: Vec, seeds: Vec) { + if seeds.is_empty() { + return; + } + let mut cache = self.cached_seeds.borrow_mut(); + let entry = cache.entry(address.to_string()).or_default(); + let chain = match entry + .chains + .iter_mut() + .find(|c| c.ratchet_key == ratchet_key) + { + Some(c) => c, + None => { + entry.chains.push(ChainSeeds { + ratchet_key, + seeds: Vec::new(), + }); + entry.chains.last_mut().expect("just pushed") + } }; + // O(n): a linear find per seed would be O(n²) on a big gap (up to + // MAX_FORWARD_JUMPS new indices). Existing indices in a set; new ones push. + let mut existing: std::collections::HashSet = + chain.seeds.iter().map(|e| e.index).collect(); + for s in seeds { + if existing.insert(s.index) { + chain.seeds.push(s); + } else if let Some(e) = chain.seeds.iter_mut().find(|e| e.index == s.index) { + e.seed = s.seed; + } + } + // Match wacore's cap (MAX_MESSAGE_KEYS + prune threshold) so the sidecar + // never drops a key the record still holds. + if chain.seeds.len() > MAX_SIDECAR_KEYS { + chain.seeds.sort_unstable_by_key(|s| s.index); + let excess = chain.seeds.len() - MAX_SIDECAR_KEYS; + chain.seeds.drain(..excess); + } + if entry.chains.len() > MAX_CACHED_CHAINS { + let excess = entry.chains.len() - MAX_CACHED_CHAINS; + entry.chains.drain(..excess); + } + } + + /// Record `baseKeyType = OURS` while a bridge-native initiator still has + /// `pendingPreKey` — the only window, since wacore keeps no baseKeyType and the + /// ack clears pendingPreKey. Never downgrades an imported value. + fn mark_base_key_ours(&self, address: &str, base_key: &[u8]) { + let mut cache = self.cached_seeds.borrow_mut(); + let entry = cache.entry(address.to_string()).or_default(); + match entry.sessions.iter_mut().find(|m| m.base_key == base_key) { + Some(m) if m.base_key_type == 0 => m.base_key_type = 1, + Some(_) => {} + None => entry.sessions.push(SessionMeta { + base_key: base_key.to_vec(), + base_key_type: 1, + last_remote_ephemeral: Vec::new(), + has_index_info: false, + used: 0.0, + created: 0.0, + closed: -1.0, + }), + } + } - let record = RecordStructure { - current_session: Some(session), - previous_sessions: Vec::new(), + /// Rewrite the session JSON after a gap decrypt commits new seeds — wacore's + /// own store ran before they existed. Drop-in mode only. + pub(crate) async fn repersist_session_json(&self, address: &libsignal::ProtocolAddress) { + if !self.is_drop_in() { + return; + } + let address_str = self.get_address_string(address); + let record_bytes = { + let cache = self.cached_sessions.borrow(); + match cache.get(&address_str).and_then(|r| r.serialize().ok()) { + Some(b) => b, + None => return, + } }; + // Best-effort: the base session is already durably stored by wacore's own + // write; only the skipped-key enrichment is at stake here. Don't fail the + // (already successful) decrypt, but log so a dropped re-persist is visible + // — its sole effect is those skipped messages needing a retry on revert. + if let Err(e) = self.write_session_json(&address_str, &record_bytes).await { + log::warn!("repersist of skipped-key seeds for {address_str} failed: {e}"); + } + } - Ok(Some(record.encode_to_vec())) + /// Build the libsignal-node JSON for a record (+ sidecar seeds) and hand it to + /// the JS store. Shared by `store_session` and `repersist_session_json`. + async fn write_session_json(&self, address_str: &str, record_bytes: &[u8]) -> SignalResult<()> { + let record_struct = RecordStructure::decode(record_bytes) + .map_err(|e| invalid_js_data("store_session", format!("decode record: {e}")))?; + // Capture baseKeyType=OURS while pendingPreKey is present (mark_base_key_ours). + if let Some(cs) = record_struct.current_session.as_ref() + && cs.pending_pre_key.is_some() + && let Some(base_key) = cs.alice_base_key.as_deref() + { + self.mark_base_key_ours(address_str, base_key); + } + let seeds = self + .cached_seeds + .borrow() + .get(address_str) + .cloned() + .unwrap_or_default(); + let json = crate::legacy_session::record_to_legacy_json(&record_struct, &seeds) + .map_err(js_to_signal_error)?; + let result = self.js_storage.js_store_session(address_str, json); + let promise_value = result.map_err(js_to_signal_error)?; + resolve_maybe_promise(promise_value) + .await + .map_err(js_to_signal_error)?; + Ok(()) } fn migrate_legacy_sender_key(&self, data: &[u8]) -> SignalResult>> { @@ -485,6 +614,339 @@ impl JsStorageAdapter { } } +/// One libsignal-node session entry -> wacore `SessionStructure` + its seed +/// chains + sidecar meta. `local_identity_public`/`local_reg_id` aren't part of +/// the interchange — pass empty/0 when only the seeds matter. +fn legacy_entry_to_session( + session_data: &JsValue, + local_identity_public: &[u8], + local_reg_id: u32, +) -> SignalResult, SessionMeta)>> { + // Propagate (don't swallow) invalid base64 so a corrupt input fails AT + // migration instead of "succeeding" into an undecryptable session. An empty + // string still decodes to empty (absent fields stay absent). + let decode_b64 = |s: String| -> SignalResult> { + BASE64_STANDARD + .decode(s) + .map_err(|e| invalid_js_data("migrate", format!("invalid base64: {e}"))) + }; + + let registration_id = get_number(session_data, "registrationId").unwrap_or(0.0) as u32; + + let current_ratchet = get_object(session_data, "currentRatchet") + .ok_or_else(|| invalid_js_data("migrate", "Missing currentRatchet"))?; + let root_key = decode_b64(get_string(¤t_ratchet, "rootKey").unwrap_or_default())?; + let previous_counter = get_number(¤t_ratchet, "previousCounter").unwrap_or(0.0) as u32; + + let ephemeral_key_pair = get_object(¤t_ratchet, "ephemeralKeyPair") + .ok_or_else(|| invalid_js_data("migrate", "Missing ephemeralKeyPair"))?; + let sender_ratchet_pub = ensure_pubkey_33(decode_b64( + get_string(&ephemeral_key_pair, "pubKey").unwrap_or_default(), + )?); + let sender_ratchet_priv = + decode_b64(get_string(&ephemeral_key_pair, "privKey").unwrap_or_default())?; + + let index_info = get_object(session_data, "indexInfo") + .ok_or_else(|| invalid_js_data("migrate", "Missing indexInfo"))?; + let remote_identity = ensure_pubkey_33(decode_b64( + get_string(&index_info, "remoteIdentityKey").unwrap_or_default(), + )?); + let base_key = ensure_pubkey_33(decode_b64( + get_string(&index_info, "baseKey").unwrap_or_default(), + )?); + + // Sidecar meta for fields wacore's proto can't hold (baseKeyType, + // lastRemoteEphemeralKey, indexInfo timestamps). + let meta = SessionMeta { + base_key: base_key.clone(), + base_key_type: get_number(&index_info, "baseKeyType").unwrap_or(0.0) as u32, + last_remote_ephemeral: ensure_pubkey_33(decode_b64( + get_string(¤t_ratchet, "lastRemoteEphemeralKey").unwrap_or_default(), + )?), + has_index_info: true, + used: get_number(&index_info, "used").unwrap_or(0.0), + created: get_number(&index_info, "created").unwrap_or(0.0), + closed: get_number(&index_info, "closed").unwrap_or(-1.0), + }; + + let chains = get_object(session_data, "_chains") + .ok_or_else(|| invalid_js_data("migrate", "Missing _chains"))?; + let chains_obj = chains + .dyn_ref::() + .ok_or_else(|| invalid_js_data("migrate", "_chains expected to be an object"))?; + let chain_keys = js_sys::Object::keys(chains_obj); + + let mut sender_chain_struct = None; + let mut receiver_chains_vec = Vec::new(); + let mut seed_chains = Vec::new(); + + for i in 0..chain_keys.length() { + let key = chain_keys.get(i); + let chain = js_sys::Reflect::get(&chains, &key).map_err(|err| { + invalid_js_data( + "migrate", + format!( + "Failed to read chain entry {:?}: {:?}", + key.as_string(), + err + ), + ) + })?; + let chain_type = get_number(&chain, "chainType").unwrap_or(0.0) as u32; + + let chain_key_obj = get_object(&chain, "chainKey") + .ok_or_else(|| invalid_js_data("migrate", "Missing chainKey for legacy chain entry"))?; + // JS counter (last-used, fresh -1) -> wacore index (next-to-derive, fresh + // 0). Add before the cast so -1 maps to 0 rather than saturating. + let chain_index = + (get_number(&chain_key_obj, "counter").unwrap_or(-1.0) + 1.0).max(0.0) as u32; + // A closed receiver chain has no `chainKey.key` — keep the index, leave the + // key absent so it stays closed instead of becoming an empty-key live chain. + let chain_key_bytes = get_string(&chain_key_obj, "key") + .map(decode_b64) + .transpose()? + .filter(|b| !b.is_empty()); + + let message_keys_obj = get_object(&chain, "messageKeys").ok_or_else(|| { + invalid_js_data("migrate", "Missing messageKeys for legacy chain entry") + })?; + let message_keys_object = message_keys_obj + .dyn_ref::() + .ok_or_else(|| invalid_js_data("migrate", "Invalid messageKeys object"))?; + let msg_keys_list = js_sys::Object::keys(message_keys_object); + let mut message_keys = Vec::new(); + for j in 0..msg_keys_list.length() { + // messageKeys is keyed by counter; `Object::keys` yields STRING keys. + let idx_val = msg_keys_list.get(j); + let idx = idx_val + .as_string() + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| invalid_js_data("migrate", "Message key index is not a number"))?; + let msg_key_b64 = js_sys::Reflect::get(&message_keys_obj, &idx_val) + .map_err(|err| { + invalid_js_data("migrate", format!("Missing message key {}: {:?}", idx, err)) + })? + .as_string() + .unwrap_or_default(); + message_keys.push((idx, decode_b64(msg_key_b64)?)); + } + + let ratchet_key = if chain_type == 1 { + sender_ratchet_pub.clone() + } else { + ensure_pubkey_33(decode_b64(key.as_string().unwrap_or_default())?) + }; + + // Stash the raw seeds so the reverse (export) path can re-emit them. + if !message_keys.is_empty() { + seed_chains.push(ChainSeeds { + ratchet_key: ratchet_key.clone(), + seeds: message_keys + .iter() + .map(|(index, seed)| MessageKeySeed { + index: *index, + seed: seed.clone(), + }) + .collect(), + }); + } + + let built = Chain { + sender_ratchet_key: Some(ratchet_key), + sender_ratchet_key_private: (chain_type == 1).then(|| sender_ratchet_priv.clone()), + chain_key: Some(ChainKey { + index: Some(chain_index), + key: chain_key_bytes.map(Into::into), + }), + message_keys: legacy_message_keys(message_keys), + }; + + if chain_type == 1 { + sender_chain_struct = Some(built); + } else if chain_type == 2 { + receiver_chains_vec.push(built); + } + } + + // Filter to a real object: `get_object` returns `Some(undefined)` for an + // absent key, and a forged empty pendingPreKey makes wacore reject the session. + let pending_pre_key = match get_object(session_data, "pendingPreKey").filter(|p| p.is_object()) + { + Some(ppk) => Some(PendingPreKey { + pre_key_id: get_number(&ppk, "preKeyId").map(|n| n as u32), + signed_pre_key_id: get_number(&ppk, "signedKeyId").map(|n| n as i32), + base_key: Some(ensure_pubkey_33(decode_b64( + get_string(&ppk, "baseKey").unwrap_or_default(), + )?)), + }), + None => None, + }; + + let session = SessionStructure { + session_version: Some(3), + local_identity_public: Some(local_identity_public.to_vec()), + remote_identity_public: Some(remote_identity), + root_key: Some(root_key), + previous_counter: Some(previous_counter), + sender_chain: sender_chain_struct, + receiver_chains: receiver_chains_vec, + pending_key_exchange: None, + pending_pre_key, + remote_registration_id: Some(registration_id), + local_registration_id: Some(local_reg_id), + needs_refresh: None, + alice_base_key: Some(base_key), + }; + + Ok(Some((session, seed_chains, meta))) +} + +/// Convert a libsignal-node value (a flat session, or a `{_sessions}` wrapper +/// with current + archived sessions) into a wacore `RecordStructure` plus the +/// `SessionSeeds` sidecar. The open session (`indexInfo.closed === -1`) becomes +/// `current_session`; the rest become `previous_sessions` (most-recently-closed +/// first), so archived sessions survive a revert too. Returns `None` when there +/// is nothing migratable. +fn legacy_value_to_record( + value: &JsValue, + local_identity_public: Vec, + local_reg_id: u32, +) -> SignalResult> { + let has_reg_id = + js_sys::Reflect::has(value, &JsValue::from_str("registrationId")).unwrap_or(false); + let has_ratchet = + js_sys::Reflect::has(value, &JsValue::from_str("currentRatchet")).unwrap_or(false); + + // (entry, closed) pairs to convert. A flat session is treated as open. + let mut entries: Vec<(JsValue, f64)> = Vec::new(); + if has_reg_id && has_ratchet { + let closed = get_object(value, "indexInfo") + .and_then(|i| get_number(&i, "closed")) + .unwrap_or(-1.0); + entries.push((value.clone(), closed)); + } else { + if !js_sys::Reflect::has(value, &JsValue::from_str("_sessions")).unwrap_or(false) { + return Ok(None); + } + let sessions = get_object(value, "_sessions") + .ok_or_else(|| invalid_js_data("migrate", "Missing _sessions"))?; + let sessions_obj = sessions + .dyn_ref::() + .ok_or_else(|| invalid_js_data("migrate", "Invalid _sessions object"))?; + let keys = js_sys::Object::keys(sessions_obj); + for i in 0..keys.length() { + let entry = + js_sys::Reflect::get(&sessions, &keys.get(i)).map_err(js_to_signal_error)?; + if !js_sys::Reflect::has(&entry, &JsValue::from_str("registrationId")).unwrap_or(false) + { + continue; // not a real session entry + } + let closed = get_object(&entry, "indexInfo") + .and_then(|i| get_number(&i, "closed")) + .unwrap_or(-1.0); + entries.push((entry, closed)); + } + } + if entries.is_empty() { + return Ok(None); + } + + let mut current: Option = None; + let mut previous: Vec<(SessionStructure, f64)> = Vec::new(); + let mut all_chains: Vec = Vec::new(); + let mut all_meta: Vec = Vec::new(); + + for (entry, closed) in entries { + let Some((session, chains, meta)) = + legacy_entry_to_session(&entry, &local_identity_public, local_reg_id)? + else { + continue; + }; + all_chains.extend(chains); + all_meta.push(meta); + // First open session is current; everything else is archived. + if closed < 0.0 && current.is_none() { + current = Some(session); + } else { + previous.push((session, closed)); + } + } + + // Most-recently-closed first (matches libsignal-node's removeOldSessions order). + previous.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + // No open session → leave `current_session` empty (don't promote an archived + // one): libsignal-node refuses to encrypt without one, so mirror that. + let previous_sessions: Vec = previous.into_iter().map(|(s, _)| s).collect(); + if current.is_none() && previous_sessions.is_empty() { + return Ok(None); + } + + Ok(Some(( + RecordStructure { + current_session: current, + previous_sessions, + }, + SessionSeeds { + chains: all_chains, + sessions: all_meta, + }, + ))) +} + +/// Baileys session JSON -> the bridge's `{ record, seeds }` pair (both +/// `Uint8Array`). Inverse of `exportLegacySession`; `null` if not migratable. +/// For round-trip/export use only: the record omits the local identity + reg id +/// (not part of the interchange), so it isn't directly usable for live decryption +/// — the live `load_session` migration injects those from the store. +#[wasm_bindgen(js_name = importLegacySession)] +pub fn import_legacy_session(value: JsValue) -> Result { + let (record, seeds) = match legacy_value_to_record(&value, Vec::new(), 0) + .map_err(|e| JsValue::from_str(&e.to_string()))? + { + Some(pair) => pair, + None => return Ok(JsValue::NULL), + }; + + let out = js_sys::Object::new(); + js_sys::Reflect::set( + &out, + &JsValue::from_str("record"), + &Uint8Array::from(record.encode_to_vec().as_slice()), + )?; + js_sys::Reflect::set( + &out, + &JsValue::from_str("seeds"), + &Uint8Array::from(seeds.encode_to_vec().as_slice()), + )?; + Ok(out.into()) +} + +/// Bare 32-byte DJB pubkey (Baileys tolerates it) -> the 0x05-prefixed 33-byte +/// form wacore requires. Leaves 33-byte keys untouched. +fn ensure_pubkey_33(bytes: Vec) -> Vec { + if bytes.len() == 32 { + let mut prefixed = Vec::with_capacity(33); + prefixed.push(0x05); + prefixed.extend_from_slice(&bytes); + prefixed + } else { + bytes + } +} + +/// Baileys stores each skipped key as the raw seed; wacore wants the post-HKDF +/// split, so derive it via wacore's `MessageKeyGenerator`. Malformed (non-32-byte) +/// seeds are dropped (the peer retries that one message). +fn legacy_message_keys(msg_keys: Vec<(u32, Vec)>) -> Vec { + msg_keys + .into_iter() + .filter_map(|(index, seed)| { + let seed: [u8; 32] = seed.try_into().ok()?; + Some(libsignal::MessageKeyGenerator::new_from_seed(&seed, index).into_pb()) + }) + .collect() +} + #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct JsKeyPairBytes { @@ -767,7 +1229,17 @@ impl SessionStore for JsStorageAdapter { let bytes = if let Some(b) = js_value_to_bytes(&value) { Some(b) } else if is_legacy_session_object(&value) { - self.migrate_legacy_json(value).await? + match self.migrate_legacy_json(value).await? { + Some((record_bytes, seeds)) => { + // Remember the skipped-key seeds so a later write-back can + // reproduce the exact Baileys JSON. + self.cached_seeds + .borrow_mut() + .insert(address_str.clone(), seeds); + Some(record_bytes) + } + None => None, + } } else { None }; @@ -803,12 +1275,16 @@ impl SessionStore for JsStorageAdapter { .borrow_mut() .insert(address_str.clone(), record); - let result = if self.has_store_session_raw() { + // Drop-in (opt-in): persist Baileys JSON (with captured seeds). Raw: + // hand back proto bytes. Default: a `SessionRecord` whose `.serialize()` + // a legacy consumer calls — unchanged from before this feature. + let result = if self.is_drop_in() { + return self.write_session_json(&address_str, &bytes).await; + } else if self.has_store_session_raw() { let uint8 = Uint8Array::from(bytes.as_slice()); self.js_storage.js_store_session_raw(&address_str, &uint8) } else { - let session_record = SessionRecord::new(bytes); - let js_record: JsValue = session_record.into(); + let js_record: JsValue = SessionRecord::new(bytes).into(); self.js_storage.js_store_session(&address_str, js_record) }; @@ -816,7 +1292,6 @@ impl SessionStore for JsStorageAdapter { resolve_maybe_promise(promise_value) .await .map_err(js_to_signal_error)?; - Ok(()) } } @@ -1072,7 +1547,16 @@ impl SenderKeyStore for JsStorageAdapter { self.cached_sender_keys .borrow_mut() .insert(key_id.clone(), record); - let uint8 = Uint8Array::from(bytes.as_slice()); + + // Drop-in mode persists the Baileys sender-key JSON so a group session can + // revert; otherwise keep the native proto bytes. + let out_bytes = if self.is_drop_in() { + crate::legacy_session::sender_key_record_to_legacy_json(&bytes) + .map_err(js_to_signal_error)? + } else { + bytes + }; + let uint8 = Uint8Array::from(out_bytes.as_slice()); let result = self .js_storage diff --git a/test/dropin_gaps.test.ts b/test/dropin_gaps.test.ts new file mode 100644 index 0000000..1f17f2b --- /dev/null +++ b/test/dropin_gaps.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect } from "bun:test"; +import { + ProtocolAddress, + GroupCipher, + GroupSessionBuilder, + SenderKeyName, + importLegacySession, + exportLegacySession, +} from "../dist"; +import { DropInStorage } from "./helpers/dropin_storage"; +import { makeRunningLibsignalPair } from "./helpers/libsignal_store"; + +// First offset of `needle` within `hay`, or -1. +function indexOfBytes(hay: Uint8Array, needle: Uint8Array): number { + outer: for (let i = 0; i + needle.length <= hay.length; i++) { + for (let j = 0; j < needle.length; j++) if (hay[i + j] !== needle[j]) continue outer; + return i; + } + return -1; +} + +// The `_chains[ratchet]` entry of whichever session holds it. +function findChain(exported: any, ratchet: string): any { + for (const s of Object.values(exported._sessions)) { + if (s._chains[ratchet]) return s._chains[ratchet]; + } + return undefined; +} + +// Deterministic base64 of an n-filled buffer (33-byte pubkeys, 32-byte keys). +const b64 = (fill: number, len = 33) => Buffer.alloc(len, fill).toString("base64"); + +type ReceiverChain = [ratchetB64: string, chain: any]; + +function makeEntry(opts: { + baseKey: string; + closed: number; + baseKeyType?: number; + lastRemote?: string; + receiverChains?: ReceiverChain[]; +}): any { + const senderRatchet = b64(10); + const chains: Record = { + [senderRatchet]: { + chainKey: { counter: 5, key: b64(15, 32) }, + chainType: 1, // SENDING + messageKeys: {}, + }, + }; + for (const [ratchet, chain] of opts.receiverChains ?? []) chains[ratchet] = chain; + + return { + registrationId: 555, + currentRatchet: { + ephemeralKeyPair: { pubKey: senderRatchet, privKey: b64(11, 32) }, + lastRemoteEphemeralKey: opts.lastRemote ?? b64(12), + previousCounter: 3, + rootKey: b64(13, 32), + }, + indexInfo: { + baseKey: opts.baseKey, + baseKeyType: opts.baseKeyType ?? 2, + closed: opts.closed, + used: 1, + created: 1, + remoteIdentityKey: b64(14), + }, + _chains: chains, + }; +} + +const roundTrip = (record: any) => { + const { record: rec, seeds } = importLegacySession(record) as { + record: Uint8Array; + seeds: Uint8Array; + }; + return exportLegacySession(rec, seeds); +}; + +describe("Drop-in compatibility gaps", () => { + it("[2] preserves archived sessions across import → export", () => { + const open = b64(1); + const archived = b64(2); + const out = roundTrip({ + _sessions: { + [open]: makeEntry({ baseKey: open, closed: -1 }), + [archived]: makeEntry({ baseKey: archived, closed: 1_700_000_000_000 }), + }, + version: "v1", + }); + + // Both sessions survive; the open one stays open, the other stays archived. + expect(Object.keys(out._sessions).sort()).toEqual([open, archived].sort()); + expect(out._sessions[open].indexInfo.closed).toBe(-1); + expect(out._sessions[archived].indexInfo.closed).toBeGreaterThan(0); + }); + + it("[4] preserves baseKeyType=OURS even with no pendingPreKey", () => { + const bk = b64(3); + const out = roundTrip({ + _sessions: { [bk]: makeEntry({ baseKey: bk, closed: -1, baseKeyType: 1 }) }, + version: "v1", + }); + // Without the sidecar, an acked initiator would export as THEIRS (2). + expect(out._sessions[bk].indexInfo.baseKeyType).toBe(1); + }); + + it("[6] preserves lastRemoteEphemeralKey exactly", () => { + const bk = b64(4); + const lastRemote = b64(21); + const out = roundTrip({ + _sessions: { [bk]: makeEntry({ baseKey: bk, closed: -1, lastRemote: lastRemote }) }, + version: "v1", + }); + expect(out._sessions[bk].currentRatchet.lastRemoteEphemeralKey).toBe(lastRemote); + }); + + it("[5] keeps a closed receiver chain closed (no chainKey.key)", () => { + const bk = b64(5); + const closedRatchet = b64(22); + const out = roundTrip({ + _sessions: { + [bk]: makeEntry({ + baseKey: bk, + closed: -1, + receiverChains: [ + [closedRatchet, { chainKey: { counter: 9 }, chainType: 2, messageKeys: {} }], + ], + }), + }, + version: "v1", + }); + const chain = out._sessions[bk]._chains[closedRatchet]; + expect(chain).toBeDefined(); + expect(chain.chainKey.counter).toBe(9); // counter preserved + expect(chain.chainKey.key).toBeUndefined(); // still closed, not an empty-key live chain + }); + + it("[no-open] does not resurrect an all-closed record as open", () => { + const a = b64(31); + const c = b64(32); + const out = roundTrip({ + _sessions: { + [a]: makeEntry({ baseKey: a, closed: 1_700_000_000_001 }), + [c]: makeEntry({ baseKey: c, closed: 1_700_000_000_000 }), + }, + version: "v1", + }); + // Both archived sessions survive; NONE becomes open (closed === -1). + expect(Object.keys(out._sessions).sort()).toEqual([a, c].sort()); + for (const s of Object.values(out._sessions)) { + expect(s.indexInfo.closed).toBeGreaterThan(0); + } + }); + + it("[meta] preserves used/created/closed timestamps verbatim", () => { + const bk = b64(33); + const out = roundTrip({ + _sessions: { + [bk]: { + ...makeEntry({ baseKey: bk, closed: 1_700_000_009_999 }), + indexInfo: { + baseKey: bk, + baseKeyType: 2, + closed: 1_700_000_009_999, + used: 1_700_000_001_111, + created: 1_700_000_000_222, + remoteIdentityKey: b64(14), + }, + }, + }, + version: "v1", + }); + const ii = out._sessions[bk].indexInfo; + expect(ii.closed).toBe(1_700_000_009_999); + expect(ii.used).toBe(1_700_000_001_111); + expect(ii.created).toBe(1_700_000_000_222); + }); + + it("[validate] drops a tampered seed on export but keeps the untampered ones", async () => { + // Build a real session with cached skipped-key seeds (Bob jumps to m2, so 0 + // and 1 are cached), then corrupt one seed's bytes in the sidecar and confirm + // the export's self-validation drops exactly that one. + const { aliceCipher, bobCipher, bobStore, aliceAddr } = await makeRunningLibsignalPair(); + const msgs = []; + for (let i = 0; i < 3; i++) msgs.push(await aliceCipher.encrypt(Buffer.from(`v${i}`))); + await bobCipher.decryptWhisperMessage(msgs[2].body); + + const serialized = bobStore.getSerializedSession(aliceAddr.toString()); + // Locate a cached (ratchet, index) and its exact seed bytes. + let ratchet = ""; + let index = ""; + let seedB64 = ""; + for (const s of Object.values(serialized._sessions)) { + for (const [rk, ch] of Object.entries(s._chains)) { + const keys = Object.keys(ch.messageKeys); + if (keys.length) { + ratchet = rk; + index = keys[0]!; + seedB64 = ch.messageKeys[index]; + break; + } + } + if (ratchet) break; + } + expect(seedB64).not.toBe(""); + + const { record, seeds } = importLegacySession(serialized) as { + record: Uint8Array; + seeds: Uint8Array; + }; + + // Control: the genuine seed re-derives and is emitted. + expect(findChain(exportLegacySession(record, seeds), ratchet).messageKeys[index]).toBeDefined(); + + // Flip a byte of that exact seed inside the sidecar; the export must drop it. + const tampered = new Uint8Array(seeds); + const at = indexOfBytes(tampered, Buffer.from(seedB64, "base64")); + expect(at).toBeGreaterThanOrEqual(0); + tampered[at]! ^= 0xff; + expect(findChain(exportLegacySession(record, tampered), ratchet).messageKeys[index]).toBeUndefined(); + }); + + it("[bad-base64] fails migration on malformed base64 instead of a broken session", () => { + const bk = b64(36); + const entry = makeEntry({ baseKey: bk, closed: -1 }); + entry.currentRatchet.rootKey = "!!! not base64 !!!"; // corrupt a critical field + // Pre-fix this silently migrated to an empty rootKey (undecryptable session); + // now it must throw so the corruption surfaces at import time. + expect(() => importLegacySession({ _sessions: { [bk]: entry }, version: "v1" })).toThrow(); + }); + + it("[32-byte] normalizes bare 32-byte public keys to 33-byte on import", () => { + const bk32 = Buffer.alloc(32, 7).toString("base64"); // unprefixed base key + const out = roundTrip({ + _sessions: { [bk32]: makeEntry({ baseKey: bk32, closed: -1 }) }, + version: "v1", + }); + // Exactly one session; its baseKey is now the 0x05-prefixed 33-byte form. + const keys = Object.keys(out._sessions); + expect(keys.length).toBe(1); + const decoded = Buffer.from(keys[0]!, "base64"); + expect(decoded.length).toBe(33); + expect(decoded[0]).toBe(0x05); + }); + + it("[chains] closes older receiver chains, keeps the tail live, lastRemote=tail", () => { + const bk = b64(42); + const ratchetA = b64(40); // older receiver chain + const ratchetB = b64(41); // newest (tail) receiver chain + const out = roundTrip({ + _sessions: { + [bk]: makeEntry({ + baseKey: bk, + closed: -1, + lastRemote: ratchetB, // consistent: lastRemote == tail receiver chain + receiverChains: [ + [ratchetA, { chainKey: { counter: 3, key: b64(43, 32) }, chainType: 2, messageKeys: {} }], + [ratchetB, { chainKey: { counter: 7, key: b64(44, 32) }, chainType: 2, messageKeys: {} }], + ], + }), + }, + version: "v1", + }); + const e = out._sessions[bk]; + // Newest receiver chain stays live; the older one is closed (key omitted). + expect(e._chains[ratchetB].chainKey.key).toBeDefined(); + expect(e._chains[ratchetA].chainKey.key).toBeUndefined(); + // Both still carry their counters. + expect(e._chains[ratchetA].chainKey.counter).toBe(3); + expect(e._chains[ratchetB].chainKey.counter).toBe(7); + // lastRemoteEphemeralKey is derived from the current tail chain. + expect(e.currentRatchet.lastRemoteEphemeralKey).toBe(ratchetB); + }); + + it("[3] persists sender keys as Baileys JSON that round-trips through the bridge", async () => { + const aliceStore = new DropInStorage(); + const bobStore = new DropInStorage(); + const groupId = "gaps-group@g.us"; + const aliceAddr = new ProtocolAddress("alice", 1); + const skName = new SenderKeyName(groupId, aliceAddr); + + const skdm = await new GroupSessionBuilder(aliceStore as any).create(skName); + + // The bridge wrote the sender key as libsignal-node JSON (array of states, + // BufferJSON-wrapped seeds) — exactly what Baileys reads back on revert. + const stored = aliceStore.senderKeys.get(skName.toString())!; + const states = JSON.parse(Buffer.from(stored).toString("utf-8")); + expect(Array.isArray(states)).toBe(true); + // Baileys' on-disk shape: Node Buffer.toJSON() = {type:'Buffer', data:[…]}. + expect(states[0].senderChainKey.seed.type).toBe("Buffer"); + expect(Array.isArray(states[0].senderChainKey.seed.data)).toBe(true); + expect(states[0].senderSigningKey.public.type).toBe("Buffer"); + + await new GroupSessionBuilder(bobStore as any).process(skName, skdm); + + const aliceCipher = new GroupCipher(aliceStore as any, groupId, aliceAddr); + const bobCipher = new GroupCipher(bobStore as any, groupId, aliceAddr); + const ct1 = await aliceCipher.encrypt(Buffer.from("g1")); + expect(Buffer.from(await bobCipher.decrypt(ct1)).toString()).toBe("g1"); + + // Revert round-trip: a fresh sender loaded from the stored JSON (now at the + // advanced iteration) keeps producing messages the receiver decrypts. + const alice2 = new DropInStorage(aliceStore.ourIdentityKeyPair, aliceStore.ourRegistrationId); + alice2.senderKeys.set(skName.toString(), aliceStore.senderKeys.get(skName.toString())!); + const alice2Cipher = new GroupCipher(alice2 as any, groupId, aliceAddr); + const ct2 = await alice2Cipher.encrypt(Buffer.from("g2")); + expect(Buffer.from(await bobCipher.decrypt(ct2)).toString()).toBe("g2"); + }); +}); diff --git a/test/dropin_store.test.ts b/test/dropin_store.test.ts new file mode 100644 index 0000000..7818bd6 --- /dev/null +++ b/test/dropin_store.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from "bun:test"; +import { + ProtocolAddress, + SessionBuilder, + SessionCipher, + generateSignedPreKey, + generatePreKey, +} from "../dist"; +import { LibsignalStore, libsignalNode } from "./helpers/libsignal_store"; +import { DropInStorage } from "./helpers/dropin_storage"; + +async function bridgeHandshake(aliceStore: DropInStorage, bobStore: DropInStorage) { + const aliceAddr = new ProtocolAddress("alice", 1); + const bobAddr = new ProtocolAddress("bob", 1); + + aliceStore.trustIdentity("bob", bobStore.ourIdentityKeyPair.pubKey); + bobStore.trustIdentity("alice", aliceStore.ourIdentityKeyPair.pubKey); + + const spk = generateSignedPreKey(bobStore.ourIdentityKeyPair, 1); + const pk = generatePreKey(100); + bobStore.storeSignedPreKey(spk.keyId, spk); + bobStore.storePreKey(pk.keyId, pk.keyPair); + + await new SessionBuilder(aliceStore as any, bobAddr).processPreKeyBundle({ + registrationId: bobStore.ourRegistrationId, + identityKey: bobStore.ourIdentityKeyPair.pubKey, + signedPreKey: { keyId: spk.keyId, publicKey: spk.keyPair.pubKey, signature: spk.signature }, + preKey: { keyId: pk.keyId, publicKey: pk.keyPair.pubKey }, + }); + + // Alice → Bob (prekey), Bob → Alice (reply): both ends end on a running ratchet. + const a1 = await new SessionCipher(aliceStore as any, bobAddr).encrypt(Buffer.from("hi")); + await new SessionCipher(bobStore as any, aliceAddr).decryptPreKeyWhisperMessage( + new Uint8Array(a1.body), + ); + const r1 = await new SessionCipher(bobStore as any, aliceAddr).encrypt(Buffer.from("ack")); + await new SessionCipher(aliceStore as any, bobAddr).decryptWhisperMessage( + new Uint8Array(r1.body), + ); + + return { aliceAddr, bobAddr }; +} + +describe("Drop-in storage (bridge persists libsignal-node JSON)", () => { + it("round-trips its own sessions through JSON disk and writes Baileys-shaped data", async () => { + const aliceStore = new DropInStorage(); + const bobStore = new DropInStorage(); + const { aliceAddr, bobAddr } = await bridgeHandshake(aliceStore, bobStore); + + // The bridge wrote a libsignal-node session object (not proto bytes). + const stored = bobStore.sessions.get(aliceAddr.toString()); + expect(stored).toBeDefined(); + expect(stored.version).toBe("v1"); + expect(typeof stored._sessions).toBe("object"); + expect(stored instanceof Uint8Array).toBe(false); + + // A fresh SessionCipher (new adapter, cold cache) must reload from the JSON + // on disk and keep decrypting — exercising store-JSON → load-JSON → migrate. + const alice = new SessionCipher(aliceStore as any, bobAddr); + for (let i = 0; i < 3; i++) { + const ct = await alice.encrypt(Buffer.from(`m${i}`)); + const bob = new SessionCipher(bobStore as any, aliceAddr); // cold cache each time + const pt = await bob.decryptWhisperMessage(new Uint8Array(ct.body)); + expect(Buffer.from(pt).toString()).toBe(`m${i}`); + } + }); + + it("captures seeds for BRIDGE-created skipped keys so revert decrypts them too", async () => { + const aliceStore = new DropInStorage(); + const bobStore = new DropInStorage(); + const { aliceAddr, bobAddr } = await bridgeHandshake(aliceStore, bobStore); + + // Alice (one warm cipher) sends a burst on a single sending chain. + const alice = new SessionCipher(aliceStore as any, bobAddr); + const msgs = []; + for (let i = 0; i < 5; i++) msgs.push(await alice.encrypt(Buffer.from(`burst-${i}`))); + + // Bob establishes the chain with m0, then jumps to m3 → the BRIDGE creates + // skipped keys for 1 and 2 (each decrypt a cold cipher → through-JSON path). + await new SessionCipher(bobStore as any, aliceAddr).decryptWhisperMessage( + new Uint8Array(msgs[0].body), + ); + await new SessionCipher(bobStore as any, aliceAddr).decryptWhisperMessage( + new Uint8Array(msgs[3].body), + ); + + // Those skipped keys are now in the persisted JSON as real seeds (not lost). + const stored = bobStore.sessions.get(aliceAddr.toString()); + const cached = Object.values(stored._sessions) + .flatMap((s: any) => Object.values(s._chains)) + .reduce((n: number, ch: any) => n + Object.keys(ch.messageKeys).length, 0); + expect(cached).toBeGreaterThanOrEqual(2); + + // A real libsignal-node loads the exported session and decrypts the + // bridge-created skipped messages (1, 2) plus the forward one (4). + const libBob = new LibsignalStore(); + libBob.ourIdentityKeyPair = bobStore.ourIdentityKeyPair; + libBob.isTrustedIdentity("alice", aliceStore.ourIdentityKeyPair.pubKey); + libBob.setSerializedSession(aliceAddr.toString(), stored); + const libAliceAddr = new libsignalNode.ProtocolAddress("alice", 1); + const cipher = new libsignalNode.SessionCipher(libBob, libAliceAddr); + + expect(Buffer.from(await cipher.decryptWhisperMessage(Buffer.from(msgs[1].body))).toString()).toBe("burst-1"); + expect(Buffer.from(await cipher.decryptWhisperMessage(Buffer.from(msgs[2].body))).toString()).toBe("burst-2"); + expect(Buffer.from(await cipher.decryptWhisperMessage(Buffer.from(msgs[4].body))).toString()).toBe("burst-4"); + }); + + it("captures seeds even when the first message of a NEW ratchet chain is delayed", async () => { + const aliceStore = new DropInStorage(); + const bobStore = new DropInStorage(); + const { aliceAddr, bobAddr } = await bridgeHandshake(aliceStore, bobStore); + + // Alice's burst lives on a brand-new sending ratchet (post-handshake). + const alice = new SessionCipher(aliceStore as any, bobAddr); + const msgs = []; + for (let i = 0; i < 5; i++) msgs.push(await alice.encrypt(Buffer.from(`nr-${i}`))); + + // Bob's FIRST decrypt on that ratchet is m2 → the receiver chain doesn't + // exist yet, so capture must replicate the DH ratchet to seed 0 and 1. + await new SessionCipher(bobStore as any, aliceAddr).decryptWhisperMessage( + new Uint8Array(msgs[2].body), + ); + + const stored = bobStore.sessions.get(aliceAddr.toString()); + const cached = Object.values(stored._sessions) + .flatMap((s: any) => Object.values(s._chains)) + .reduce((n: number, ch: any) => n + Object.keys(ch.messageKeys).length, 0); + expect(cached).toBeGreaterThanOrEqual(2); // seeds for 0 and 1 + + const libBob = new LibsignalStore(); + libBob.ourIdentityKeyPair = bobStore.ourIdentityKeyPair; + libBob.isTrustedIdentity("alice", aliceStore.ourIdentityKeyPair.pubKey); + libBob.setSerializedSession(aliceAddr.toString(), stored); + const cipher = new libsignalNode.SessionCipher( + libBob, + new libsignalNode.ProtocolAddress("alice", 1), + ); + expect(Buffer.from(await cipher.decryptWhisperMessage(Buffer.from(msgs[0].body))).toString()).toBe("nr-0"); + expect(Buffer.from(await cipher.decryptWhisperMessage(Buffer.from(msgs[1].body))).toString()).toBe("nr-1"); + expect(Buffer.from(await cipher.decryptWhisperMessage(Buffer.from(msgs[3].body))).toString()).toBe("nr-3"); + }); + + it("writes sessions a real libsignal-node can load and decrypt (true revert)", async () => { + const aliceStore = new DropInStorage(); + const bobStore = new DropInStorage(); + const { aliceAddr, bobAddr } = await bridgeHandshake(aliceStore, bobStore); + + // Bridge Alice sends a plain WhisperMessage (type 2 in libsignal's Rust + // numbering; PreKey would be 3 — so this confirms pendingPreKey was cleared). + const ct = await new SessionCipher(aliceStore as any, bobAddr).encrypt(Buffer.from("revert")); + expect(ct.type).toBe(2); + + // Hand Bob's bridge-written JSON to a real libsignal-node Bob (same device + // identity) and decrypt — proves the on-disk format is genuinely Baileys. + const libBob = new LibsignalStore(); + libBob.ourIdentityKeyPair = bobStore.ourIdentityKeyPair; + libBob.isTrustedIdentity("alice", aliceStore.ourIdentityKeyPair.pubKey); + libBob.setSerializedSession(aliceAddr.toString(), bobStore.sessions.get(aliceAddr.toString())); + + // libsignal-node needs its own ProtocolAddress (same "alice.1" key string). + const libAliceAddr = new libsignalNode.ProtocolAddress("alice", 1); + const cipher = new libsignalNode.SessionCipher(libBob, libAliceAddr); + const pt = await cipher.decryptWhisperMessage(Buffer.from(ct.body)); + expect(Buffer.from(pt).toString()).toBe("revert"); + }); +}); diff --git a/test/helpers/dropin_storage.ts b/test/helpers/dropin_storage.ts new file mode 100644 index 0000000..5fdff8f --- /dev/null +++ b/test/helpers/dropin_storage.ts @@ -0,0 +1,78 @@ +// A Baileys-style store that persists sessions/sender-keys as the serialized +// values the bridge hands to storeSession/storeSenderKey. It does NOT implement +// storeSessionRaw, so the bridge takes its drop-in path and writes libsignal-node +// JSON to "disk" — the format a reverted Baileys can read. +import { + generateIdentityKeyPair, + generateRegistrationId, + type KeyPair, +} from "../../dist"; + +export class DropInStorage { + // Opt into the Baileys-JSON drop-in format (the bridge defaults to proto). + dropInBaileysFormat = true; + sessions = new Map(); + senderKeys = new Map(); + private identities = new Map(); + private preKeys = new Map(); + private signedPreKeys = new Map(); + ourIdentityKeyPair: KeyPair; + ourRegistrationId: number; + + constructor(identity?: KeyPair, regId?: number) { + this.ourIdentityKeyPair = identity ?? generateIdentityKeyPair(); + this.ourRegistrationId = regId ?? generateRegistrationId(); + } + // Clone on both boundaries so the in-memory map behaves like real on-disk + // (serialized) persistence: a caller can't mutate stored state without an + // explicit store, and a stored object can't be changed after the fact. + async loadSession(address: string) { + const s = this.sessions.get(address); // the libsignal-node JSON object, as stored + return s === undefined ? undefined : structuredClone(s); + } + async storeSession(address: string, session: any) { + this.sessions.set(address, structuredClone(session)); + } + // intentionally no storeSessionRaw → drop-in (JSON) mode for sessions + sender keys + async loadSenderKey(id: string) { + return this.senderKeys.get(id); + } + async storeSenderKey(id: string, record: Uint8Array) { + this.senderKeys.set(id, new Uint8Array(record)); + } + async getOurIdentity() { + return this.ourIdentityKeyPair; + } + async getOurRegistrationId() { + return this.ourRegistrationId; + } + // libsignal `SignalStorage` interface method (the bridge's SessionCipher calls + // it). TOFU: registers the identity on first sight (an intentional setup side + // effect), then verifies equality thereafter. + async isTrustedIdentity(id: string, key: Uint8Array) { + const e = this.identities.get(id); + if (!e) { + this.identities.set(id, new Uint8Array(key)); // copy: caller may reuse the buffer + return true; + } + return Buffer.from(e).equals(Buffer.from(key)); + } + trustIdentity(id: string, key: Uint8Array) { + this.identities.set(id, new Uint8Array(key)); + } + async loadPreKey(id: number) { + return this.preKeys.get(id); + } + async removePreKey(id: number) { + this.preKeys.delete(id); + } + storePreKey(id: number, kp: KeyPair) { + this.preKeys.set(id, kp); + } + storeSignedPreKey(id: number, spk: any) { + this.signedPreKeys.set(id, { ...spk, timestamp: Date.now() }); + } + async loadSignedPreKey(id: number) { + return this.signedPreKeys.get(id); + } +} diff --git a/test/helpers/libsignal_store.ts b/test/helpers/libsignal_store.ts new file mode 100644 index 0000000..2ec9538 --- /dev/null +++ b/test/helpers/libsignal_store.ts @@ -0,0 +1,115 @@ +// Minimal in-memory SignalStorage for the upstream @whiskeysockets/libsignal-node +// side, shared by interop/round-trip tests. Mirrors the shape benches/signal.ts +// uses, plus serialized-session accessors for migration tests. +import * as libsignalNode from "@whiskeysockets/libsignal-node"; +import type { SignalStorage } from "@whiskeysockets/libsignal-node"; + +const keyhelper = (libsignalNode as any).keyhelper; + +export class LibsignalStore implements SignalStorage { + private sessions = new Map(); + private identities = new Map(); + private preKeys = new Map(); + private signedPreKeys = new Map(); + + public ourIdentityKeyPair = keyhelper.generateIdentityKeyPair(); + public ourRegistrationId = keyhelper.generateRegistrationId(); + + async loadSession(address: string) { + const s = this.sessions.get(address); + return s ? libsignalNode.SessionRecord.deserialize(s) : undefined; + } + async storeSession(address: string, record: any) { + this.sessions.set(address, record.serialize()); + } + getSerializedSession(address: string) { + return this.sessions.get(address); + } + setSerializedSession(address: string, serialized: any) { + this.sessions.set(address, serialized); + } + getOurIdentity() { + return this.ourIdentityKeyPair; + } + getOurRegistrationId() { + return this.ourRegistrationId; + } + // libsignal `SignalStorage` interface method. TOFU: registers the identity on + // first sight (intentional — also used to pre-trust a peer in test setup), then + // verifies equality. Name is fixed by the interface, so it can't be renamed. + isTrustedIdentity(id: string, key: Uint8Array) { + const k = Buffer.from(key); + const e = this.identities.get(id); + if (!e) { + this.identities.set(id, k); + return true; + } + return e.equals(k); + } + async loadPreKey(id: number) { + return this.preKeys.get(id); + } + removePreKey(id: number) { + this.preKeys.delete(id); + } + storePreKey(id: number, kp: any) { + this.preKeys.set(id, kp); + } + getOurSignedPreKey() { + return this.signedPreKeys.values().next().value; + } + storeSignedPreKey(id: number, spk: any) { + this.signedPreKeys.set(id, spk); + } + loadSignedPreKey(id?: number) { + const spk = + typeof id === "number" && this.signedPreKeys.has(id) + ? this.signedPreKeys.get(id) + : this.signedPreKeys.values().next().value; + return spk?.keyPair; + } +} + +/** + * Establish a running (post-handshake) libsignal-node session between two fresh + * stores and return the ciphers + addresses. After this, Alice's next encrypts + * are plain WhisperMessages on a fresh sending chain. + */ +export async function makeRunningLibsignalPair() { + const aliceStore = new LibsignalStore(); + const bobStore = new LibsignalStore(); + const aliceAddr = new libsignalNode.ProtocolAddress("alice", 1); + const bobAddr = new libsignalNode.ProtocolAddress("bob", 1); + + const bobSpk = keyhelper.generateSignedPreKey(bobStore.ourIdentityKeyPair, 1); + const bobPk = keyhelper.generatePreKey(100); + bobStore.storeSignedPreKey(bobSpk.keyId, bobSpk); + bobStore.storePreKey(bobPk.keyId, bobPk.keyPair); + + aliceStore.isTrustedIdentity("bob", bobStore.ourIdentityKeyPair.pubKey); + bobStore.isTrustedIdentity("alice", aliceStore.ourIdentityKeyPair.pubKey); + + const builder = new libsignalNode.SessionBuilder(aliceStore, bobAddr); + await builder.initOutgoing({ + registrationId: bobStore.ourRegistrationId, + identityKey: bobStore.ourIdentityKeyPair.pubKey, + signedPreKey: { + keyId: bobSpk.keyId, + publicKey: bobSpk.keyPair.pubKey, + signature: bobSpk.signature, + }, + preKey: { keyId: bobPk.keyId, publicKey: bobPk.keyPair.pubKey }, + }); + + const aliceCipher = new libsignalNode.SessionCipher(aliceStore, bobAddr); + const bobCipher = new libsignalNode.SessionCipher(bobStore, aliceAddr); + + const hello = await aliceCipher.encrypt(Buffer.from("hello")); + await bobCipher.decryptPreKeyWhisperMessage(hello.body); + const reply = await bobCipher.encrypt(Buffer.from("reply")); + await aliceCipher.decryptWhisperMessage(reply.body); + + return { aliceStore, bobStore, aliceAddr, bobAddr, aliceCipher, bobCipher }; +} + +export { libsignalNode }; diff --git a/test/legacy_proto_storage.test.ts b/test/legacy_proto_storage.test.ts new file mode 100644 index 0000000..b671363 --- /dev/null +++ b/test/legacy_proto_storage.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from "bun:test"; +import { + ProtocolAddress, + SessionBuilder, + SessionCipher, + SessionRecord, + generateSignedPreKey, + generatePreKey, + generateIdentityKeyPair, + generateRegistrationId, + type KeyPair, + type SignedPreKey, +} from "../dist"; + +// Mirrors the real Baileys `signalStorage` contract (src/Signal/libsignal.ts): +// storeSession receives a bridge `SessionRecord` and calls `.serialize()`, and it +// implements neither `storeSessionRaw` nor `dropInBaileysFormat`. This is the +// default (native proto) mode — the bridge must hand it a SessionRecord, NOT a +// plain JSON object (which would throw `record.serialize is not a function`). +class LegacyProtoStorage { + sessions = new Map(); + private identities = new Map(); + private preKeys = new Map(); + private signedPreKeys = new Map(); + ourIdentityKeyPair: KeyPair = generateIdentityKeyPair(); + ourRegistrationId: number = generateRegistrationId(); + + // no storeSessionRaw, no dropInBaileysFormat → default SessionRecord mode + async loadSession(address: string) { + const s = this.sessions.get(address); + return s ? new Uint8Array(s) : null; + } + async storeSession(address: string, session: SessionRecord) { + // Exactly what Baileys does — crashes if handed a plain object. + this.sessions.set(address, session.serialize()); + } + isTrustedIdentity(id: string, key: Uint8Array) { + const e = this.identities.get(id); + if (!e) { + this.identities.set(id, new Uint8Array(key)); // copy: caller may reuse the buffer + return true; + } + return Buffer.from(e).equals(Buffer.from(key)); + } + trustIdentity(id: string, key: Uint8Array) { + this.identities.set(id, new Uint8Array(key)); + } + async getOurIdentity() { + return this.ourIdentityKeyPair; + } + async getOurRegistrationId() { + return this.ourRegistrationId; + } + async loadPreKey(id: number) { + return this.preKeys.get(id); + } + async removePreKey(id: number) { + this.preKeys.delete(id); + } + storePreKey(id: number, kp: KeyPair) { + this.preKeys.set(id, kp); + } + storeSignedPreKey(id: number, spk: SignedPreKey) { + this.signedPreKeys.set(id, { ...spk, timestamp: Date.now() }); + } + async loadSignedPreKey(id: number) { + return this.signedPreKeys.get(id); + } +} + +describe("Default (proto) storage contract is preserved", () => { + it("hands storeSession a SessionRecord (.serialize works) and round-trips", async () => { + const aliceStore = new LegacyProtoStorage(); + const bobStore = new LegacyProtoStorage(); + const aliceAddr = new ProtocolAddress("alice", 1); + const bobAddr = new ProtocolAddress("bob", 1); + + const spk = generateSignedPreKey(bobStore.ourIdentityKeyPair, 1); + const pk = generatePreKey(100); + bobStore.storeSignedPreKey(spk.keyId, spk); + bobStore.storePreKey(pk.keyId, pk.keyPair); + + await new SessionBuilder(aliceStore as any, bobAddr).processPreKeyBundle({ + registrationId: bobStore.ourRegistrationId, + identityKey: bobStore.ourIdentityKeyPair.pubKey, + signedPreKey: { keyId: spk.keyId, publicKey: spk.keyPair.pubKey, signature: spk.signature }, + preKey: { keyId: pk.keyId, publicKey: pk.keyPair.pubKey }, + }); + + // If the bridge regressed to passing a plain object, this encrypt's + // store_session would throw `session.serialize is not a function`. + const aliceCipher = new SessionCipher(aliceStore as any, bobAddr); + const bobCipher = new SessionCipher(bobStore as any, aliceAddr); + + const ct = await aliceCipher.encrypt(Buffer.from("hi bob")); + const pt = await bobCipher.decryptPreKeyWhisperMessage(new Uint8Array(ct.body)); + expect(Buffer.from(pt).toString()).toBe("hi bob"); + + // Reply completes the ratchet; then replay both ciphers off their persisted + // (re-loaded) proto sessions with a non-UTF8 binary payload. + const reply = await bobCipher.encrypt(Buffer.from("ack")); + const replyPt = await aliceCipher.decryptWhisperMessage(new Uint8Array(reply.body)); + expect(Buffer.from(replyPt).toString()).toBe("ack"); + + const bin = new Uint8Array([0, 1, 2, 255, 254, 128, 13, 10, 0]); + const ctBin = await aliceCipher.encrypt(bin); + const ptBin = await bobCipher.decryptWhisperMessage(new Uint8Array(ctBin.body)); + expect(Buffer.from(ptBin).equals(Buffer.from(bin))).toBe(true); + + // Stored value is native proto bytes (a real SessionRecord, not Baileys JSON). + const stored = aliceStore.sessions.get(bobAddr.toString())!; + expect(stored).toBeInstanceOf(Uint8Array); + expect(SessionRecord.deserialize(stored).haveOpenSession()).toBe(true); + }); +}); diff --git a/test/legacy_roundtrip.test.ts b/test/legacy_roundtrip.test.ts new file mode 100644 index 0000000..5cabd2c --- /dev/null +++ b/test/legacy_roundtrip.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from "bun:test"; +import { importLegacySession, exportLegacySession } from "../dist"; +import { + LibsignalStore, + makeRunningLibsignalPair, + libsignalNode, +} from "./helpers/libsignal_store"; + +// Sum of all cached skipped message keys across every chain of every session. +function countMessageKeys(record: any): number { + return Object.values(record._sessions) + .flatMap((s: any) => Object.values(s._chains)) + .reduce((n: number, ch: any) => n + Object.keys(ch.messageKeys).length, 0); +} + +// Flatten { baseKey -> { ratchetKey -> { counter -> seedB64 } } } for comparison. +function seedsOf(record: any): Record>> { + const out: Record = {}; + for (const [baseKey, sess] of Object.entries(record._sessions)) { + out[baseKey] = {}; + for (const [ratchet, ch] of Object.entries(sess._chains)) { + if (Object.keys(ch.messageKeys).length) out[baseKey][ratchet] = ch.messageKeys; + } + } + return out; +} + +describe("Legacy session reverse round-trip (bridge ↔ libsignal-node)", () => { + it("import → export reproduces a session libsignal-node can fully use", async () => { + const { aliceStore, bobStore, aliceAddr, aliceCipher, bobCipher } = + await makeRunningLibsignalPair(); + + const enc = (s: string) => aliceCipher.encrypt(Buffer.from(s)); + const m0 = await enc("msg-0"); + const m1 = await enc("msg-1"); + const m2 = await enc("msg-2"); + const m3 = await enc("msg-3"); + const m4 = await enc("msg-4"); + + // Bob receives m2 first → caches skipped keys for 0 and 1. + await bobCipher.decryptWhisperMessage(m2.body); + + const original = bobStore.getSerializedSession(aliceAddr.toString()); + expect(countMessageKeys(original)).toBeGreaterThanOrEqual(2); + + // Round-trip through the bridge's persisted pair. + const { record, seeds } = importLegacySession(original) as { + record: Uint8Array; + seeds: Uint8Array; + }; + expect(seeds.length).toBeGreaterThan(0); // seeds were captured + const exported = exportLegacySession(record, seeds); + + // 1) Structural: the skipped-key seeds survive byte-for-byte. + expect(seedsOf(exported)).toEqual(seedsOf(original)); + + // 2) Functional: a FRESH libsignal-node Bob (same device identity) loads the + // exported session and decrypts both the cached (0,1) and forward (3,4) + // messages — proving the reverse is lossless end-to-end. + const bob2 = new LibsignalStore(); + bob2.ourIdentityKeyPair = bobStore.ourIdentityKeyPair; + bob2.isTrustedIdentity("alice", aliceStore.ourIdentityKeyPair.pubKey); + bob2.setSerializedSession(aliceAddr.toString(), exported); + + const cipher = new libsignalNode.SessionCipher(bob2, aliceAddr); + expect(Buffer.from(await cipher.decryptWhisperMessage(m0.body)).toString()).toBe("msg-0"); + expect(Buffer.from(await cipher.decryptWhisperMessage(m1.body)).toString()).toBe("msg-1"); + expect(Buffer.from(await cipher.decryptWhisperMessage(m3.body)).toString()).toBe("msg-3"); + expect(Buffer.from(await cipher.decryptWhisperMessage(m4.body)).toString()).toBe("msg-4"); + }); + + it("round-trips a pending (initiator) session: pendingPreKey + baseKeyType OURS", async () => { + const aliceStore = new LibsignalStore(); + const bobStore = new LibsignalStore(); + const bobAddr = new libsignalNode.ProtocolAddress("bob", 1); + const keyhelper = (libsignalNode as any).keyhelper; + + const spk = keyhelper.generateSignedPreKey(bobStore.ourIdentityKeyPair, 1); + const pk = keyhelper.generatePreKey(100); + bobStore.storeSignedPreKey(spk.keyId, spk); + bobStore.storePreKey(pk.keyId, pk.keyPair); + aliceStore.isTrustedIdentity("bob", bobStore.ourIdentityKeyPair.pubKey); + + // initOutgoing leaves Alice with a pending (not-yet-acked) session. + const builder = new libsignalNode.SessionBuilder(aliceStore, bobAddr); + await builder.initOutgoing({ + registrationId: bobStore.ourRegistrationId, + identityKey: bobStore.ourIdentityKeyPair.pubKey, + signedPreKey: { keyId: spk.keyId, publicKey: spk.keyPair.pubKey, signature: spk.signature }, + preKey: { keyId: pk.keyId, publicKey: pk.keyPair.pubKey }, + }); + + const original = aliceStore.getSerializedSession(bobAddr.toString()); + const [orig] = Object.values(original._sessions); + expect(orig.pendingPreKey).toBeDefined(); + expect(orig.indexInfo.baseKeyType).toBe(1); // OURS + + const { record, seeds } = importLegacySession(original) as { + record: Uint8Array; + seeds: Uint8Array; + }; + const exported = exportLegacySession(record, seeds); + const [out] = Object.values(exported._sessions); + + expect(out.pendingPreKey).toBeDefined(); + expect(out.pendingPreKey.baseKey).toBe(orig.pendingPreKey.baseKey); + expect(out.pendingPreKey.preKeyId).toBe(orig.pendingPreKey.preKeyId); + expect(out.pendingPreKey.signedKeyId).toBe(orig.pendingPreKey.signedKeyId); + expect(out.indexInfo.baseKeyType).toBe(1); // OURS preserved (pendingPreKey present) + }); + + it("preserves core session fields (registrationId, rootKey, identity, baseKey)", async () => { + const { bobStore, aliceAddr, aliceCipher, bobCipher } = + await makeRunningLibsignalPair(); + + // One in-order exchange so the chains are populated. + const m = await aliceCipher.encrypt(Buffer.from("hi")); + await bobCipher.decryptWhisperMessage(m.body); + + const original = bobStore.getSerializedSession(aliceAddr.toString()); + const { record, seeds } = importLegacySession(original) as { + record: Uint8Array; + seeds: Uint8Array; + }; + const exported = exportLegacySession(record, seeds); + + const [origEntry] = Object.values(original._sessions); + const [outEntry] = Object.values(exported._sessions); + + expect(exported.version).toBe("v1"); + expect(Object.keys(exported._sessions)).toEqual(Object.keys(original._sessions)); // baseKey + expect(outEntry.registrationId).toBe(origEntry.registrationId); + expect(outEntry.currentRatchet.rootKey).toBe(origEntry.currentRatchet.rootKey); + expect(outEntry.currentRatchet.ephemeralKeyPair.pubKey).toBe( + origEntry.currentRatchet.ephemeralKeyPair.pubKey, + ); + expect(outEntry.currentRatchet.ephemeralKeyPair.privKey).toBe( + origEntry.currentRatchet.ephemeralKeyPair.privKey, + ); + expect(outEntry.indexInfo.remoteIdentityKey).toBe(origEntry.indexInfo.remoteIdentityKey); + expect(outEntry.indexInfo.baseKey).toBe(origEntry.indexInfo.baseKey); + }); +}); diff --git a/test/session_info.test.ts b/test/session_info.test.ts new file mode 100644 index 0000000..88edaae --- /dev/null +++ b/test/session_info.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "bun:test"; +import { + generatePreKey, + generateSignedPreKey, + ProtocolAddress, + SessionBuilder, + SessionCipher, + SessionRecord, +} from "../dist"; +import { FakeStorage } from "./helpers/fake_storage"; + +// SessionRecord.sessionInfo() exposes the open session's baseKey + remote +// registrationId — the libsignal-node getOpenSession().indexInfo.baseKey / +// registrationId equivalents that Baileys' retry protections read. +describe("SessionRecord.sessionInfo", () => { + it("returns the shared baseKey + peer registrationId for an established session", async () => { + const aliceStore = new FakeStorage(); + const bobStore = new FakeStorage(); + const aliceAddr = new ProtocolAddress("alice", 1); + const bobAddr = new ProtocolAddress("bob", 1); + + const spk = generateSignedPreKey(bobStore.ourIdentityKeyPair, 1); + const pk = generatePreKey(100); + bobStore.storeSignedPreKey(spk.keyId, spk); + bobStore.storePreKey(pk.keyId, pk.keyPair); + + await new SessionBuilder(aliceStore as any, bobAddr).processPreKeyBundle({ + registrationId: bobStore.ourRegistrationId, + identityKey: bobStore.ourIdentityKeyPair.pubKey, + signedPreKey: { + keyId: spk.keyId, + publicKey: spk.keyPair.pubKey, + signature: spk.signature, + }, + preKey: { keyId: pk.keyId, publicKey: pk.keyPair.pubKey }, + }); + + // Drive the handshake both ways so each side persists its own SessionRecord. + const ct = await new SessionCipher(aliceStore as any, bobAddr).encrypt( + Buffer.from("hi bob"), + ); + await new SessionCipher(bobStore as any, aliceAddr).decryptPreKeyWhisperMessage( + new Uint8Array(ct.body), + ); + + const aliceInfo = SessionRecord.deserialize( + aliceStore.getSession(bobAddr.toString())!, + ).sessionInfo(); + const bobInfo = SessionRecord.deserialize( + bobStore.getSession(aliceAddr.toString())!, + ).sessionInfo(); + + expect(aliceInfo).toBeTruthy(); + expect(bobInfo).toBeTruthy(); + + // baseKey is the X3DH base key (33-byte DJB pubkey: 0x05 + 32), and it indexes + // the session — so both peers must see the SAME value. + expect(aliceInfo!.baseKey).toBeInstanceOf(Uint8Array); + expect(aliceInfo!.baseKey.length).toBe(33); + expect(aliceInfo!.baseKey[0]).toBe(5); + expect(Buffer.from(bobInfo!.baseKey).equals(Buffer.from(aliceInfo!.baseKey))).toBe(true); + + // remote registrationId identifies the peer device (cross-checked on each side). + expect(aliceInfo!.registrationId).toBe(bobStore.ourRegistrationId); + expect(bobInfo!.registrationId).toBe(aliceStore.ourRegistrationId); + }); + + it("returns undefined when there is no open session", () => { + // Mirrors haveOpenSession() === false for a fresh/empty record. + const empty = SessionRecord.deserialize(new Uint8Array()); + expect(empty.haveOpenSession()).toBe(false); + expect(empty.sessionInfo()).toBeUndefined(); + }); + + it("returns undefined for a legacy libsignal-node JSON record (reset to empty)", () => { + const legacyJson = { + _sessions: { + BXqk9qn8XfEUVVcLkKn1L8h8KqzaeErLOS96ZZmrsoBu: { + registrationId: 1210404435, + indexInfo: { baseKey: "BXqk9qn8XfEUVVcLkKn1L8h8KqzaeErLOS96ZZmrsoBu" }, + }, + }, + version: "v1", + }; + const record = SessionRecord.deserialize(legacyJson); + expect(record.haveOpenSession()).toBe(false); + expect(record.sessionInfo()).toBeUndefined(); + }); +}); diff --git a/test/skipped_key_migration.test.ts b/test/skipped_key_migration.test.ts new file mode 100644 index 0000000..ef9b346 --- /dev/null +++ b/test/skipped_key_migration.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "bun:test"; +import { ProtocolAddress, SessionCipher } from "../dist"; +import { DropInStorage } from "./helpers/dropin_storage"; +import { makeRunningLibsignalPair } from "./helpers/libsignal_store"; + +describe("Skipped-key migration (libsignal-node → bridge)", () => { + it("decrypts out-of-order messages whose skipped keys came from an upstream session", async () => { + const { bobStore, aliceStore, aliceAddr, aliceCipher, bobCipher } = + await makeRunningLibsignalPair(); + + const enc = (s: string) => aliceCipher.encrypt(Buffer.from(s)); + // Alice sends several plain WhisperMessages on the same sending chain. + const m0 = await enc("msg-0"); + const m1 = await enc("msg-1"); + const m2 = await enc("msg-2"); + const m3 = await enc("msg-3"); + const m4 = await enc("msg-4"); + expect(m0.type).toBe(1); // WhisperMessage (not prekey) + + // Bob receives the LAST one first → libsignal-node caches the skipped + // message keys for counters 0 and 1 as raw seeds in the session. + const p2 = await bobCipher.decryptWhisperMessage(m2.body); + expect(Buffer.from(p2).toString()).toBe("msg-2"); + + const serialized = bobStore.getSerializedSession(aliceAddr.toString()); + const messageKeyCount = Object.values(serialized._sessions) + .flatMap((s: any) => Object.values(s._chains)) + .reduce((n: number, ch: any) => n + Object.keys(ch.messageKeys).length, 0); + expect(messageKeyCount).toBeGreaterThanOrEqual(2); // 0 and 1 are cached + + // Migrate that exact session into a fresh bridge store and decrypt the + // earlier messages with the BRIDGE. A DropInStorage (no storeSessionRaw) + // exercises the drop-in path: migrate-on-load + JSON write-back on store. + // Pre-fix this corrupted the cached keys (seed stuffed into cipherKey, + // mac/iv zeroed) and decryption failed. + const bridgeBob = new DropInStorage(); + // A real drop-in shares the same device identity; the WhisperMessage MAC is + // keyed on both parties' identity keys, so the bridge must reuse Bob's. + bridgeBob.ourIdentityKeyPair = { + pubKey: new Uint8Array(bobStore.ourIdentityKeyPair.pubKey), + privKey: new Uint8Array(bobStore.ourIdentityKeyPair.privKey), + }; + // @ts-ignore — feed the upstream JSON so the bridge migration path runs. + bridgeBob.loadSession = async () => serialized; + bridgeBob.trustIdentity("alice", aliceStore.ourIdentityKeyPair.pubKey); + + const bridgeCipher = new SessionCipher(bridgeBob, new ProtocolAddress("alice", 1)); + + // Cached (skipped) keys: counters 0 and 1 come from the migrated seeds. + const out0 = await bridgeCipher.decryptWhisperMessage(new Uint8Array(m0.body)); + const out1 = await bridgeCipher.decryptWhisperMessage(new Uint8Array(m1.body)); + expect(Buffer.from(out0).toString()).toBe("msg-0"); + expect(Buffer.from(out1).toString()).toBe("msg-1"); + + // Forward derivation: counters 3 and 4 are stepped from the migrated chain + // key — exercises the chain-key index off-by-one between JS and wacore. + const out3 = await bridgeCipher.decryptWhisperMessage(new Uint8Array(m3.body)); + const out4 = await bridgeCipher.decryptWhisperMessage(new Uint8Array(m4.body)); + expect(Buffer.from(out3).toString()).toBe("msg-3"); + expect(Buffer.from(out4).toString()).toBe("msg-4"); + }); +});