From fc93036c2a6ad3b797e61167b7bd2f85425f7ec3 Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Tue, 27 Oct 2026 06:08:55 +0000 Subject: [PATCH] feat(whatsapp-store): store whatsmeow state encrypted in Postgres Why: A linked device's Signal sessions, identity keys, pre-keys and app state are as sensitive as the account itself, and whichever session-service replica holds an account's lease must be able to pick it up after another releases it. They belong in Postgres, encrypted per account and fenced by the lease. What: - `crates/whatsapp_store_pg`: implements every `whatsmeow::store` trait for one account. Each value is sealed with AES-256-GCM under a per-account data key wrapped by KMS (associated data = account | store | key); every write checks the account's `lease_epoch`. The threat model is the crate doc comment in `src/lib.rs`. A fake KMS (`fake-kms` feature) backs tests. - Migration `20261027062649_whatsapp_store`: device, store values, pre-keys, app-state MACs, privacy tokens, event buffer and outgoing events, all cascading from `whatsapp_linked_account` (layer 4). - SQLx cache entries; regenerated `workspace-hack` and closures. --- .github/workspace-dep-closures.json | 6 + ...880fd2711bfe6b51d315b82d536188070c4fc.json | 18 + ...d3a9d6f64e8a5e648a7695d57d16e6f6c3ddc.json | 18 + ...0b4ba280cc5c4dbb73145ea20c760bd908d44.json | 14 + ...2ebcd2197cd118be9736c5d191d03f0791bae.json | 28 + ...0ccf5731c8e7d94998fa291bb803a19e45a0d.json | 15 + ...376eb7be0fafea29cabcc7ebff858658dc605.json | 18 + ...3dd8b3cc7b2bd8c496ac5df4d5f8d18e1d4a2.json | 18 + ...3028734d830295ec28a9c7d8af470ff02429e.json | 19 + ...3f369a1aa25a74072ab3e9df9eca7fbf86310.json | 22 + ...f89eb2f93b09087aef6e243c54ed1b79ddad9.json | 22 + ...f7bad3144b1132e03f72da6877ed1fb76e6d5.json | 18 + ...e22251951d222996556950b097783cf490754.json | 36 + ...4e695bbcb9229d3cdb0987a9669f1d19ac7f3.json | 24 + ...1e1428cd7ea6b86742cbd1ef4a32d92b96e7f.json | 15 + ...cb7fdef517a70dd5cb3d956b446b544321cb0.json | 36 + ...cb17d3614e0e0a878295a87158d13cce36996.json | 41 ++ ...1d1833abbd317b0dd167fc77ab4b437d8e289.json | 34 + ...e0be6ea5121e095284c21cbab19b1b37b2f5a.json | 41 ++ ...4307ac0877548a768cc05a20ff44a031fb429.json | 15 + ...25b241c3c2029c35150f7f7848a3019e29d2e.json | 16 + ...facf8a97e72f6745a7f89cfcbf00e931ead7c.json | 16 + ...2dcab39e3d7104c887f15c89ea6cc88a5ec33.json | 15 + ...775a497a3de61fb7f46af66652753aee8a56d.json | 41 ++ ...92b06149ef60db06db23522af1be020313efd.json | 15 + ...694b0e85f3522b35db9861c4e6dc2b92c66cb.json | 36 + ...7be65718c281c817a548d5e5623bbd2712e70.json | 35 + ...ba77b3012907540e25a8b714fb85e6039b1a2.json | 35 + ...097c074f86a123c4672a0fd8a6c2f2d4d3315.json | 22 + ...90923766b32552ff6f4ed3a298eb843706ae3.json | 16 + ...50e02c89048231acb431fecd09ebc69c97c45.json | 15 + ...252578b5611ca2cd3ee2b7b84c09622ae7a57.json | 36 + ...35fc051ba997d902cedbffa0f707565a87834.json | 18 + ...19a1f3dc83ab532fc78fbb3de520bf1e3d0a8.json | 22 + ...b1537770f983ba7bdbbad5733973bda1129b4.json | 30 + ...da52a02bb874d5a20301ba7a2d78842d4ef2b.json | 25 + Cargo.lock | 27 + Cargo.toml | 1 + .../20261027062649_whatsapp_store.sql | 92 +++ crates/whatsapp_store_pg/AGENTS.md | 68 ++ crates/whatsapp_store_pg/CLAUDE.md | 1 + crates/whatsapp_store_pg/Cargo.toml | 36 + crates/whatsapp_store_pg/src/cipher.rs | 170 +++++ crates/whatsapp_store_pg/src/cipher/test.rs | 132 ++++ crates/whatsapp_store_pg/src/error.rs | 68 ++ crates/whatsapp_store_pg/src/kms.rs | 165 +++++ crates/whatsapp_store_pg/src/kms/fake.rs | 110 +++ crates/whatsapp_store_pg/src/kms/test.rs | 71 ++ crates/whatsapp_store_pg/src/lib.rs | 55 ++ crates/whatsapp_store_pg/src/store.rs | 555 ++++++++++++++++ .../whatsapp_store_pg/src/store/app_state.rs | 297 +++++++++ .../src/store/app_state/test.rs | 192 ++++++ .../whatsapp_store_pg/src/store/contacts.rs | 329 +++++++++ .../src/store/contacts/test.rs | 192 ++++++ crates/whatsapp_store_pg/src/store/device.rs | 255 +++++++ .../src/store/device/test.rs | 94 +++ crates/whatsapp_store_pg/src/store/events.rs | 552 +++++++++++++++ .../src/store/events/test.rs | 285 ++++++++ crates/whatsapp_store_pg/src/store/lid.rs | 225 +++++++ .../whatsapp_store_pg/src/store/lid/test.rs | 130 ++++ crates/whatsapp_store_pg/src/store/signal.rs | 456 +++++++++++++ .../src/store/signal/test.rs | 258 +++++++ crates/whatsapp_store_pg/src/store/test.rs | 628 ++++++++++++++++++ 63 files changed, 6295 insertions(+) create mode 100644 .sqlx/query-030bd856f8ae96822a5151b09ad880fd2711bfe6b51d315b82d536188070c4fc.json create mode 100644 .sqlx/query-0387cb4c338223f63292ca716f7d3a9d6f64e8a5e648a7695d57d16e6f6c3ddc.json create mode 100644 .sqlx/query-06df6697a45bd245c73d6da7f510b4ba280cc5c4dbb73145ea20c760bd908d44.json create mode 100644 .sqlx/query-072e2177f55dcbd6777d0ecb0be2ebcd2197cd118be9736c5d191d03f0791bae.json create mode 100644 .sqlx/query-17949476e90b6053b41fc8c5ba60ccf5731c8e7d94998fa291bb803a19e45a0d.json create mode 100644 .sqlx/query-1ce1b770a5834653d4ebe823e80376eb7be0fafea29cabcc7ebff858658dc605.json create mode 100644 .sqlx/query-2939bea5a0d20c91c73f6f3514f3dd8b3cc7b2bd8c496ac5df4d5f8d18e1d4a2.json create mode 100644 .sqlx/query-2d07890bf15dbab0a3c15e6fe1a3028734d830295ec28a9c7d8af470ff02429e.json create mode 100644 .sqlx/query-3005b416c2a9034a14fd4a3dd473f369a1aa25a74072ab3e9df9eca7fbf86310.json create mode 100644 .sqlx/query-3665ba05801dda1cf017023d6e6f89eb2f93b09087aef6e243c54ed1b79ddad9.json create mode 100644 .sqlx/query-4679c75486b6688406986e1329cf7bad3144b1132e03f72da6877ed1fb76e6d5.json create mode 100644 .sqlx/query-4c55be08c54d40219fd8b0ea217e22251951d222996556950b097783cf490754.json create mode 100644 .sqlx/query-577de129ec419b785c3901ba6e64e695bbcb9229d3cdb0987a9669f1d19ac7f3.json create mode 100644 .sqlx/query-5bea223c321de8a7af2a0aed6151e1428cd7ea6b86742cbd1ef4a32d92b96e7f.json create mode 100644 .sqlx/query-5f16a3e103c92ccfede1be826ebcb7fdef517a70dd5cb3d956b446b544321cb0.json create mode 100644 .sqlx/query-6b3229250262e62c59363059d69cb17d3614e0e0a878295a87158d13cce36996.json create mode 100644 .sqlx/query-6b56595e15afc9414013ba0261e1d1833abbd317b0dd167fc77ab4b437d8e289.json create mode 100644 .sqlx/query-6fdcb808278c91fbd0c23432cd7e0be6ea5121e095284c21cbab19b1b37b2f5a.json create mode 100644 .sqlx/query-8eaf12a19b9c9b292b8533288354307ac0877548a768cc05a20ff44a031fb429.json create mode 100644 .sqlx/query-8ef89b280bdf1778c23df6e530825b241c3c2029c35150f7f7848a3019e29d2e.json create mode 100644 .sqlx/query-90de5f57e7abcb810b0503c4e58facf8a97e72f6745a7f89cfcbf00e931ead7c.json create mode 100644 .sqlx/query-92a3f1f677ee5f0879147b31a962dcab39e3d7104c887f15c89ea6cc88a5ec33.json create mode 100644 .sqlx/query-a2d207fe6f036cbc70182c69ebf775a497a3de61fb7f46af66652753aee8a56d.json create mode 100644 .sqlx/query-a34087e4ca88dca708c0f79b67a92b06149ef60db06db23522af1be020313efd.json create mode 100644 .sqlx/query-a3d6c425cbc9d0ce175b50d3418694b0e85f3522b35db9861c4e6dc2b92c66cb.json create mode 100644 .sqlx/query-a48da657cf79e1737fc6c7412ed7be65718c281c817a548d5e5623bbd2712e70.json create mode 100644 .sqlx/query-a724bbd607353cbccdbf15aa197ba77b3012907540e25a8b714fb85e6039b1a2.json create mode 100644 .sqlx/query-a8ccae7c5f1b09755b74c6e8bdf097c074f86a123c4672a0fd8a6c2f2d4d3315.json create mode 100644 .sqlx/query-b12b97a2e971d70339e318c9dc690923766b32552ff6f4ed3a298eb843706ae3.json create mode 100644 .sqlx/query-c0d3a430b9508b91d4313d85d5e50e02c89048231acb431fecd09ebc69c97c45.json create mode 100644 .sqlx/query-cafd81c309588887ceaa8d648ab252578b5611ca2cd3ee2b7b84c09622ae7a57.json create mode 100644 .sqlx/query-d9cf564a13b36c2ab1bf73b950a35fc051ba997d902cedbffa0f707565a87834.json create mode 100644 .sqlx/query-e4afd2f840b2c2728cf4e5f8bb919a1f3dc83ab532fc78fbb3de520bf1e3d0a8.json create mode 100644 .sqlx/query-eaaf5cc4dce0a3e49d0b87133ecb1537770f983ba7bdbbad5733973bda1129b4.json create mode 100644 .sqlx/query-f0c532d8e744b58ba33b2b61bbada52a02bb874d5a20301ba7a2d78842d4ef2b.json create mode 100644 crates/macro_db_client/migrations/20261027062649_whatsapp_store.sql create mode 100644 crates/whatsapp_store_pg/AGENTS.md create mode 120000 crates/whatsapp_store_pg/CLAUDE.md create mode 100644 crates/whatsapp_store_pg/Cargo.toml create mode 100644 crates/whatsapp_store_pg/src/cipher.rs create mode 100644 crates/whatsapp_store_pg/src/cipher/test.rs create mode 100644 crates/whatsapp_store_pg/src/error.rs create mode 100644 crates/whatsapp_store_pg/src/kms.rs create mode 100644 crates/whatsapp_store_pg/src/kms/fake.rs create mode 100644 crates/whatsapp_store_pg/src/kms/test.rs create mode 100644 crates/whatsapp_store_pg/src/lib.rs create mode 100644 crates/whatsapp_store_pg/src/store.rs create mode 100644 crates/whatsapp_store_pg/src/store/app_state.rs create mode 100644 crates/whatsapp_store_pg/src/store/app_state/test.rs create mode 100644 crates/whatsapp_store_pg/src/store/contacts.rs create mode 100644 crates/whatsapp_store_pg/src/store/contacts/test.rs create mode 100644 crates/whatsapp_store_pg/src/store/device.rs create mode 100644 crates/whatsapp_store_pg/src/store/device/test.rs create mode 100644 crates/whatsapp_store_pg/src/store/events.rs create mode 100644 crates/whatsapp_store_pg/src/store/events/test.rs create mode 100644 crates/whatsapp_store_pg/src/store/lid.rs create mode 100644 crates/whatsapp_store_pg/src/store/lid/test.rs create mode 100644 crates/whatsapp_store_pg/src/store/signal.rs create mode 100644 crates/whatsapp_store_pg/src/store/signal/test.rs create mode 100644 crates/whatsapp_store_pg/src/store/test.rs diff --git a/.github/workspace-dep-closures.json b/.github/workspace-dep-closures.json index f99573d18f4..b1ce4ad643d 100644 --- a/.github/workspace-dep-closures.json +++ b/.github/workspace-dep-closures.json @@ -14000,6 +14000,12 @@ "crates/whatsapp_linked", "crates/workspace-hack" ], + "whatsapp_store_pg": [ + "crates/whatsapp_store_pg", + "crates/whatsmeow", + "crates/whatsmeow_proto", + "crates/workspace-hack" + ], "whatsmeow": [ "crates/whatsmeow", "crates/whatsmeow_proto", diff --git a/.sqlx/query-030bd856f8ae96822a5151b09ad880fd2711bfe6b51d315b82d536188070c4fc.json b/.sqlx/query-030bd856f8ae96822a5151b09ad880fd2711bfe6b51d315b82d536188070c4fc.json new file mode 100644 index 00000000000..02de138a05d --- /dev/null +++ b/.sqlx/query-030bd856f8ae96822a5151b09ad880fd2711bfe6b51d315b82d536188070c4fc.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO whatsapp_privacy_token (account_id, user_jid, token_timestamp, nonce, value)\n SELECT $1, entry.user_jid, entry.token_timestamp, entry.nonce, entry.value\n FROM UNNEST($2::text[], $3::timestamptz[], $4::bytea[], $5::bytea[])\n AS entry(user_jid, token_timestamp, nonce, value)\n ON CONFLICT (account_id, user_jid) DO UPDATE SET\n token_timestamp = EXCLUDED.token_timestamp,\n nonce = EXCLUDED.nonce,\n value = EXCLUDED.value\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "TextArray", + "TimestamptzArray", + "ByteaArray", + "ByteaArray" + ] + }, + "nullable": [] + }, + "hash": "030bd856f8ae96822a5151b09ad880fd2711bfe6b51d315b82d536188070c4fc" +} diff --git a/.sqlx/query-0387cb4c338223f63292ca716f7d3a9d6f64e8a5e648a7695d57d16e6f6c3ddc.json b/.sqlx/query-0387cb4c338223f63292ca716f7d3a9d6f64e8a5e648a7695d57d16e6f6c3ddc.json new file mode 100644 index 00000000000..2a74f31e226 --- /dev/null +++ b/.sqlx/query-0387cb4c338223f63292ca716f7d3a9d6f64e8a5e648a7695d57d16e6f6c3ddc.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO whatsapp_store_value (account_id, store, key, nonce, value)\n SELECT $1, $2, entry.key, entry.nonce, entry.value\n FROM UNNEST($3::text[], $4::bytea[], $5::bytea[]) AS entry(key, nonce, value)\n ON CONFLICT (account_id, store, key) DO UPDATE SET\n nonce = EXCLUDED.nonce,\n value = EXCLUDED.value,\n updated_at = now()\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "TextArray", + "ByteaArray", + "ByteaArray" + ] + }, + "nullable": [] + }, + "hash": "0387cb4c338223f63292ca716f7d3a9d6f64e8a5e648a7695d57d16e6f6c3ddc" +} diff --git a/.sqlx/query-06df6697a45bd245c73d6da7f510b4ba280cc5c4dbb73145ea20c760bd908d44.json b/.sqlx/query-06df6697a45bd245c73d6da7f510b4ba280cc5c4dbb73145ea20c760bd908d44.json new file mode 100644 index 00000000000..1a25dafabc0 --- /dev/null +++ b/.sqlx/query-06df6697a45bd245c73d6da7f510b4ba280cc5c4dbb73145ea20c760bd908d44.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM whatsapp_device\n WHERE account_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "06df6697a45bd245c73d6da7f510b4ba280cc5c4dbb73145ea20c760bd908d44" +} diff --git a/.sqlx/query-072e2177f55dcbd6777d0ecb0be2ebcd2197cd118be9736c5d191d03f0791bae.json b/.sqlx/query-072e2177f55dcbd6777d0ecb0be2ebcd2197cd118be9736c5d191d03f0791bae.json new file mode 100644 index 00000000000..ec0a60be94b --- /dev/null +++ b/.sqlx/query-072e2177f55dcbd6777d0ecb0be2ebcd2197cd118be9736c5d191d03f0791bae.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_id, lease_epoch\n FROM whatsapp_linked_account\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "lease_epoch", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "072e2177f55dcbd6777d0ecb0be2ebcd2197cd118be9736c5d191d03f0791bae" +} diff --git a/.sqlx/query-17949476e90b6053b41fc8c5ba60ccf5731c8e7d94998fa291bb803a19e45a0d.json b/.sqlx/query-17949476e90b6053b41fc8c5ba60ccf5731c8e7d94998fa291bb803a19e45a0d.json new file mode 100644 index 00000000000..07e84dfcdcf --- /dev/null +++ b/.sqlx/query-17949476e90b6053b41fc8c5ba60ccf5731c8e7d94998fa291bb803a19e45a0d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM whatsapp_outgoing_event\n WHERE account_id = $1 AND created_at < $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "17949476e90b6053b41fc8c5ba60ccf5731c8e7d94998fa291bb803a19e45a0d" +} diff --git a/.sqlx/query-1ce1b770a5834653d4ebe823e80376eb7be0fafea29cabcc7ebff858658dc605.json b/.sqlx/query-1ce1b770a5834653d4ebe823e80376eb7be0fafea29cabcc7ebff858658dc605.json new file mode 100644 index 00000000000..c46b37d8d41 --- /dev/null +++ b/.sqlx/query-1ce1b770a5834653d4ebe823e80376eb7be0fafea29cabcc7ebff858658dc605.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO whatsapp_pre_key (account_id, key_id, uploaded, nonce, value)\n SELECT $1, entry.key_id, $2, entry.nonce, entry.value\n FROM UNNEST($3::bigint[], $4::bytea[], $5::bytea[]) AS entry(key_id, nonce, value)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Bool", + "Int8Array", + "ByteaArray", + "ByteaArray" + ] + }, + "nullable": [] + }, + "hash": "1ce1b770a5834653d4ebe823e80376eb7be0fafea29cabcc7ebff858658dc605" +} diff --git a/.sqlx/query-2939bea5a0d20c91c73f6f3514f3dd8b3cc7b2bd8c496ac5df4d5f8d18e1d4a2.json b/.sqlx/query-2939bea5a0d20c91c73f6f3514f3dd8b3cc7b2bd8c496ac5df4d5f8d18e1d4a2.json new file mode 100644 index 00000000000..c4b9c96c497 --- /dev/null +++ b/.sqlx/query-2939bea5a0d20c91c73f6f3514f3dd8b3cc7b2bd8c496ac5df4d5f8d18e1d4a2.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO whatsapp_store_value (account_id, store, key, nonce, value)\n SELECT $1, $2, entry.key, entry.nonce, entry.value\n FROM UNNEST($3::text[], $4::bytea[], $5::bytea[]) AS entry(key, nonce, value)\n ON CONFLICT (account_id, store, key) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "TextArray", + "ByteaArray", + "ByteaArray" + ] + }, + "nullable": [] + }, + "hash": "2939bea5a0d20c91c73f6f3514f3dd8b3cc7b2bd8c496ac5df4d5f8d18e1d4a2" +} diff --git a/.sqlx/query-2d07890bf15dbab0a3c15e6fe1a3028734d830295ec28a9c7d8af470ff02429e.json b/.sqlx/query-2d07890bf15dbab0a3c15e6fe1a3028734d830295ec28a9c7d8af470ff02429e.json new file mode 100644 index 00000000000..e9ea4a2b2eb --- /dev/null +++ b/.sqlx/query-2d07890bf15dbab0a3c15e6fe1a3028734d830295ec28a9c7d8af470ff02429e.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO whatsapp_app_state_mutation_mac (\n account_id, name, index_mac, version, nonce, value\n )\n SELECT $1, $2, entry.index_mac, $3, entry.nonce, entry.value\n FROM UNNEST($4::bytea[], $5::bytea[], $6::bytea[]) AS entry(index_mac, nonce, value)\n ON CONFLICT (account_id, name, index_mac, version) DO UPDATE SET\n nonce = EXCLUDED.nonce,\n value = EXCLUDED.value\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Int8", + "ByteaArray", + "ByteaArray", + "ByteaArray" + ] + }, + "nullable": [] + }, + "hash": "2d07890bf15dbab0a3c15e6fe1a3028734d830295ec28a9c7d8af470ff02429e" +} diff --git a/.sqlx/query-3005b416c2a9034a14fd4a3dd473f369a1aa25a74072ab3e9df9eca7fbf86310.json b/.sqlx/query-3005b416c2a9034a14fd4a3dd473f369a1aa25a74072ab3e9df9eca7fbf86310.json new file mode 100644 index 00000000000..8ff75f0cdd0 --- /dev/null +++ b/.sqlx/query-3005b416c2a9034a14fd4a3dd473f369a1aa25a74072ab3e9df9eca7fbf86310.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT account_id\n FROM whatsapp_device\n WHERE account_id = $1\n FOR NO KEY UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "account_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "3005b416c2a9034a14fd4a3dd473f369a1aa25a74072ab3e9df9eca7fbf86310" +} diff --git a/.sqlx/query-3665ba05801dda1cf017023d6e6f89eb2f93b09087aef6e243c54ed1b79ddad9.json b/.sqlx/query-3665ba05801dda1cf017023d6e6f89eb2f93b09087aef6e243c54ed1b79ddad9.json new file mode 100644 index 00000000000..2241e3ba6e1 --- /dev/null +++ b/.sqlx/query-3665ba05801dda1cf017023d6e6f89eb2f93b09087aef6e243c54ed1b79ddad9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT lease_epoch\n FROM whatsapp_linked_account\n WHERE id = $1\n FOR SHARE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "lease_epoch", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "3665ba05801dda1cf017023d6e6f89eb2f93b09087aef6e243c54ed1b79ddad9" +} diff --git a/.sqlx/query-4679c75486b6688406986e1329cf7bad3144b1132e03f72da6877ed1fb76e6d5.json b/.sqlx/query-4679c75486b6688406986e1329cf7bad3144b1132e03f72da6877ed1fb76e6d5.json new file mode 100644 index 00000000000..85d5d9d4af4 --- /dev/null +++ b/.sqlx/query-4679c75486b6688406986e1329cf7bad3144b1132e03f72da6877ed1fb76e6d5.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO whatsapp_event_buffer (account_id, ciphertext_hash, server_time, nonce, value)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (account_id, ciphertext_hash) DO UPDATE SET\n server_time = EXCLUDED.server_time,\n inserted_at = now(),\n nonce = EXCLUDED.nonce,\n value = EXCLUDED.value\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Bytea", + "Timestamptz", + "Bytea", + "Bytea" + ] + }, + "nullable": [] + }, + "hash": "4679c75486b6688406986e1329cf7bad3144b1132e03f72da6877ed1fb76e6d5" +} diff --git a/.sqlx/query-4c55be08c54d40219fd8b0ea217e22251951d222996556950b097783cf490754.json b/.sqlx/query-4c55be08c54d40219fd8b0ea217e22251951d222996556950b097783cf490754.json new file mode 100644 index 00000000000..a9fb004b078 --- /dev/null +++ b/.sqlx/query-4c55be08c54d40219fd8b0ea217e22251951d222996556950b097783cf490754.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT key, nonce, value\n FROM whatsapp_store_value\n WHERE account_id = $1 AND store = $2 AND key LIKE $3\n ORDER BY key\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "key", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "nonce", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "value", + "type_info": "Bytea" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "4c55be08c54d40219fd8b0ea217e22251951d222996556950b097783cf490754" +} diff --git a/.sqlx/query-577de129ec419b785c3901ba6e64e695bbcb9229d3cdb0987a9669f1d19ac7f3.json b/.sqlx/query-577de129ec419b785c3901ba6e64e695bbcb9229d3cdb0987a9669f1d19ac7f3.json new file mode 100644 index 00000000000..66c3bb0aa1c --- /dev/null +++ b/.sqlx/query-577de129ec419b785c3901ba6e64e695bbcb9229d3cdb0987a9669f1d19ac7f3.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS (\n SELECT 1\n FROM whatsapp_store_value\n WHERE account_id = $1 AND store = $2 AND key = $3\n ) AS \"exists!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "577de129ec419b785c3901ba6e64e695bbcb9229d3cdb0987a9669f1d19ac7f3" +} diff --git a/.sqlx/query-5bea223c321de8a7af2a0aed6151e1428cd7ea6b86742cbd1ef4a32d92b96e7f.json b/.sqlx/query-5bea223c321de8a7af2a0aed6151e1428cd7ea6b86742cbd1ef4a32d92b96e7f.json new file mode 100644 index 00000000000..ccb25a77a6a --- /dev/null +++ b/.sqlx/query-5bea223c321de8a7af2a0aed6151e1428cd7ea6b86742cbd1ef4a32d92b96e7f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM whatsapp_privacy_token\n WHERE account_id = $1 AND token_timestamp < $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "5bea223c321de8a7af2a0aed6151e1428cd7ea6b86742cbd1ef4a32d92b96e7f" +} diff --git a/.sqlx/query-5f16a3e103c92ccfede1be826ebcb7fdef517a70dd5cb3d956b446b544321cb0.json b/.sqlx/query-5f16a3e103c92ccfede1be826ebcb7fdef517a70dd5cb3d956b446b544321cb0.json new file mode 100644 index 00000000000..daa4d5a29f1 --- /dev/null +++ b/.sqlx/query-5f16a3e103c92ccfede1be826ebcb7fdef517a70dd5cb3d956b446b544321cb0.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT chat_jid, nonce, value\n FROM whatsapp_outgoing_event\n WHERE account_id = $1 AND chat_jid = ANY($2) AND message_id = $3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "chat_jid", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "nonce", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "value", + "type_info": "Bytea" + } + ], + "parameters": { + "Left": [ + "Uuid", + "TextArray", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "5f16a3e103c92ccfede1be826ebcb7fdef517a70dd5cb3d956b446b544321cb0" +} diff --git a/.sqlx/query-6b3229250262e62c59363059d69cb17d3614e0e0a878295a87158d13cce36996.json b/.sqlx/query-6b3229250262e62c59363059d69cb17d3614e0e0a878295a87158d13cce36996.json new file mode 100644 index 00000000000..e40a1a9e623 --- /dev/null +++ b/.sqlx/query-6b3229250262e62c59363059d69cb17d3614e0e0a878295a87158d13cce36996.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_jid, token_timestamp, nonce, value\n FROM whatsapp_privacy_token\n WHERE account_id = $1 AND user_jid = ANY($2)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_jid", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "token_timestamp", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "nonce", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "value", + "type_info": "Bytea" + } + ], + "parameters": { + "Left": [ + "Uuid", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "6b3229250262e62c59363059d69cb17d3614e0e0a878295a87158d13cce36996" +} diff --git a/.sqlx/query-6b56595e15afc9414013ba0261e1d1833abbd317b0dd167fc77ab4b437d8e289.json b/.sqlx/query-6b56595e15afc9414013ba0261e1d1833abbd317b0dd167fc77ab4b437d8e289.json new file mode 100644 index 00000000000..c41c94bd387 --- /dev/null +++ b/.sqlx/query-6b56595e15afc9414013ba0261e1d1833abbd317b0dd167fc77ab4b437d8e289.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT data_key_ciphertext, kms_key_id, encryption_version\n FROM whatsapp_device\n WHERE account_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "data_key_ciphertext", + "type_info": "Bytea" + }, + { + "ordinal": 1, + "name": "kms_key_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "encryption_version", + "type_info": "Int2" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "6b56595e15afc9414013ba0261e1d1833abbd317b0dd167fc77ab4b437d8e289" +} diff --git a/.sqlx/query-6fdcb808278c91fbd0c23432cd7e0be6ea5121e095284c21cbab19b1b37b2f5a.json b/.sqlx/query-6fdcb808278c91fbd0c23432cd7e0be6ea5121e095284c21cbab19b1b37b2f5a.json new file mode 100644 index 00000000000..4238a92bfc3 --- /dev/null +++ b/.sqlx/query-6fdcb808278c91fbd0c23432cd7e0be6ea5121e095284c21cbab19b1b37b2f5a.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_jid, token_timestamp, nonce, value\n FROM whatsapp_privacy_token\n WHERE account_id = $1 AND user_jid = ANY($2)\n ORDER BY token_timestamp DESC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_jid", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "token_timestamp", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "nonce", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "value", + "type_info": "Bytea" + } + ], + "parameters": { + "Left": [ + "Uuid", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "6fdcb808278c91fbd0c23432cd7e0be6ea5121e095284c21cbab19b1b37b2f5a" +} diff --git a/.sqlx/query-8eaf12a19b9c9b292b8533288354307ac0877548a768cc05a20ff44a031fb429.json b/.sqlx/query-8eaf12a19b9c9b292b8533288354307ac0877548a768cc05a20ff44a031fb429.json new file mode 100644 index 00000000000..974dd651a79 --- /dev/null +++ b/.sqlx/query-8eaf12a19b9c9b292b8533288354307ac0877548a768cc05a20ff44a031fb429.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM whatsapp_event_buffer\n WHERE account_id = $1 AND inserted_at < $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "8eaf12a19b9c9b292b8533288354307ac0877548a768cc05a20ff44a031fb429" +} diff --git a/.sqlx/query-8ef89b280bdf1778c23df6e530825b241c3c2029c35150f7f7848a3019e29d2e.json b/.sqlx/query-8ef89b280bdf1778c23df6e530825b241c3c2029c35150f7f7848a3019e29d2e.json new file mode 100644 index 00000000000..c927f5bbc25 --- /dev/null +++ b/.sqlx/query-8ef89b280bdf1778c23df6e530825b241c3c2029c35150f7f7848a3019e29d2e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM whatsapp_store_value\n WHERE account_id = $1 AND store = $2 AND key = ANY($3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "8ef89b280bdf1778c23df6e530825b241c3c2029c35150f7f7848a3019e29d2e" +} diff --git a/.sqlx/query-90de5f57e7abcb810b0503c4e58facf8a97e72f6745a7f89cfcbf00e931ead7c.json b/.sqlx/query-90de5f57e7abcb810b0503c4e58facf8a97e72f6745a7f89cfcbf00e931ead7c.json new file mode 100644 index 00000000000..c9ba026402b --- /dev/null +++ b/.sqlx/query-90de5f57e7abcb810b0503c4e58facf8a97e72f6745a7f89cfcbf00e931ead7c.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM whatsapp_app_state_mutation_mac\n WHERE account_id = $1 AND name = $2 AND index_mac = ANY($3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "ByteaArray" + ] + }, + "nullable": [] + }, + "hash": "90de5f57e7abcb810b0503c4e58facf8a97e72f6745a7f89cfcbf00e931ead7c" +} diff --git a/.sqlx/query-92a3f1f677ee5f0879147b31a962dcab39e3d7104c887f15c89ea6cc88a5ec33.json b/.sqlx/query-92a3f1f677ee5f0879147b31a962dcab39e3d7104c887f15c89ea6cc88a5ec33.json new file mode 100644 index 00000000000..bb43b00e9de --- /dev/null +++ b/.sqlx/query-92a3f1f677ee5f0879147b31a962dcab39e3d7104c887f15c89ea6cc88a5ec33.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM whatsapp_pre_key\n WHERE account_id = $1 AND key_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "92a3f1f677ee5f0879147b31a962dcab39e3d7104c887f15c89ea6cc88a5ec33" +} diff --git a/.sqlx/query-a2d207fe6f036cbc70182c69ebf775a497a3de61fb7f46af66652753aee8a56d.json b/.sqlx/query-a2d207fe6f036cbc70182c69ebf775a497a3de61fb7f46af66652753aee8a56d.json new file mode 100644 index 00000000000..5838a89c256 --- /dev/null +++ b/.sqlx/query-a2d207fe6f036cbc70182c69ebf775a497a3de61fb7f46af66652753aee8a56d.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT server_time, inserted_at, nonce, value\n FROM whatsapp_event_buffer\n WHERE account_id = $1 AND ciphertext_hash = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "server_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "inserted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "nonce", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "value", + "type_info": "Bytea" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Bytea" + ] + }, + "nullable": [ + false, + false, + true, + true + ] + }, + "hash": "a2d207fe6f036cbc70182c69ebf775a497a3de61fb7f46af66652753aee8a56d" +} diff --git a/.sqlx/query-a34087e4ca88dca708c0f79b67a92b06149ef60db06db23522af1be020313efd.json b/.sqlx/query-a34087e4ca88dca708c0f79b67a92b06149ef60db06db23522af1be020313efd.json new file mode 100644 index 00000000000..46c540a2dcb --- /dev/null +++ b/.sqlx/query-a34087e4ca88dca708c0f79b67a92b06149ef60db06db23522af1be020313efd.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE whatsapp_pre_key\n SET uploaded = true\n WHERE account_id = $1 AND key_id <= $2 AND NOT uploaded\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "a34087e4ca88dca708c0f79b67a92b06149ef60db06db23522af1be020313efd" +} diff --git a/.sqlx/query-a3d6c425cbc9d0ce175b50d3418694b0e85f3522b35db9861c4e6dc2b92c66cb.json b/.sqlx/query-a3d6c425cbc9d0ce175b50d3418694b0e85f3522b35db9861c4e6dc2b92c66cb.json new file mode 100644 index 00000000000..ad2c2fc45e4 --- /dev/null +++ b/.sqlx/query-a3d6c425cbc9d0ce175b50d3418694b0e85f3522b35db9861c4e6dc2b92c66cb.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT key, nonce, value\n FROM whatsapp_store_value\n WHERE account_id = $1 AND store = $2 AND key = ANY($3)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "key", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "nonce", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "value", + "type_info": "Bytea" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "a3d6c425cbc9d0ce175b50d3418694b0e85f3522b35db9861c4e6dc2b92c66cb" +} diff --git a/.sqlx/query-a48da657cf79e1737fc6c7412ed7be65718c281c817a548d5e5623bbd2712e70.json b/.sqlx/query-a48da657cf79e1737fc6c7412ed7be65718c281c817a548d5e5623bbd2712e70.json new file mode 100644 index 00000000000..f5367395bdf --- /dev/null +++ b/.sqlx/query-a48da657cf79e1737fc6c7412ed7be65718c281c817a548d5e5623bbd2712e70.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT key_id, nonce, value\n FROM whatsapp_pre_key\n WHERE account_id = $1 AND NOT uploaded\n ORDER BY key_id\n LIMIT $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "key_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "nonce", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "value", + "type_info": "Bytea" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Int8" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "a48da657cf79e1737fc6c7412ed7be65718c281c817a548d5e5623bbd2712e70" +} diff --git a/.sqlx/query-a724bbd607353cbccdbf15aa197ba77b3012907540e25a8b714fb85e6039b1a2.json b/.sqlx/query-a724bbd607353cbccdbf15aa197ba77b3012907540e25a8b714fb85e6039b1a2.json new file mode 100644 index 00000000000..526d6592e48 --- /dev/null +++ b/.sqlx/query-a724bbd607353cbccdbf15aa197ba77b3012907540e25a8b714fb85e6039b1a2.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT key_id, nonce, value\n FROM whatsapp_pre_key\n WHERE account_id = $1 AND key_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "key_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "nonce", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "value", + "type_info": "Bytea" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Int8" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "a724bbd607353cbccdbf15aa197ba77b3012907540e25a8b714fb85e6039b1a2" +} diff --git a/.sqlx/query-a8ccae7c5f1b09755b74c6e8bdf097c074f86a123c4672a0fd8a6c2f2d4d3315.json b/.sqlx/query-a8ccae7c5f1b09755b74c6e8bdf097c074f86a123c4672a0fd8a6c2f2d4d3315.json new file mode 100644 index 00000000000..76bd62ff80a --- /dev/null +++ b/.sqlx/query-a8ccae7c5f1b09755b74c6e8bdf097c074f86a123c4672a0fd8a6c2f2d4d3315.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT COUNT(*) AS \"count!\"\n FROM whatsapp_pre_key\n WHERE account_id = $1 AND uploaded\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "a8ccae7c5f1b09755b74c6e8bdf097c074f86a123c4672a0fd8a6c2f2d4d3315" +} diff --git a/.sqlx/query-b12b97a2e971d70339e318c9dc690923766b32552ff6f4ed3a298eb843706ae3.json b/.sqlx/query-b12b97a2e971d70339e318c9dc690923766b32552ff6f4ed3a298eb843706ae3.json new file mode 100644 index 00000000000..4324a6bec30 --- /dev/null +++ b/.sqlx/query-b12b97a2e971d70339e318c9dc690923766b32552ff6f4ed3a298eb843706ae3.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM whatsapp_store_value\n WHERE account_id = $1 AND store = $2 AND key LIKE $3\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "b12b97a2e971d70339e318c9dc690923766b32552ff6f4ed3a298eb843706ae3" +} diff --git a/.sqlx/query-c0d3a430b9508b91d4313d85d5e50e02c89048231acb431fecd09ebc69c97c45.json b/.sqlx/query-c0d3a430b9508b91d4313d85d5e50e02c89048231acb431fecd09ebc69c97c45.json new file mode 100644 index 00000000000..f76ee8bac19 --- /dev/null +++ b/.sqlx/query-c0d3a430b9508b91d4313d85d5e50e02c89048231acb431fecd09ebc69c97c45.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE whatsapp_event_buffer\n SET nonce = NULL, value = NULL\n WHERE account_id = $1 AND ciphertext_hash = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Bytea" + ] + }, + "nullable": [] + }, + "hash": "c0d3a430b9508b91d4313d85d5e50e02c89048231acb431fecd09ebc69c97c45" +} diff --git a/.sqlx/query-cafd81c309588887ceaa8d648ab252578b5611ca2cd3ee2b7b84c09622ae7a57.json b/.sqlx/query-cafd81c309588887ceaa8d648ab252578b5611ca2cd3ee2b7b84c09622ae7a57.json new file mode 100644 index 00000000000..9c39df8efb5 --- /dev/null +++ b/.sqlx/query-cafd81c309588887ceaa8d648ab252578b5611ca2cd3ee2b7b84c09622ae7a57.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT version, nonce, value\n FROM whatsapp_app_state_mutation_mac\n WHERE account_id = $1 AND name = $2 AND index_mac = $3\n ORDER BY version DESC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "nonce", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "value", + "type_info": "Bytea" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Bytea" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "cafd81c309588887ceaa8d648ab252578b5611ca2cd3ee2b7b84c09622ae7a57" +} diff --git a/.sqlx/query-d9cf564a13b36c2ab1bf73b950a35fc051ba997d902cedbffa0f707565a87834.json b/.sqlx/query-d9cf564a13b36c2ab1bf73b950a35fc051ba997d902cedbffa0f707565a87834.json new file mode 100644 index 00000000000..bf9fddd0295 --- /dev/null +++ b/.sqlx/query-d9cf564a13b36c2ab1bf73b950a35fc051ba997d902cedbffa0f707565a87834.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO whatsapp_outgoing_event (account_id, chat_jid, message_id, nonce, value)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (account_id, chat_jid, message_id) DO UPDATE SET\n created_at = now(),\n nonce = EXCLUDED.nonce,\n value = EXCLUDED.value\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Bytea", + "Bytea" + ] + }, + "nullable": [] + }, + "hash": "d9cf564a13b36c2ab1bf73b950a35fc051ba997d902cedbffa0f707565a87834" +} diff --git a/.sqlx/query-e4afd2f840b2c2728cf4e5f8bb919a1f3dc83ab532fc78fbb3de520bf1e3d0a8.json b/.sqlx/query-e4afd2f840b2c2728cf4e5f8bb919a1f3dc83ab532fc78fbb3de520bf1e3d0a8.json new file mode 100644 index 00000000000..b1ef17ec2ec --- /dev/null +++ b/.sqlx/query-e4afd2f840b2c2728cf4e5f8bb919a1f3dc83ab532fc78fbb3de520bf1e3d0a8.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT MAX(key_id)\n FROM whatsapp_pre_key\n WHERE account_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "max", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "e4afd2f840b2c2728cf4e5f8bb919a1f3dc83ab532fc78fbb3de520bf1e3d0a8" +} diff --git a/.sqlx/query-eaaf5cc4dce0a3e49d0b87133ecb1537770f983ba7bdbbad5733973bda1129b4.json b/.sqlx/query-eaaf5cc4dce0a3e49d0b87133ecb1537770f983ba7bdbbad5733973bda1129b4.json new file mode 100644 index 00000000000..ca4fd81f3e0 --- /dev/null +++ b/.sqlx/query-eaaf5cc4dce0a3e49d0b87133ecb1537770f983ba7bdbbad5733973bda1129b4.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT nonce, value\n FROM whatsapp_store_value\n WHERE account_id = $1 AND store = $2 AND key = $3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "nonce", + "type_info": "Bytea" + }, + { + "ordinal": 1, + "name": "value", + "type_info": "Bytea" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "eaaf5cc4dce0a3e49d0b87133ecb1537770f983ba7bdbbad5733973bda1129b4" +} diff --git a/.sqlx/query-f0c532d8e744b58ba33b2b61bbada52a02bb874d5a20301ba7a2d78842d4ef2b.json b/.sqlx/query-f0c532d8e744b58ba33b2b61bbada52a02bb874d5a20301ba7a2d78842d4ef2b.json new file mode 100644 index 00000000000..43022c90346 --- /dev/null +++ b/.sqlx/query-f0c532d8e744b58ba33b2b61bbada52a02bb874d5a20301ba7a2d78842d4ef2b.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO whatsapp_device (\n account_id,\n data_key_ciphertext,\n kms_key_id,\n encryption_version\n )\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (account_id) DO NOTHING\n RETURNING account_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "account_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Bytea", + "Text", + "Int2" + ] + }, + "nullable": [ + false + ] + }, + "hash": "f0c532d8e744b58ba33b2b61bbada52a02bb874d5a20301ba7a2d78842d4ef2b" +} diff --git a/Cargo.lock b/Cargo.lock index b78c01e7a0b..b1bcff0bd7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21629,6 +21629,33 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "whatsapp_store_pg" +version = "0.1.0" +dependencies = [ + "aes-gcm 0.10.3", + "async-trait", + "aws-sdk-kms", + "chrono", + "futures", + "hex", + "macro_db_migrator", + "postcard", + "prost 0.14.3", + "rand 0.9.4", + "rootcause", + "serde", + "sqlx", + "strum 0.27.2", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", + "whatsmeow", + "workspace-hack", + "zeroize", +] + [[package]] name = "whatsmeow" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index ee98994e45c..a789190ebda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -132,6 +132,7 @@ members = [ "crates/webhook_signature", "crates/whatsapp_fake_server", "crates/whatsapp_linked", + "crates/whatsapp_store_pg", "crates/whatsmeow", "crates/whatsmeow_proto", "crates/whatsmeow_signal", diff --git a/crates/macro_db_client/migrations/20261027062649_whatsapp_store.sql b/crates/macro_db_client/migrations/20261027062649_whatsapp_store.sql new file mode 100644 index 00000000000..88c744042ee --- /dev/null +++ b/crates/macro_db_client/migrations/20261027062649_whatsapp_store.sql @@ -0,0 +1,92 @@ +-- The whatsmeow device store of a linked WhatsApp account (crates/whatsapp_store_pg). +-- +-- Every value column holds AES-256-GCM ciphertext under the account's data key, +-- which is itself only stored wrapped by KMS (whatsapp_device). Lookup keys +-- (Signal addresses, JIDs, prekey ids, MACs) stay plaintext so they can be +-- indexed. Everything cascades from the account, so deleting the account or +-- the device row removes every key and session. +SET LOCAL lock_timeout = '5s'; + +CREATE TABLE whatsapp_device ( + account_id uuid PRIMARY KEY REFERENCES whatsapp_linked_account (id) ON DELETE CASCADE, + -- The account's data key, wrapped by KMS under the account's encryption context. + data_key_ciphertext bytea NOT NULL, + kms_key_id text NOT NULL, + encryption_version smallint NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Values with point and prefix lookups only. `key` is byte-ordered (C +-- collation) so `LIKE 'prefix%'` deletes use the primary key. +CREATE TABLE whatsapp_store_value ( + account_id uuid NOT NULL REFERENCES whatsapp_device (account_id) ON DELETE CASCADE, + store text NOT NULL, + key text COLLATE "C" NOT NULL, + nonce bytea NOT NULL CHECK (length(nonce) = 12), + value bytea NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (account_id, store, key) +); + +-- One-time prekeys: numbered, listed in order, counted by upload state. +CREATE TABLE whatsapp_pre_key ( + account_id uuid NOT NULL REFERENCES whatsapp_device (account_id) ON DELETE CASCADE, + key_id bigint NOT NULL CHECK (key_id BETWEEN 0 AND 4294967295), + uploaded boolean NOT NULL DEFAULT false, + nonce bytea NOT NULL CHECK (length(nonce) = 12), + -- The encrypted private key. + value bytea NOT NULL, + PRIMARY KEY (account_id, key_id) +); + +-- App state mutation MACs: the newest version per index MAC, and batch deletes +-- by index MAC. The index MAC is keyed by an app state key, so it reveals +-- nothing without one; the value MAC is encrypted. +CREATE TABLE whatsapp_app_state_mutation_mac ( + account_id uuid NOT NULL REFERENCES whatsapp_device (account_id) ON DELETE CASCADE, + name text NOT NULL, + index_mac bytea NOT NULL, + version bigint NOT NULL, + nonce bytea NOT NULL CHECK (length(nonce) = 12), + value bytea NOT NULL, + PRIMARY KEY (account_id, name, index_mac, version) +); + +-- Privacy tokens, deleted by age. +CREATE TABLE whatsapp_privacy_token ( + account_id uuid NOT NULL REFERENCES whatsapp_device (account_id) ON DELETE CASCADE, + user_jid text NOT NULL, + token_timestamp timestamptz NOT NULL, + nonce bytea NOT NULL CHECK (length(nonce) = 12), + value bytea NOT NULL, + PRIMARY KEY (account_id, user_jid) +); + +-- Decrypted incoming events, kept so a redelivery isn't decrypted twice and +-- deleted after 14 days. The plaintext is cleared (nonce and value null) once +-- the event is handled. +CREATE TABLE whatsapp_event_buffer ( + account_id uuid NOT NULL REFERENCES whatsapp_device (account_id) ON DELETE CASCADE, + ciphertext_hash bytea NOT NULL CHECK (length(ciphertext_hash) = 32), + server_time timestamptz NOT NULL, + inserted_at timestamptz NOT NULL DEFAULT now(), + nonce bytea CHECK (length(nonce) = 12), + value bytea, + PRIMARY KEY (account_id, ciphertext_hash), + CHECK ((nonce IS NULL) = (value IS NULL)) +); + +CREATE INDEX whatsapp_event_buffer_inserted_at_idx ON whatsapp_event_buffer (account_id, inserted_at); + +-- Sent messages kept for answering retry receipts, deleted after 7 days. +CREATE TABLE whatsapp_outgoing_event ( + account_id uuid NOT NULL REFERENCES whatsapp_device (account_id) ON DELETE CASCADE, + chat_jid text NOT NULL, + message_id text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + nonce bytea NOT NULL CHECK (length(nonce) = 12), + value bytea NOT NULL, + PRIMARY KEY (account_id, chat_jid, message_id) +); + +CREATE INDEX whatsapp_outgoing_event_created_at_idx ON whatsapp_outgoing_event (account_id, created_at); diff --git a/crates/whatsapp_store_pg/AGENTS.md b/crates/whatsapp_store_pg/AGENTS.md new file mode 100644 index 00000000000..9acb2f0a94b --- /dev/null +++ b/crates/whatsapp_store_pg/AGENTS.md @@ -0,0 +1,68 @@ +# whatsapp_store_pg + +## What it is + +Part of WhatsApp linked devices: the Postgres implementation of every +`whatsmeow::store` trait for one linked account, with per-account envelope +encryption (KMS-wrapped data key, AES-256-GCM per value) and lease fencing. +The threat model is the crate doc comment in `src/lib.rs`. + +## Start here + +- `src/store.rs`: `PgWhatsAppStore::open`, fencing (`check_lease`) and the + row helpers every store is built from. +- `src/cipher.rs`: the data key and value sealing (associated data = + account | store | key). +- `src/kms.rs`: the KMS seam, the AWS impl, and the encryption context. + +## Scope + +- Owns: the `whatsapp_device`, `whatsapp_store_value`, `whatsapp_pre_key`, + `whatsapp_app_state_mutation_mac`, `whatsapp_privacy_token`, + `whatsapp_event_buffer` and `whatsapp_outgoing_event` tables + (`crates/macro_db_client/migrations/20261027062649_whatsapp_store.sql`). +- Not here: the account, pairing and lease rows → linked-domain migration + `20261026000000_whatsapp_linked.sql`; taking leases and running clients → + `services/whatsapp_session_service`. + +## Boundaries + +- Depends on: `whatsmeow` (store traits), AWS KMS. +- Used by: the WhatsApp session service. + +## Recipes + +- Support a new whatsmeow store trait: a `StoreName` variant in + `src/cipher.rs`, the impl in `src/store/.rs`, tests in + `src/store//test.rs`. Point and prefix lookups use + `whatsapp_store_value`; a store that needs ordering, ranges or deletes by + age gets its own table. + +## Checks + +- `cargo test -p whatsapp_store_pg` with `SQLX_OFFLINE` unset and + `DATABASE_URL` on a database with the MacroDB migrations applied. +- `cargo clippy -p whatsapp_store_pg --all-targets --features fake-kms` + +## Gotchas + +- Every write goes through `begin_write` (fencing) and a value's key parts + must match between `seal` and `open`, or it stops decrypting. Never move or + rename a stored key without re-encrypting (see `move_signal_user`). +- Read-modify-write operations take `lock_device` (`FOR NO KEY UPDATE` on the + device row); plain writes don't. +- `PgWhatsAppStore::seal(purpose, plaintext)` / `unseal(purpose, sealed)` + encrypt blobs kept outside the store (e.g. media download locators) under + the account's data key: `0x01 | nonce | ciphertext`, associated data + `account | "sealed" | purpose`, so they never open as a store value or + for another purpose or account. +- whatsmeow errors come back as `StoreError::Internal`; use `is_fenced` to + detect a lost lease. +- `fake-kms` exposes `FakeWhatsAppKms` for other crates' tests; never enable + it in a binary. +- Deleting the device row deletes the keys; it does not unlink the device at + WhatsApp. + +## Related + +- Rules: `.agents/rules/rust/security.md` (CS-72), `.agents/rules/rust/db.md` diff --git a/crates/whatsapp_store_pg/CLAUDE.md b/crates/whatsapp_store_pg/CLAUDE.md new file mode 120000 index 00000000000..47dc3e3d863 --- /dev/null +++ b/crates/whatsapp_store_pg/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/crates/whatsapp_store_pg/Cargo.toml b/crates/whatsapp_store_pg/Cargo.toml new file mode 100644 index 00000000000..9fa5db33691 --- /dev/null +++ b/crates/whatsapp_store_pg/Cargo.toml @@ -0,0 +1,36 @@ +[package] +edition = "2024" +name = "whatsapp_store_pg" +publish = false +version = "0.1.0" +description = "Postgres implementation of the whatsmeow device stores, with per-account envelope encryption." + +[features] +# The in-memory KMS stand-in, for tests of crates that open a store. +fake-kms = [] + +[dependencies] +aes-gcm = { workspace = true } +async-trait = { workspace = true } +aws-sdk-kms = { workspace = true } +chrono = { workspace = true } +hex = { workspace = true } +postcard = { workspace = true } +prost = { workspace = true } +rand = { workspace = true } +rootcause = { workspace = true } +serde = { workspace = true } +sqlx = { workspace = true } +strum = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["sync"] } +tracing = { workspace = true } +uuid = { workspace = true } +whatsmeow = { path = "../whatsmeow" } +workspace-hack = { version = "0.1", path = "../workspace-hack" } +zeroize = { workspace = true } + +[dev-dependencies] +futures = { workspace = true } +macro_db_migrator = { path = "../macro_db_migrator" } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/whatsapp_store_pg/src/cipher.rs b/crates/whatsapp_store_pg/src/cipher.rs new file mode 100644 index 00000000000..d44eb432ac5 --- /dev/null +++ b/crates/whatsapp_store_pg/src/cipher.rs @@ -0,0 +1,170 @@ +//! Encrypting stored values under an account's data key. +//! +//! AES-256-GCM with a random 96-bit nonce per write, so the same data key can +//! seal any number of values: at 2^32 writes per account the collision +//! probability of random nonces is still below 2^-32. Each value is bound by +//! its associated data to the account, the store and the key it was written +//! under, so a ciphertext copied to another row, store or account fails to +//! decrypt instead of being read as that row's value. + +#[cfg(test)] +mod test; + +use aes_gcm::aead::{Aead, Payload}; +use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; +use uuid::Uuid; +use zeroize::Zeroizing; + +use crate::error::WhatsAppStoreError; + +/// The scheme this crate writes, stored on the device row so a second scheme +/// can be added without a migration. +pub const ENCRYPTION_VERSION: i16 = 1; + +const NONCE_LENGTH: usize = 12; +const DATA_KEY_LENGTH: usize = 32; + +/// An account's data key in plaintext. +/// +/// Lives only in memory, from the KMS `Decrypt` (or `GenerateDataKey`) when a +/// replica takes the account's lease until the store is dropped. Zeroizes on +/// drop, redacts its `Debug`, and has no serialization. +pub(crate) struct WhatsAppDataKey(Zeroizing<[u8; DATA_KEY_LENGTH]>); + +impl WhatsAppDataKey { + /// Accepts exactly 32 bytes of key material. + pub(crate) fn from_plaintext(plaintext: &[u8]) -> Result { + let key: [u8; DATA_KEY_LENGTH] = plaintext + .try_into() + .map_err(|_| WhatsAppStoreError::Malformed)?; + Ok(Self(Zeroizing::new(key))) + } +} + +impl std::fmt::Debug for WhatsAppDataKey { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("WhatsAppDataKey(redacted)") + } +} + +/// Which store a value belongs to: the `store` column of +/// `whatsapp_store_value`, and part of every value's associated data. +#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +pub(crate) enum StoreName { + Device, + Identity, + Session, + SenderKey, + AppStateSyncKey, + AppStateVersion, + Contact, + ChatSettings, + MessageSecret, + NctSalt, + LidByPn, + PnByLid, + PreKey, + AppStateMutationMac, + PrivacyToken, + BufferedEvent, + OutgoingEvent, + /// Not a store: blobs sealed for callers by [`crate::PgWhatsAppStore::seal`], + /// whose "key" is their purpose. + Sealed, +} + +impl StoreName { + pub(crate) fn as_str(self) -> &'static str { + self.into() + } +} + +/// A sealed value, ready for its `nonce` and `value` columns. +pub(crate) struct Sealed { + pub(crate) nonce: Vec, + pub(crate) value: Vec, +} + +/// Seals and opens the values of one account. +pub(crate) struct ValueCipher { + account_id: Uuid, + // `aes-gcm` is built with `zeroize`, so the key schedule is wiped on drop. + aead: Aes256Gcm, +} + +impl ValueCipher { + pub(crate) fn new(account_id: Uuid, data_key: &WhatsAppDataKey) -> Self { + Self { + account_id, + aead: Aes256Gcm::new(data_key.0.as_ref().into()), + } + } + + /// Encrypts a value stored under `key` in `store`. A composite key passes + /// one part per component. + pub(crate) fn seal( + &self, + store: StoreName, + key: &[&[u8]], + plaintext: &[u8], + ) -> Result { + let nonce: [u8; NONCE_LENGTH] = rand::random(); + let associated_data = self.associated_data(store, key); + let value = self + .aead + .encrypt( + &Nonce::from(nonce), + Payload { + msg: plaintext, + aad: &associated_data, + }, + ) + .map_err(|_| WhatsAppStoreError::Encrypt)?; + Ok(Sealed { + nonce: nonce.to_vec(), + value, + }) + } + + /// Decrypts a value read from under `key` in `store`. Fails if the + /// ciphertext was written for any other account, store or key. + pub(crate) fn open( + &self, + store: StoreName, + key: &[&[u8]], + nonce: &[u8], + value: &[u8], + ) -> Result, WhatsAppStoreError> { + let nonce: [u8; NONCE_LENGTH] = + nonce.try_into().map_err(|_| WhatsAppStoreError::Decrypt)?; + let associated_data = self.associated_data(store, key); + self.aead + .decrypt( + &Nonce::from(nonce), + Payload { + msg: value, + aad: &associated_data, + }, + ) + .map_err(|_| WhatsAppStoreError::Decrypt) + } + + /// `account_id | store | key…`: the account's 16 bytes, then each other + /// part prefixed by its length as a big-endian u32, so no two different + /// (store, key) pairs encode to the same bytes. + fn associated_data(&self, store: StoreName, key: &[&[u8]]) -> Vec { + let store = store.as_str().as_bytes(); + let mut associated_data = Vec::with_capacity( + 16 + 4 + store.len() + key.iter().map(|part| 4 + part.len()).sum::(), + ); + associated_data.extend_from_slice(self.account_id.as_bytes()); + for part in std::iter::once(store).chain(key.iter().copied()) { + // Parts are keys and names, far below 4 GiB. + let length = u32::try_from(part.len()).unwrap_or(u32::MAX); + associated_data.extend_from_slice(&length.to_be_bytes()); + associated_data.extend_from_slice(part); + } + associated_data + } +} diff --git a/crates/whatsapp_store_pg/src/cipher/test.rs b/crates/whatsapp_store_pg/src/cipher/test.rs new file mode 100644 index 00000000000..9f2b6e2209b --- /dev/null +++ b/crates/whatsapp_store_pg/src/cipher/test.rs @@ -0,0 +1,132 @@ +use uuid::Uuid; + +use super::*; + +const ACCOUNT: Uuid = Uuid::from_u128(1); +const OTHER_ACCOUNT: Uuid = Uuid::from_u128(2); + +fn cipher(account_id: Uuid) -> ValueCipher { + ValueCipher::new( + account_id, + &WhatsAppDataKey::from_plaintext(&[42; 32]).unwrap(), + ) +} + +#[test] +fn a_sealed_value_opens_only_under_its_account_store_and_key() { + let sealed = cipher(ACCOUNT) + .seal(StoreName::Session, &[b"111:0"], b"session") + .unwrap(); + + assert_eq!( + cipher(ACCOUNT) + .open( + StoreName::Session, + &[b"111:0"], + &sealed.nonce, + &sealed.value + ) + .unwrap(), + b"session" + ); + for (account, store, key) in [ + (OTHER_ACCOUNT, StoreName::Session, b"111:0".as_slice()), + (ACCOUNT, StoreName::Identity, b"111:0".as_slice()), + (ACCOUNT, StoreName::Session, b"111:1".as_slice()), + ] { + assert!(matches!( + cipher(account).open(store, &[key], &sealed.nonce, &sealed.value), + Err(WhatsAppStoreError::Decrypt) + )); + } +} + +#[test] +fn key_parts_are_length_prefixed_so_splits_differ() { + let sealed = cipher(ACCOUNT) + .seal(StoreName::MessageSecret, &[b"ab", b"c"], b"secret") + .unwrap(); + assert!( + cipher(ACCOUNT) + .open( + StoreName::MessageSecret, + &[b"a", b"bc"], + &sealed.nonce, + &sealed.value + ) + .is_err() + ); + assert!( + cipher(ACCOUNT) + .open( + StoreName::MessageSecret, + &[b"abc"], + &sealed.nonce, + &sealed.value + ) + .is_err() + ); +} + +#[test] +fn another_data_key_or_a_tampered_value_does_not_open() { + let sealed = cipher(ACCOUNT) + .seal(StoreName::Session, &[b"111:0"], b"session") + .unwrap(); + let other_key = ValueCipher::new( + ACCOUNT, + &WhatsAppDataKey::from_plaintext(&[43; 32]).unwrap(), + ); + assert!( + other_key + .open( + StoreName::Session, + &[b"111:0"], + &sealed.nonce, + &sealed.value + ) + .is_err() + ); + + let mut tampered = sealed.value.clone(); + tampered[0] ^= 1; + assert!( + cipher(ACCOUNT) + .open(StoreName::Session, &[b"111:0"], &sealed.nonce, &tampered) + .is_err() + ); + assert!( + cipher(ACCOUNT) + .open( + StoreName::Session, + &[b"111:0"], + &sealed.nonce[..11], + &sealed.value + ) + .is_err() + ); +} + +#[test] +fn every_seal_uses_a_fresh_nonce() { + let cipher = cipher(ACCOUNT); + let first = cipher + .seal(StoreName::Session, &[b"111:0"], b"same") + .unwrap(); + let second = cipher + .seal(StoreName::Session, &[b"111:0"], b"same") + .unwrap(); + assert_eq!(first.nonce.len(), 12); + assert_ne!(first.nonce, second.nonce); + assert_ne!(first.value, second.value); +} + +#[test] +fn the_data_key_is_32_bytes_and_never_printed() { + assert!(matches!( + WhatsAppDataKey::from_plaintext(&[1; 31]), + Err(WhatsAppStoreError::Malformed) + )); + let key = WhatsAppDataKey::from_plaintext(&[0xab; 32]).unwrap(); + assert_eq!(format!("{key:?}"), "WhatsAppDataKey(redacted)"); +} diff --git a/crates/whatsapp_store_pg/src/error.rs b/crates/whatsapp_store_pg/src/error.rs new file mode 100644 index 00000000000..6ba8f96a870 --- /dev/null +++ b/crates/whatsapp_store_pg/src/error.rs @@ -0,0 +1,68 @@ +//! Store failures, and how they reach whatsmeow. + +use whatsmeow::store::StoreError; + +use crate::kms::WhatsAppKmsError; + +/// Why a store operation failed. +/// +/// Carries no stored values or keys: these errors end up in logs. +#[derive(Debug, thiserror::Error)] +pub enum WhatsAppStoreError { + /// The account's lease epoch is no longer the one this store was opened + /// with: another replica took the account over, so this one must stop. + /// Nothing was written. + #[error("whatsapp account lease was taken over; this store is fenced out")] + Fenced, + /// The linked account does not exist (or was deleted). + #[error("whatsapp linked account not found")] + AccountNotFound, + /// The device was deleted (logged out) after this store was opened. + #[error("whatsapp device was deleted")] + DeviceDeleted, + /// Wrapping or unwrapping the data key failed, including because the + /// encryption context did not match. + #[error(transparent)] + Kms(#[from] WhatsAppKmsError), + /// The device row was written by a scheme this build does not know. + #[error("whatsapp store uses unsupported encryption version {0}")] + UnsupportedVersion(i16), + /// A stored value did not decrypt: it was tampered with, or moved from + /// another row, store or account. + #[error("a stored whatsapp value failed to decrypt")] + Decrypt, + /// A value could not be encrypted. + #[error("a whatsapp value failed to encrypt")] + Encrypt, + /// A stored value decrypted but is not what its store holds. + #[error("a stored whatsapp value is malformed")] + Malformed, + /// The call was invalid. + #[error("{0}")] + InvalidArgument(String), + /// Postgres failed. + #[error("whatsapp store database error: {0}")] + Database(#[from] sqlx::Error), +} + +impl From for StoreError { + fn from(error: WhatsAppStoreError) -> Self { + match error { + WhatsAppStoreError::InvalidArgument(message) => StoreError::InvalidArgument(message), + WhatsAppStoreError::DeviceDeleted => StoreError::DeviceDeleted, + error => StoreError::Internal(rootcause::Report::new(error).into_dynamic()), + } + } +} + +/// Whether a whatsmeow store failure is this store being fenced out, after +/// which the caller must stop using the account instead of retrying. +pub fn is_fenced(error: &StoreError) -> bool { + match error { + StoreError::Internal(report) => matches!( + report.downcast_current_context::(), + Some(WhatsAppStoreError::Fenced) + ), + _ => false, + } +} diff --git a/crates/whatsapp_store_pg/src/kms.rs b/crates/whatsapp_store_pg/src/kms.rs new file mode 100644 index 00000000000..660874b0df9 --- /dev/null +++ b/crates/whatsapp_store_pg/src/kms.rs @@ -0,0 +1,165 @@ +//! Wrapping an account's data key with AWS KMS. +//! +//! Envelope encryption, as `microsoft_oauth_grant_db_utils` does, not the +//! direct KMS calls of `cursor_api_key`: a device store is thousands of small +//! values written on every message, far too many for a KMS call each. KMS is +//! called once per account to generate the data key (`GenerateDataKey`) and +//! once each time a replica takes the account's lease (`Decrypt`); everything +//! else is local AES-GCM under that key (see [`crate::cipher`]). +//! +//! The wrapped key is bound by its encryption context to one user and one +//! account, and KMS enforces it: a `whatsapp_device` row copied to another +//! account does not unwrap. + +#[cfg(any(test, feature = "fake-kms"))] +mod fake; +#[cfg(test)] +mod test; + +use std::collections::HashMap; + +use aws_sdk_kms::primitives::Blob; +use aws_sdk_kms::types::DataKeySpec; +use uuid::Uuid; +use zeroize::Zeroizing; + +#[cfg(any(test, feature = "fake-kms"))] +pub use fake::FakeWhatsAppKms; + +use crate::cipher::ENCRYPTION_VERSION; + +/// Names the purpose in the encryption context, so a data key from another +/// feature cannot be unwrapped as a WhatsApp one even under the same KMS key. +const ENCRYPTION_PURPOSE: &str = "whatsapp-session"; + +/// A freshly generated data key. +pub struct GeneratedDataKey { + /// The plaintext key. Only for sealing values in memory. + pub plaintext: Zeroizing>, + /// The key wrapped by KMS, to store. + pub ciphertext: Vec, + /// The KMS key that wrapped it. + pub kms_key_id: String, +} + +impl std::fmt::Debug for GeneratedDataKey { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("GeneratedDataKey") + .field("kms_key_id", &self.kms_key_id) + .finish_non_exhaustive() + } +} + +/// A KMS operation failed. +/// +/// Deliberately opaque: the AWS error can tell "wrong encryption context" +/// from "no permission", and a caller that can tell them apart can probe. The +/// detail belongs in CloudTrail. +#[derive(Debug, thiserror::Error)] +#[error("whatsapp data key kms operation failed")] +pub struct WhatsAppKmsError; + +/// The KMS operations the store needs, as a seam tests can stand in for. +pub trait WhatsAppKms: Send + Sync { + /// Generates a 256-bit data key wrapped under the configured KMS key and + /// `encryption_context`. + fn generate_data_key( + &self, + encryption_context: HashMap, + ) -> impl Future> + Send; + + /// Unwraps a data key, which KMS refuses unless `encryption_context` + /// matches the one it was generated with. `kms_key_id` is the key the row + /// recorded; the AWS adapter ignores it and only ever uses its configured + /// key, so changing that key means relinking the accounts. + fn decrypt( + &self, + kms_key_id: &str, + ciphertext: &[u8], + encryption_context: HashMap, + ) -> impl Future>, WhatsAppKmsError>> + Send; +} + +/// The encryption context of an account's data key. +/// +/// The one place it is built, so it cannot be built two slightly different +/// ways: a context that differs at all is a key nothing can unwrap again. +pub(crate) fn encryption_context(user_id: &str, account_id: Uuid) -> HashMap { + HashMap::from([ + ("macro:purpose".to_owned(), ENCRYPTION_PURPOSE.to_owned()), + ("macro:user-id".to_owned(), user_id.to_owned()), + ("macro:account-id".to_owned(), account_id.to_string()), + ( + "macro:encryption-version".to_owned(), + ENCRYPTION_VERSION.to_string(), + ), + ]) +} + +/// [`WhatsAppKms`] over a real AWS KMS client. +/// +/// The key must be separate from the Cursor and Microsoft ones: whatever can +/// unwrap WhatsApp data keys can read every linked account's sessions. +#[derive(Clone)] +pub struct AwsWhatsAppKms { + client: aws_sdk_kms::Client, + kms_key_id: String, +} + +impl AwsWhatsAppKms { + /// Generates data keys under `kms_key_id`, and unwraps them. + pub fn new(client: aws_sdk_kms::Client, kms_key_id: String) -> Self { + Self { client, kms_key_id } + } +} + +impl WhatsAppKms for AwsWhatsAppKms { + #[tracing::instrument(skip_all, err)] + async fn generate_data_key( + &self, + encryption_context: HashMap, + ) -> Result { + let response = self + .client + .generate_data_key() + .key_id(&self.kms_key_id) + .key_spec(DataKeySpec::Aes256) + .set_encryption_context(Some(encryption_context)) + .send() + .await + .map_err(|_| WhatsAppKmsError)?; + let plaintext = response.plaintext.ok_or(WhatsAppKmsError)?; + let ciphertext = response.ciphertext_blob.ok_or(WhatsAppKmsError)?; + Ok(GeneratedDataKey { + plaintext: Zeroizing::new(plaintext.into_inner()), + ciphertext: ciphertext.into_inner(), + // The response names the fully qualified ARN even when the + // configured id is an alias, which is what the row records. + kms_key_id: response.key_id.ok_or(WhatsAppKmsError)?, + }) + } + + #[tracing::instrument(skip_all, err)] + async fn decrypt( + &self, + _kms_key_id: &str, + ciphertext: &[u8], + encryption_context: HashMap, + ) -> Result>, WhatsAppKmsError> { + let response = self + .client + .decrypt() + // Always the configured key, never the id stored with the row: a + // blob wrapped under any other key is refused, even if this role + // could use that key. + .key_id(&self.kms_key_id) + .ciphertext_blob(Blob::new(ciphertext)) + .set_encryption_context(Some(encryption_context)) + .send() + .await + .map_err(|_| WhatsAppKmsError)?; + let plaintext = response.plaintext.ok_or(WhatsAppKmsError)?; + Ok(Zeroizing::new(plaintext.into_inner())) + } +} diff --git a/crates/whatsapp_store_pg/src/kms/fake.rs b/crates/whatsapp_store_pg/src/kms/fake.rs new file mode 100644 index 00000000000..d10fe1121b3 --- /dev/null +++ b/crates/whatsapp_store_pg/src/kms/fake.rs @@ -0,0 +1,110 @@ +//! An in-memory stand-in for KMS, for tests. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use aes_gcm::aead::{Aead, Payload}; +use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; +use zeroize::Zeroizing; + +use super::{GeneratedDataKey, WhatsAppKms, WhatsAppKmsError}; + +const NONCE_LENGTH: usize = 12; + +/// Wraps data keys under a random in-memory key with the encryption context +/// as associated data, so like KMS it refuses to unwrap under any other +/// context or key id. Counts calls, so tests can check how often a store +/// talks to KMS. +pub struct FakeWhatsAppKms { + kms_key_id: String, + wrapping_key: Aes256Gcm, + generate_calls: AtomicUsize, + decrypt_calls: AtomicUsize, +} + +impl FakeWhatsAppKms { + /// A fake whose wrapped keys name `kms_key_id`. + pub fn new(kms_key_id: &str) -> Self { + let wrapping_key: [u8; 32] = rand::random(); + Self { + kms_key_id: kms_key_id.to_owned(), + wrapping_key: Aes256Gcm::new(&wrapping_key.into()), + generate_calls: AtomicUsize::new(0), + decrypt_calls: AtomicUsize::new(0), + } + } + + /// How many data keys were generated. + pub fn generate_calls(&self) -> usize { + self.generate_calls.load(Ordering::SeqCst) + } + + /// How many data keys were unwrapped (successfully or not). + pub fn decrypt_calls(&self) -> usize { + self.decrypt_calls.load(Ordering::SeqCst) + } + + fn associated_data(encryption_context: HashMap) -> Vec { + let sorted: BTreeMap = encryption_context.into_iter().collect(); + let mut associated_data = Vec::new(); + for (key, value) in sorted { + for part in [key, value] { + associated_data.extend_from_slice(&(part.len() as u64).to_be_bytes()); + associated_data.extend_from_slice(part.as_bytes()); + } + } + associated_data + } +} + +impl WhatsAppKms for FakeWhatsAppKms { + async fn generate_data_key( + &self, + encryption_context: HashMap, + ) -> Result { + self.generate_calls.fetch_add(1, Ordering::SeqCst); + let plaintext: [u8; 32] = rand::random(); + let nonce: [u8; NONCE_LENGTH] = rand::random(); + let wrapped = self + .wrapping_key + .encrypt( + &Nonce::from(nonce), + Payload { + msg: &plaintext, + aad: &Self::associated_data(encryption_context), + }, + ) + .map_err(|_| WhatsAppKmsError)?; + Ok(GeneratedDataKey { + plaintext: Zeroizing::new(plaintext.to_vec()), + ciphertext: [nonce.as_slice(), &wrapped].concat(), + kms_key_id: self.kms_key_id.clone(), + }) + } + + async fn decrypt( + &self, + kms_key_id: &str, + ciphertext: &[u8], + encryption_context: HashMap, + ) -> Result>, WhatsAppKmsError> { + self.decrypt_calls.fetch_add(1, Ordering::SeqCst); + if kms_key_id != self.kms_key_id { + return Err(WhatsAppKmsError); + } + let (nonce, wrapped) = ciphertext + .split_first_chunk::() + .ok_or(WhatsAppKmsError)?; + let plaintext = self + .wrapping_key + .decrypt( + &Nonce::from(*nonce), + Payload { + msg: wrapped, + aad: &Self::associated_data(encryption_context), + }, + ) + .map_err(|_| WhatsAppKmsError)?; + Ok(Zeroizing::new(plaintext)) + } +} diff --git a/crates/whatsapp_store_pg/src/kms/test.rs b/crates/whatsapp_store_pg/src/kms/test.rs new file mode 100644 index 00000000000..584d2d1e380 --- /dev/null +++ b/crates/whatsapp_store_pg/src/kms/test.rs @@ -0,0 +1,71 @@ +use uuid::Uuid; + +use super::*; + +const ACCOUNT: Uuid = Uuid::from_u128(1); + +#[test] +fn the_encryption_context_names_the_purpose_user_account_and_version() { + assert_eq!( + encryption_context("macro|alice", ACCOUNT), + HashMap::from([ + ("macro:purpose".to_owned(), "whatsapp-session".to_owned()), + ("macro:user-id".to_owned(), "macro|alice".to_owned()), + ( + "macro:account-id".to_owned(), + "00000000-0000-0000-0000-000000000001".to_owned() + ), + ("macro:encryption-version".to_owned(), "1".to_owned()), + ]) + ); +} + +#[tokio::test] +async fn the_fake_unwraps_only_under_the_same_context_and_key() { + let kms = FakeWhatsAppKms::new("key-1"); + let context = encryption_context("macro|alice", ACCOUNT); + let generated = kms.generate_data_key(context.clone()).await.unwrap(); + assert_eq!(generated.plaintext.len(), 32); + assert_eq!(generated.kms_key_id, "key-1"); + + let unwrapped = kms + .decrypt("key-1", &generated.ciphertext, context.clone()) + .await + .unwrap(); + assert_eq!(*unwrapped, *generated.plaintext); + + for wrong_context in [ + encryption_context("macro|bob", ACCOUNT), + encryption_context("macro|alice", Uuid::from_u128(2)), + ] { + assert!( + kms.decrypt("key-1", &generated.ciphertext, wrong_context) + .await + .is_err() + ); + } + assert!( + kms.decrypt("key-2", &generated.ciphertext, context.clone()) + .await + .is_err() + ); + assert!( + FakeWhatsAppKms::new("key-1") + .decrypt("key-1", &generated.ciphertext, context) + .await + .is_err() + ); + assert_eq!((kms.generate_calls(), kms.decrypt_calls()), (1, 4)); +} + +#[test] +fn a_generated_key_never_prints_its_plaintext() { + let generated = GeneratedDataKey { + plaintext: Zeroizing::new(vec![0xab; 32]), + ciphertext: vec![1, 2, 3], + kms_key_id: "key-1".to_owned(), + }; + let printed = format!("{generated:?}"); + assert!(!printed.contains("171"), "{printed}"); + assert!(printed.contains("key-1")); +} diff --git a/crates/whatsapp_store_pg/src/lib.rs b/crates/whatsapp_store_pg/src/lib.rs new file mode 100644 index 00000000000..db4ae36b630 --- /dev/null +++ b/crates/whatsapp_store_pg/src/lib.rs @@ -0,0 +1,55 @@ +#![deny(missing_docs)] +//! The whatsmeow device store of a linked WhatsApp account, in Postgres. +//! +//! [`PgWhatsAppStore`] implements every store trait of `whatsmeow::store` for +//! one account: Signal identities, sessions, prekeys and sender keys, app +//! state keys and versions, contacts, chat settings, message secrets, privacy +//! tokens, event buffers and LID mappings, plus the device's own keys +//! ([`whatsmeow::store::DeviceContainer`]). [`PgWhatsAppStore::load_device`] +//! hands out a [`whatsmeow::store::Device`] backed by it. +//! +//! # Threat model +//! +//! The store holds the keys of a person's WhatsApp account. With the noise +//! and identity keys and the sessions, anyone can impersonate the linked +//! device: read the person's new messages and send as them, until they unlink +//! it. So every value is encrypted at rest: +//! +//! - **Per-account data key** ([`cipher`]): AES-256-GCM, a fresh random nonce +//! per write, and associated data binding each ciphertext to its account, +//! store and key. A value copied to another row, store or account fails to +//! decrypt. +//! - **Wrapped by KMS** ([`kms`]): the data key is generated by KMS once per +//! account and stored only wrapped, under an encryption context naming the +//! user and the account. A replica unwraps it once when it takes the +//! account's lease and holds it in memory (zeroized on drop, never logged or +//! serialized) until the store is dropped. A database dump alone decrypts +//! nothing; every unwrap is an IAM-gated, CloudTrail-recorded KMS call. +//! - **Plaintext lookup keys**: Signal addresses, JIDs (which contain phone +//! numbers), message IDs, prekey IDs, app state index MACs and timestamps +//! stay plaintext so they can be indexed. Values (keys, sessions, sender +//! keys, app state keys, versions and value MACs, names, settings, secrets, +//! tokens, event plaintexts, and which LID belongs to which phone number) +//! are all sealed. +//! +//! What this does not protect against: a process holding the lease has the +//! plaintext data key, and whatsmeow's [`whatsmeow::store::Device`] holds the +//! device's private keys in memory for the whole connection. +//! +//! # Fencing +//! +//! A store is opened for the lease epoch its caller holds. Every write checks +//! `whatsapp_linked_account.lease_epoch` in the same transaction and fails +//! with [`WhatsAppStoreError::Fenced`] (see [`is_fenced`] for whatsmeow's +//! errors) once another replica has taken the account over. +//! +//! Deleting the account or its device row deletes the keys; it does not +//! unlink the device at WhatsApp. A "disconnect" surface must log out first. + +pub mod cipher; +mod error; +pub mod kms; +mod store; + +pub use error::{WhatsAppStoreError, is_fenced}; +pub use store::PgWhatsAppStore; diff --git a/crates/whatsapp_store_pg/src/store.rs b/crates/whatsapp_store_pg/src/store.rs new file mode 100644 index 00000000000..8a7694c850d --- /dev/null +++ b/crates/whatsapp_store_pg/src/store.rs @@ -0,0 +1,555 @@ +//! The device store of one linked account, and the row helpers every +//! whatsmeow store trait is built from. + +mod app_state; +mod contacts; +mod device; +mod events; +mod lid; +mod signal; +#[cfg(test)] +mod test; + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::Mutex; + +use sqlx::{PgConnection, PgExecutor, PgPool, Postgres, Transaction}; +use uuid::Uuid; +use zeroize::Zeroizing; + +use crate::cipher::{ENCRYPTION_VERSION, StoreName, ValueCipher, WhatsAppDataKey}; +use crate::error::WhatsAppStoreError; +use crate::kms::{WhatsAppKms, encryption_context}; + +/// Every whatsmeow store of one linked account, in Postgres. +/// +/// Holds the account's data key in memory (see [`crate::cipher`]) and the +/// lease epoch the caller held when it opened the store. Every write runs in +/// a transaction that first checks the account still has that epoch, and +/// holds a share lock on the account row until it commits, so once a +/// takeover has bumped the epoch no write from the previous holder can land. +pub struct PgWhatsAppStore { + pool: PgPool, + account_id: Uuid, + lease_epoch: i64, + cipher: ValueCipher, + /// Phone number Signal users already moved to their LID, as whatsmeow + /// remembers them, so a migration runs once per user per process. + migrated_pn_sessions: Mutex>, +} + +impl std::fmt::Debug for PgWhatsAppStore { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PgWhatsAppStore") + .field("account_id", &self.account_id) + .field("lease_epoch", &self.lease_epoch) + .finish_non_exhaustive() + } +} + +impl PgWhatsAppStore { + /// Opens the store of `account_id` for the holder of `lease_epoch`. + /// + /// The first open of an account generates its data key (one KMS + /// `GenerateDataKey`) and stores it wrapped; every later open unwraps it + /// (one KMS `Decrypt`). The plaintext key then stays in memory only. + /// + /// # Errors + /// [`WhatsAppStoreError::Fenced`] when the account's epoch is not + /// `lease_epoch`, [`WhatsAppStoreError::Kms`] when KMS refuses, and + /// database errors. + #[tracing::instrument( + name = "whatsapp_store.open", + skip(pool, kms), + fields(account_id = %account_id, lease_epoch, created), + err + )] + pub async fn open( + pool: PgPool, + account_id: Uuid, + lease_epoch: i64, + kms: &impl WhatsAppKms, + ) -> Result { + let account = sqlx::query!( + r#" + SELECT user_id, lease_epoch + FROM whatsapp_linked_account + WHERE id = $1 + "#, + account_id, + ) + .fetch_optional(&pool) + .await? + .ok_or(WhatsAppStoreError::AccountNotFound)?; + if account.lease_epoch != lease_epoch { + return Err(WhatsAppStoreError::Fenced); + } + let context = encryption_context(&account.user_id, account_id); + + let data_key = match read_wrapped_data_key(&pool, account_id).await? { + Some(wrapped) => wrapped.unwrap(kms, context).await?, + None => { + let generated = kms.generate_data_key(context.clone()).await?; + let mut transaction = pool.begin().await?; + check_lease(&mut transaction, account_id, lease_epoch).await?; + let inserted = sqlx::query_scalar!( + r#" + INSERT INTO whatsapp_device ( + account_id, + data_key_ciphertext, + kms_key_id, + encryption_version + ) + VALUES ($1, $2, $3, $4) + ON CONFLICT (account_id) DO NOTHING + RETURNING account_id + "#, + account_id, + generated.ciphertext, + generated.kms_key_id, + ENCRYPTION_VERSION, + ) + .fetch_optional(&mut *transaction) + .await?; + transaction.commit().await?; + tracing::Span::current().record("created", inserted.is_some()); + match inserted { + Some(_) => WhatsAppDataKey::from_plaintext(&generated.plaintext)?, + // Another open created the device first: use its key. + None => { + read_wrapped_data_key(&pool, account_id) + .await? + .ok_or(WhatsAppStoreError::DeviceDeleted)? + .unwrap(kms, context) + .await? + } + } + } + }; + + Ok(Self { + pool, + account_id, + lease_epoch, + cipher: ValueCipher::new(account_id, &data_key), + migrated_pn_sessions: Mutex::new(HashSet::new()), + }) + } + + /// The account this store belongs to. + pub fn account_id(&self) -> Uuid { + self.account_id + } + + /// The lease epoch every write is checked against. + pub fn lease_epoch(&self) -> i64 { + self.lease_epoch + } + + /// Encrypts a blob the caller keeps elsewhere (e.g. a media download + /// locator) under the account's data key, bound to the account and + /// `purpose`: it only unseals with [`PgWhatsAppStore::unseal`] for the same + /// account and purpose. The output is `SEALED_FORMAT | nonce | ciphertext`. + /// + /// # Errors + /// [`WhatsAppStoreError::Encrypt`] if encryption fails. + pub fn seal(&self, purpose: &str, plaintext: &[u8]) -> Result, WhatsAppStoreError> { + let sealed = self + .cipher + .seal(StoreName::Sealed, &[purpose.as_bytes()], plaintext)?; + Ok([&[SEALED_FORMAT], sealed.nonce.as_slice(), &sealed.value].concat()) + } + + /// Decrypts a blob from [`PgWhatsAppStore::seal`]. + /// + /// # Errors + /// [`WhatsAppStoreError::Decrypt`] if it was sealed for another account or + /// purpose, was tampered with, or is not in a known format. + pub fn unseal( + &self, + purpose: &str, + sealed: &[u8], + ) -> Result>, WhatsAppStoreError> { + let Some((&SEALED_FORMAT, rest)) = sealed.split_first() else { + return Err(WhatsAppStoreError::Decrypt); + }; + let (nonce, value) = rest + .split_at_checked(SEALED_NONCE_LENGTH) + .ok_or(WhatsAppStoreError::Decrypt)?; + Ok(Zeroizing::new(self.cipher.open( + StoreName::Sealed, + &[purpose.as_bytes()], + nonce, + value, + )?)) + } + + /// Begins a write transaction, failing with + /// [`WhatsAppStoreError::Fenced`] unless this store still holds the lease. + async fn begin_write(&self) -> Result, WhatsAppStoreError> { + let mut transaction = self.pool.begin().await?; + check_lease(&mut transaction, self.account_id, self.lease_epoch).await?; + Ok(transaction) + } + + /// Serializes read-modify-write operations of this account until the + /// transaction ends. `NO KEY UPDATE`, so plain writes (whose foreign keys + /// take `KEY SHARE` locks on the device row) are not blocked. + async fn lock_device(&self, connection: &mut PgConnection) -> Result<(), WhatsAppStoreError> { + sqlx::query_scalar!( + r#" + SELECT account_id + FROM whatsapp_device + WHERE account_id = $1 + FOR NO KEY UPDATE + "#, + self.account_id, + ) + .fetch_optional(connection) + .await? + .ok_or(WhatsAppStoreError::DeviceDeleted)?; + Ok(()) + } + + async fn get_value( + &self, + executor: impl PgExecutor<'_>, + store: StoreName, + key: &str, + ) -> Result>, WhatsAppStoreError> { + let row = sqlx::query!( + r#" + SELECT nonce, value + FROM whatsapp_store_value + WHERE account_id = $1 AND store = $2 AND key = $3 + "#, + self.account_id, + store.as_str(), + key, + ) + .fetch_optional(executor) + .await?; + row.map(|row| { + self.cipher + .open(store, &[key.as_bytes()], &row.nonce, &row.value) + }) + .transpose() + } + + /// The values of the keys that have one. + async fn get_values( + &self, + executor: impl PgExecutor<'_>, + store: StoreName, + keys: &[String], + ) -> Result>, WhatsAppStoreError> { + if keys.is_empty() { + return Ok(HashMap::new()); + } + let rows = sqlx::query!( + r#" + SELECT key, nonce, value + FROM whatsapp_store_value + WHERE account_id = $1 AND store = $2 AND key = ANY($3) + "#, + self.account_id, + store.as_str(), + keys, + ) + .fetch_all(executor) + .await?; + rows.into_iter() + .map(|row| { + let value = + self.cipher + .open(store, &[row.key.as_bytes()], &row.nonce, &row.value)?; + Ok((row.key, value)) + }) + .collect() + } + + /// Every value whose key starts with `prefix`, in key order. + async fn list_values( + &self, + executor: impl PgExecutor<'_>, + store: StoreName, + prefix: &str, + ) -> Result)>, WhatsAppStoreError> { + let rows = sqlx::query!( + r#" + SELECT key, nonce, value + FROM whatsapp_store_value + WHERE account_id = $1 AND store = $2 AND key LIKE $3 + ORDER BY key + "#, + self.account_id, + store.as_str(), + like_prefix(prefix), + ) + .fetch_all(executor) + .await?; + rows.into_iter() + .map(|row| { + let value = + self.cipher + .open(store, &[row.key.as_bytes()], &row.nonce, &row.value)?; + Ok((row.key, value)) + }) + .collect() + } + + /// Writes many values in one statement, replacing existing ones. + async fn put_values( + &self, + connection: &mut PgConnection, + store: StoreName, + entries: BTreeMap>, + ) -> Result<(), WhatsAppStoreError> { + let (keys, nonces, values) = self.seal_values(store, entries)?; + if keys.is_empty() { + return Ok(()); + } + sqlx::query!( + r#" + INSERT INTO whatsapp_store_value (account_id, store, key, nonce, value) + SELECT $1, $2, entry.key, entry.nonce, entry.value + FROM UNNEST($3::text[], $4::bytea[], $5::bytea[]) AS entry(key, nonce, value) + ON CONFLICT (account_id, store, key) DO UPDATE SET + nonce = EXCLUDED.nonce, + value = EXCLUDED.value, + updated_at = now() + "#, + self.account_id, + store.as_str(), + &keys, + &nonces, + &values, + ) + .execute(connection) + .await?; + Ok(()) + } + + /// Writes many values in one statement, keeping existing ones. + async fn insert_values_if_absent( + &self, + connection: &mut PgConnection, + store: StoreName, + entries: BTreeMap>, + ) -> Result<(), WhatsAppStoreError> { + let (keys, nonces, values) = self.seal_values(store, entries)?; + if keys.is_empty() { + return Ok(()); + } + sqlx::query!( + r#" + INSERT INTO whatsapp_store_value (account_id, store, key, nonce, value) + SELECT $1, $2, entry.key, entry.nonce, entry.value + FROM UNNEST($3::text[], $4::bytea[], $5::bytea[]) AS entry(key, nonce, value) + ON CONFLICT (account_id, store, key) DO NOTHING + "#, + self.account_id, + store.as_str(), + &keys, + &nonces, + &values, + ) + .execute(connection) + .await?; + Ok(()) + } + + /// Seals entries into the three arrays of an `UNNEST` insert. A map, so a + /// batch never names one key twice (which `ON CONFLICT` rejects), and in + /// key order, so concurrent batches lock rows in the same order. + #[expect( + clippy::type_complexity, + reason = "the three UNNEST columns, used right away by both callers" + )] + fn seal_values( + &self, + store: StoreName, + entries: BTreeMap>, + ) -> Result<(Vec, Vec>, Vec>), WhatsAppStoreError> { + let mut keys = Vec::with_capacity(entries.len()); + let mut nonces = Vec::with_capacity(entries.len()); + let mut values = Vec::with_capacity(entries.len()); + for (key, plaintext) in entries { + let sealed = self.cipher.seal(store, &[key.as_bytes()], &plaintext)?; + keys.push(key); + nonces.push(sealed.nonce); + values.push(sealed.value); + } + Ok((keys, nonces, values)) + } + + async fn delete_values( + &self, + connection: &mut PgConnection, + store: StoreName, + keys: &[String], + ) -> Result<(), WhatsAppStoreError> { + if keys.is_empty() { + return Ok(()); + } + sqlx::query!( + r#" + DELETE FROM whatsapp_store_value + WHERE account_id = $1 AND store = $2 AND key = ANY($3) + "#, + self.account_id, + store.as_str(), + keys, + ) + .execute(connection) + .await?; + Ok(()) + } + + async fn delete_prefix( + &self, + connection: &mut PgConnection, + store: StoreName, + prefix: &str, + ) -> Result<(), WhatsAppStoreError> { + sqlx::query!( + r#" + DELETE FROM whatsapp_store_value + WHERE account_id = $1 AND store = $2 AND key LIKE $3 + "#, + self.account_id, + store.as_str(), + like_prefix(prefix), + ) + .execute(connection) + .await?; + Ok(()) + } + + /// Writes one value in its own fenced transaction. + async fn put_one( + &self, + store: StoreName, + key: String, + plaintext: Vec, + ) -> Result<(), WhatsAppStoreError> { + let mut transaction = self.begin_write().await?; + self.put_values(&mut transaction, store, BTreeMap::from([(key, plaintext)])) + .await?; + transaction.commit().await?; + Ok(()) + } + + /// Deletes one value in its own fenced transaction. + async fn delete_one(&self, store: StoreName, key: String) -> Result<(), WhatsAppStoreError> { + let mut transaction = self.begin_write().await?; + self.delete_values(&mut transaction, store, &[key]).await?; + transaction.commit().await?; + Ok(()) + } +} + +/// Fails with [`WhatsAppStoreError::Fenced`] unless the account still has +/// `lease_epoch`, and share-locks the account row until the transaction ends +/// so a takeover's epoch bump waits for this write. +async fn check_lease( + connection: &mut PgConnection, + account_id: Uuid, + lease_epoch: i64, +) -> Result<(), WhatsAppStoreError> { + let current = sqlx::query_scalar!( + r#" + SELECT lease_epoch + FROM whatsapp_linked_account + WHERE id = $1 + FOR SHARE + "#, + account_id, + ) + .fetch_optional(connection) + .await?; + if current == Some(lease_epoch) { + Ok(()) + } else { + Err(WhatsAppStoreError::Fenced) + } +} + +/// The device row's wrapped data key. +struct WrappedDataKey { + data_key_ciphertext: Vec, + kms_key_id: String, + encryption_version: i16, +} + +impl WrappedDataKey { + async fn unwrap( + self, + kms: &impl WhatsAppKms, + context: HashMap, + ) -> Result { + // Checked before the KMS call: a version this build cannot read is our + // bug to report, not a request worth a KMS call. + if self.encryption_version != ENCRYPTION_VERSION { + return Err(WhatsAppStoreError::UnsupportedVersion( + self.encryption_version, + )); + } + let plaintext = kms + .decrypt(&self.kms_key_id, &self.data_key_ciphertext, context) + .await?; + WhatsAppDataKey::from_plaintext(&plaintext) + } +} + +async fn read_wrapped_data_key( + pool: &PgPool, + account_id: Uuid, +) -> Result, WhatsAppStoreError> { + Ok(sqlx::query_as!( + WrappedDataKey, + r#" + SELECT data_key_ciphertext, kms_key_id, encryption_version + FROM whatsapp_device + WHERE account_id = $1 + "#, + account_id, + ) + .fetch_optional(pool) + .await?) +} + +/// A `LIKE` pattern matching keys that start with `prefix`. Signal users of +/// LIDs contain `_`, a `LIKE` wildcard, so the prefix is escaped. +fn like_prefix(prefix: &str) -> String { + let mut pattern = String::with_capacity(prefix.len() + 1); + for character in prefix.chars() { + if matches!(character, '\\' | '%' | '_') { + pattern.push('\\'); + } + pattern.push(character); + } + pattern.push('%'); + pattern +} + +/// Joins the parts of a composite key. The separator appears in no JID, +/// Signal address or message ID; a part containing it is rejected so two +/// different keys can never join to the same string. +fn composite_key(parts: &[&str]) -> Result { + // The part itself stays out of the error: it may be a phone number. + if parts.iter().any(|part| part.contains(KEY_SEPARATOR)) { + return Err(WhatsAppStoreError::InvalidArgument(format!( + "a store key part contains {KEY_SEPARATOR:?}" + ))); + } + Ok(parts.join(KEY_SEPARATOR)) +} + +const KEY_SEPARATOR: &str = "|"; + +/// The first byte of a [`PgWhatsAppStore::seal`] output, so another format can +/// be added later. +const SEALED_FORMAT: u8 = 1; +const SEALED_NONCE_LENGTH: usize = 12; diff --git a/crates/whatsapp_store_pg/src/store/app_state.rs b/crates/whatsapp_store_pg/src/store/app_state.rs new file mode 100644 index 00000000000..fc5123bd76b --- /dev/null +++ b/crates/whatsapp_store_pg/src/store/app_state.rs @@ -0,0 +1,297 @@ +//! App state sync keys, collection versions and mutation MACs. + +#[cfg(test)] +mod test; + +use std::collections::BTreeMap; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use whatsmeow::store::{ + AppStateMutationMac, AppStateStore, AppStateSyncKey, AppStateSyncKeyStore, StoreError, +}; + +use super::PgWhatsAppStore; +use crate::cipher::StoreName; +use crate::error::WhatsAppStoreError; + +const HASH_LENGTH: usize = 128; + +/// An app state sync key as sealed in its value. +#[derive(Serialize, Deserialize)] +struct SyncKeyRecord { + data: Vec, + fingerprint: Vec, + timestamp: i64, +} + +impl From for AppStateSyncKey { + fn from(record: SyncKeyRecord) -> Self { + AppStateSyncKey { + data: record.data, + fingerprint: record.fingerprint, + timestamp: record.timestamp, + } + } +} + +fn decode_sync_key(plaintext: &[u8]) -> Result { + postcard::from_bytes(plaintext).map_err(|_| WhatsAppStoreError::Malformed) +} + +impl PgWhatsAppStore { + /// Every sync key with its ID, newest first. + async fn all_sync_keys(&self) -> Result, SyncKeyRecord)>, WhatsAppStoreError> { + let mut keys = self + .list_values(&self.pool, StoreName::AppStateSyncKey, "") + .await? + .into_iter() + .map(|(id, plaintext)| { + let id = hex::decode(id).map_err(|_| WhatsAppStoreError::Malformed)?; + Ok((id, decode_sync_key(&plaintext)?)) + }) + .collect::, WhatsAppStoreError>>()?; + keys.sort_by_key(|(_, key)| std::cmp::Reverse(key.timestamp)); + Ok(keys) + } +} + +/// An account has a handful of sync keys, so "newest" is computed over all +/// of them after decrypting rather than from a plaintext timestamp column. +#[async_trait] +impl AppStateSyncKeyStore for PgWhatsAppStore { + async fn put_app_state_sync_key( + &self, + id: &[u8], + key: AppStateSyncKey, + ) -> Result<(), StoreError> { + let id = hex::encode(id); + let mut transaction = self.begin_write().await?; + self.lock_device(&mut transaction).await?; + let existing = self + .get_value(&mut *transaction, StoreName::AppStateSyncKey, &id) + .await? + .map(|plaintext| decode_sync_key(&plaintext)) + .transpose()?; + // As whatsmeow: a key is only replaced by a newer one. + if existing.is_some_and(|existing| key.timestamp <= existing.timestamp) { + return Ok(()); + } + let record = SyncKeyRecord { + data: key.data, + fingerprint: key.fingerprint, + timestamp: key.timestamp, + }; + let plaintext = + postcard::to_allocvec(&record).map_err(|_| WhatsAppStoreError::Malformed)?; + self.put_values( + &mut transaction, + StoreName::AppStateSyncKey, + BTreeMap::from([(id, plaintext)]), + ) + .await?; + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(()) + } + + async fn get_app_state_sync_key( + &self, + id: &[u8], + ) -> Result, StoreError> { + Ok(self + .get_value(&self.pool, StoreName::AppStateSyncKey, &hex::encode(id)) + .await? + .map(|plaintext| decode_sync_key(&plaintext).map(AppStateSyncKey::from)) + .transpose()?) + } + + async fn get_latest_app_state_sync_key_id(&self) -> Result>, StoreError> { + Ok(self + .all_sync_keys() + .await? + .into_iter() + .next() + .map(|(id, _)| id)) + } + + async fn get_all_app_state_sync_keys(&self) -> Result, StoreError> { + Ok(self + .all_sync_keys() + .await? + .into_iter() + .map(|(_, key)| key.into()) + .collect()) + } +} + +/// The version and LT hash of a collection, sealed as the version (8 bytes, +/// big endian) followed by the 128-byte hash. +fn encode_version(version: u64, hash: &[u8; HASH_LENGTH]) -> Vec { + [version.to_be_bytes().as_slice(), hash].concat() +} + +fn decode_version(plaintext: &[u8]) -> Result<(u64, [u8; HASH_LENGTH]), WhatsAppStoreError> { + let (version, hash) = plaintext + .split_first_chunk::<8>() + .ok_or(WhatsAppStoreError::Malformed)?; + let hash: [u8; HASH_LENGTH] = hash.try_into().map_err(|_| WhatsAppStoreError::Malformed)?; + Ok((u64::from_be_bytes(*version), hash)) +} + +#[async_trait] +impl AppStateStore for PgWhatsAppStore { + async fn put_app_state_version( + &self, + name: &str, + version: u64, + hash: [u8; 128], + ) -> Result<(), StoreError> { + Ok(self + .put_one( + StoreName::AppStateVersion, + name.to_owned(), + encode_version(version, &hash), + ) + .await?) + } + + async fn get_app_state_version(&self, name: &str) -> Result<(u64, [u8; 128]), StoreError> { + let Some(plaintext) = self + .get_value(&self.pool, StoreName::AppStateVersion, name) + .await? + else { + // Version 0 and an empty hash: the initial state. + return Ok((0, [0; HASH_LENGTH])); + }; + Ok(decode_version(&plaintext)?) + } + + async fn delete_app_state_version(&self, name: &str) -> Result<(), StoreError> { + Ok(self + .delete_one(StoreName::AppStateVersion, name.to_owned()) + .await?) + } + + async fn put_app_state_mutation_macs( + &self, + name: &str, + version: u64, + mutations: &[AppStateMutationMac], + ) -> Result<(), StoreError> { + if mutations.is_empty() { + return Ok(()); + } + let stored_version = i64::try_from(version).map_err(|_| { + WhatsAppStoreError::InvalidArgument("app state version out of range".to_owned()) + })?; + // One row per index MAC: a batch naming one twice keeps the last, as + // the later mutation wins. + let by_index: BTreeMap<&[u8], &[u8]> = mutations + .iter() + .map(|mutation| (mutation.index_mac.as_slice(), mutation.value_mac.as_slice())) + .collect(); + let mut index_macs = Vec::with_capacity(by_index.len()); + let mut nonces = Vec::with_capacity(by_index.len()); + let mut values = Vec::with_capacity(by_index.len()); + for (index_mac, value_mac) in by_index { + let sealed = self.cipher.seal( + StoreName::AppStateMutationMac, + &[name.as_bytes(), index_mac, &version.to_be_bytes()], + value_mac, + )?; + index_macs.push(index_mac.to_vec()); + nonces.push(sealed.nonce); + values.push(sealed.value); + } + let mut transaction = self.begin_write().await?; + sqlx::query!( + r#" + INSERT INTO whatsapp_app_state_mutation_mac ( + account_id, name, index_mac, version, nonce, value + ) + SELECT $1, $2, entry.index_mac, $3, entry.nonce, entry.value + FROM UNNEST($4::bytea[], $5::bytea[], $6::bytea[]) AS entry(index_mac, nonce, value) + ON CONFLICT (account_id, name, index_mac, version) DO UPDATE SET + nonce = EXCLUDED.nonce, + value = EXCLUDED.value + "#, + self.account_id, + name, + stored_version, + &index_macs, + &nonces, + &values, + ) + .execute(&mut *transaction) + .await + .map_err(WhatsAppStoreError::from)?; + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(()) + } + + async fn delete_app_state_mutation_macs( + &self, + name: &str, + index_macs: &[Vec], + ) -> Result<(), StoreError> { + if index_macs.is_empty() { + return Ok(()); + } + let mut transaction = self.begin_write().await?; + sqlx::query!( + r#" + DELETE FROM whatsapp_app_state_mutation_mac + WHERE account_id = $1 AND name = $2 AND index_mac = ANY($3) + "#, + self.account_id, + name, + index_macs, + ) + .execute(&mut *transaction) + .await + .map_err(WhatsAppStoreError::from)?; + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(()) + } + + async fn get_app_state_mutation_mac( + &self, + name: &str, + index_mac: &[u8], + ) -> Result>, StoreError> { + let row = sqlx::query!( + r#" + SELECT version, nonce, value + FROM whatsapp_app_state_mutation_mac + WHERE account_id = $1 AND name = $2 AND index_mac = $3 + ORDER BY version DESC + LIMIT 1 + "#, + self.account_id, + name, + index_mac, + ) + .fetch_optional(&self.pool) + .await + .map_err(WhatsAppStoreError::from)?; + let Some(row) = row else { + return Ok(None); + }; + let version = u64::try_from(row.version).map_err(|_| WhatsAppStoreError::Malformed)?; + Ok(Some(self.cipher.open( + StoreName::AppStateMutationMac, + &[name.as_bytes(), index_mac, &version.to_be_bytes()], + &row.nonce, + &row.value, + )?)) + } +} diff --git a/crates/whatsapp_store_pg/src/store/app_state/test.rs b/crates/whatsapp_store_pg/src/store/app_state/test.rs new file mode 100644 index 00000000000..ab451c10d24 --- /dev/null +++ b/crates/whatsapp_store_pg/src/store/app_state/test.rs @@ -0,0 +1,192 @@ +use macro_db_migrator::MACRO_DB_MIGRATIONS; +use sqlx::{Pool, Postgres}; +use whatsmeow::store::{AppStateMutationMac, AppStateStore, AppStateSyncKey, AppStateSyncKeyStore}; + +use crate::store::test::open_new_store; + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn sync_keys_are_only_replaced_by_newer_ones(pool: Pool) { + let store = open_new_store(&pool).await; + assert_eq!( + store.get_latest_app_state_sync_key_id().await.unwrap(), + None + ); + assert_eq!(store.get_all_app_state_sync_keys().await.unwrap(), vec![]); + + let older = AppStateSyncKey { + data: vec![1; 32], + fingerprint: vec![1, 2, 3], + timestamp: 1_000, + }; + let newer = AppStateSyncKey { + data: vec![2; 32], + fingerprint: vec![4, 5, 6], + timestamp: 2_000, + }; + store + .put_app_state_sync_key(&[0xaa, 0x01], older.clone()) + .await + .unwrap(); + store + .put_app_state_sync_key(&[0xbb, 0x02], newer.clone()) + .await + .unwrap(); + assert_eq!( + store.get_app_state_sync_key(&[0xaa, 0x01]).await.unwrap(), + Some(older.clone()) + ); + assert_eq!(store.get_app_state_sync_key(&[0xcc]).await.unwrap(), None); + assert_eq!( + store.get_latest_app_state_sync_key_id().await.unwrap(), + Some(vec![0xbb, 0x02]) + ); + assert_eq!( + store.get_all_app_state_sync_keys().await.unwrap(), + vec![newer.clone(), older.clone()] + ); + + // A key with an older or equal timestamp doesn't replace the stored one. + let stale = AppStateSyncKey { + data: vec![9; 32], + fingerprint: vec![9], + timestamp: 2_000, + }; + store + .put_app_state_sync_key(&[0xbb, 0x02], stale) + .await + .unwrap(); + assert_eq!( + store.get_app_state_sync_key(&[0xbb, 0x02]).await.unwrap(), + Some(newer) + ); + let fresh = AppStateSyncKey { + data: vec![3; 32], + fingerprint: vec![7], + timestamp: 3_000, + }; + store + .put_app_state_sync_key(&[0xaa, 0x01], fresh.clone()) + .await + .unwrap(); + assert_eq!( + store.get_app_state_sync_key(&[0xaa, 0x01]).await.unwrap(), + Some(fresh) + ); + assert_eq!( + store.get_latest_app_state_sync_key_id().await.unwrap(), + Some(vec![0xaa, 0x01]) + ); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn app_state_versions_start_at_zero(pool: Pool) { + let store = open_new_store(&pool).await; + assert_eq!( + store.get_app_state_version("regular").await.unwrap(), + (0, [0; 128]) + ); + + let mut hash = [0; 128]; + hash[0] = 1; + hash[127] = 255; + store + .put_app_state_version("regular", 42, hash) + .await + .unwrap(); + store + .put_app_state_version("critical_block", 7, [3; 128]) + .await + .unwrap(); + assert_eq!( + store.get_app_state_version("regular").await.unwrap(), + (42, hash) + ); + + store.delete_app_state_version("regular").await.unwrap(); + assert_eq!( + store.get_app_state_version("regular").await.unwrap(), + (0, [0; 128]) + ); + assert_eq!( + store.get_app_state_version("critical_block").await.unwrap(), + (7, [3; 128]) + ); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn the_newest_mutation_mac_wins_and_deletes_go_by_index(pool: Pool) { + let store = open_new_store(&pool).await; + let mutations: Vec = (0..1000u16) + .map(|index| AppStateMutationMac { + index_mac: index.to_be_bytes().to_vec(), + value_mac: vec![1, index as u8], + }) + .collect(); + store + .put_app_state_mutation_macs("regular", 1, &mutations) + .await + .unwrap(); + store + .put_app_state_mutation_macs( + "regular", + 2, + &[AppStateMutationMac { + index_mac: vec![0, 5], + value_mac: vec![2, 5], + }], + ) + .await + .unwrap(); + store + .put_app_state_mutation_macs("regular", 3, &[]) + .await + .unwrap(); + + assert_eq!( + store + .get_app_state_mutation_mac("regular", &[0, 5]) + .await + .unwrap(), + Some(vec![2, 5]) + ); + assert_eq!( + store + .get_app_state_mutation_mac("regular", &[3, 231]) + .await + .unwrap(), + Some(vec![1, 231]) + ); + assert_eq!( + store + .get_app_state_mutation_mac("critical_block", &[0, 5]) + .await + .unwrap(), + None + ); + + store + .delete_app_state_mutation_macs("regular", &[vec![0, 5], vec![0, 6]]) + .await + .unwrap(); + assert_eq!( + store + .get_app_state_mutation_mac("regular", &[0, 5]) + .await + .unwrap(), + None + ); + assert_eq!( + store + .get_app_state_mutation_mac("regular", &[0, 6]) + .await + .unwrap(), + None + ); + assert_eq!( + store + .get_app_state_mutation_mac("regular", &[0, 7]) + .await + .unwrap(), + Some(vec![1, 7]) + ); +} diff --git a/crates/whatsapp_store_pg/src/store/contacts.rs b/crates/whatsapp_store_pg/src/store/contacts.rs new file mode 100644 index 00000000000..0b04ed6d3e5 --- /dev/null +++ b/crates/whatsapp_store_pg/src/store/contacts.rs @@ -0,0 +1,329 @@ +//! Contact names and local chat settings. Each contact or chat is one sealed +//! record, so whatsmeow's per-column updates become read-modify-writes under +//! the device lock. + +#[cfg(test)] +mod test; + +use std::collections::{BTreeMap, HashMap}; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use whatsmeow::store::{ + ChatSettingsStore, ContactEntry, ContactStore, RedactedPhoneEntry, StoreError, +}; +use whatsmeow::types::{ContactInfo, Jid, LocalChatSettings, MessageId}; + +use super::PgWhatsAppStore; +use crate::cipher::StoreName; +use crate::error::WhatsAppStoreError; + +/// A contact as sealed in its value. +#[derive(Default, Serialize, Deserialize)] +struct ContactRecord { + first_name: String, + full_name: String, + push_name: String, + business_name: String, + redacted_phone: String, +} + +impl From for ContactInfo { + fn from(record: ContactRecord) -> Self { + ContactInfo { + first_name: record.first_name, + full_name: record.full_name, + push_name: record.push_name, + business_name: record.business_name, + redacted_phone: record.redacted_phone, + } + } +} + +/// A chat's settings as sealed in its value. +#[derive(Default, Serialize, Deserialize)] +struct ChatSettingsRecord { + muted_until: Option>, + pinned: bool, + archived: bool, + wasa_root_secret_id: Option, +} + +fn encode(record: &Record) -> Result, WhatsAppStoreError> { + postcard::to_allocvec(record).map_err(|_| WhatsAppStoreError::Malformed) +} + +fn decode Deserialize<'de>>( + plaintext: &[u8], +) -> Result { + postcard::from_bytes(plaintext).map_err(|_| WhatsAppStoreError::Malformed) +} + +impl PgWhatsAppStore { + /// Updates the records of `keys` in one transaction: reads them under the + /// device lock (a missing one starts from its default), applies `update` + /// to each, and writes back those it reports changed. Returns what + /// `update` returned per key. + async fn update_records( + &self, + store: StoreName, + keys: Vec, + mut update: impl FnMut(&str, &mut Record) -> (bool, Outcome) + Send, + ) -> Result, WhatsAppStoreError> + where + Record: Default + Serialize + for<'de> Deserialize<'de>, + { + let mut transaction = self.begin_write().await?; + self.lock_device(&mut transaction).await?; + let mut existing = self.get_values(&mut *transaction, store, &keys).await?; + let mut changed = BTreeMap::new(); + let mut outcomes = HashMap::with_capacity(keys.len()); + for key in keys { + let mut record = existing + .remove(&key) + .map(|plaintext| decode::(&plaintext)) + .transpose()? + .unwrap_or_default(); + let (was_changed, outcome) = update(&key, &mut record); + if was_changed { + changed.insert(key.clone(), encode(&record)?); + } + outcomes.insert(key, outcome); + } + self.put_values(&mut transaction, store, changed).await?; + transaction.commit().await?; + Ok(outcomes) + } + + async fn update_record( + &self, + store: StoreName, + key: String, + mut update: impl FnMut(&mut Record) -> (bool, Outcome) + Send, + ) -> Result + where + Record: Default + Serialize + for<'de> Deserialize<'de>, + { + self.update_records(store, vec![key], |_, record| update(record)) + .await? + .into_values() + .next() + .ok_or(WhatsAppStoreError::Malformed) + } +} + +/// Replaces a name, reporting whether it changed and what it was, as +/// whatsmeow's `PutPushName` does. +fn replace_name(name: &mut String, new_name: &str) -> (bool, (bool, String)) { + if name == new_name { + return (false, (false, String::new())); + } + let previous = std::mem::replace(name, new_name.to_owned()); + (true, (true, previous)) +} + +#[async_trait] +impl ContactStore for PgWhatsAppStore { + async fn put_push_name( + &self, + user: &Jid, + push_name: &str, + ) -> Result<(bool, String), StoreError> { + Ok(self + .update_record( + StoreName::Contact, + user.to_string(), + |record: &mut ContactRecord| replace_name(&mut record.push_name, push_name), + ) + .await?) + } + + async fn put_business_name( + &self, + user: &Jid, + business_name: &str, + ) -> Result<(bool, String), StoreError> { + Ok(self + .update_record( + StoreName::Contact, + user.to_string(), + |record: &mut ContactRecord| replace_name(&mut record.business_name, business_name), + ) + .await?) + } + + async fn put_contact_name( + &self, + user: &Jid, + full_name: &str, + first_name: &str, + ) -> Result<(), StoreError> { + self.update_record( + StoreName::Contact, + user.to_string(), + |record: &mut ContactRecord| { + let changed = record.full_name != full_name || record.first_name != first_name; + full_name.clone_into(&mut record.full_name); + first_name.clone_into(&mut record.first_name); + (changed, ()) + }, + ) + .await?; + Ok(()) + } + + async fn put_all_contact_names(&self, contacts: &[ContactEntry]) -> Result<(), StoreError> { + // A contact listed twice keeps its last names, as whatsmeow dedupes. + let names: HashMap = contacts + .iter() + .map(|entry| (entry.jid.to_string(), entry)) + .collect(); + let keys = names.keys().cloned().collect(); + self.update_records( + StoreName::Contact, + keys, + |key, record: &mut ContactRecord| { + let Some(entry) = names.get(key) else { + return (false, ()); + }; + let changed = + record.full_name != entry.full_name || record.first_name != entry.first_name; + record.full_name.clone_from(&entry.full_name); + record.first_name.clone_from(&entry.first_name); + (changed, ()) + }, + ) + .await?; + Ok(()) + } + + async fn put_many_redacted_phones( + &self, + entries: &[RedactedPhoneEntry], + ) -> Result<(), StoreError> { + let phones: HashMap = entries + .iter() + .map(|entry| (entry.jid.to_string(), entry.redacted_phone.as_str())) + .collect(); + let keys = phones.keys().cloned().collect(); + self.update_records( + StoreName::Contact, + keys, + |key, record: &mut ContactRecord| { + let Some(phone) = phones.get(key) else { + return (false, ()); + }; + let changed = record.redacted_phone != *phone; + (*phone).clone_into(&mut record.redacted_phone); + (changed, ()) + }, + ) + .await?; + Ok(()) + } + + async fn get_contact(&self, user: &Jid) -> Result, StoreError> { + Ok(self + .get_value(&self.pool, StoreName::Contact, &user.to_string()) + .await? + .map(|plaintext| decode::(&plaintext).map(ContactInfo::from)) + .transpose()?) + } + + async fn get_all_contacts(&self) -> Result, StoreError> { + Ok(self + .list_values(&self.pool, StoreName::Contact, "") + .await? + .into_iter() + .map(|(jid, plaintext)| { + let jid: Jid = jid.parse().map_err(|_| WhatsAppStoreError::Malformed)?; + Ok((jid, decode::(&plaintext)?.into())) + }) + .collect::>()?) + } +} + +#[async_trait] +impl ChatSettingsStore for PgWhatsAppStore { + async fn put_muted_until( + &self, + chat: &Jid, + muted_until: Option>, + ) -> Result<(), StoreError> { + self.update_record( + StoreName::ChatSettings, + chat.to_string(), + |record: &mut ChatSettingsRecord| { + record.muted_until = muted_until; + (true, ()) + }, + ) + .await?; + Ok(()) + } + + async fn put_pinned(&self, chat: &Jid, pinned: bool) -> Result<(), StoreError> { + self.update_record( + StoreName::ChatSettings, + chat.to_string(), + |record: &mut ChatSettingsRecord| { + record.pinned = pinned; + (true, ()) + }, + ) + .await?; + Ok(()) + } + + async fn put_archived(&self, chat: &Jid, archived: bool) -> Result<(), StoreError> { + self.update_record( + StoreName::ChatSettings, + chat.to_string(), + |record: &mut ChatSettingsRecord| { + record.archived = archived; + (true, ()) + }, + ) + .await?; + Ok(()) + } + + async fn put_wasa_root_secret_id(&self, chat: &Jid, id: &MessageId) -> Result<(), StoreError> { + self.update_record( + StoreName::ChatSettings, + chat.to_string(), + |record: &mut ChatSettingsRecord| { + record.wasa_root_secret_id = Some(id.clone()); + (true, ()) + }, + ) + .await?; + Ok(()) + } + + async fn get_wasa_root_secret_id(&self, chat: &Jid) -> Result, StoreError> { + Ok(self + .get_value(&self.pool, StoreName::ChatSettings, &chat.to_string()) + .await? + .map(|plaintext| decode::(&plaintext)) + .transpose()? + .and_then(|record| record.wasa_root_secret_id)) + } + + /// Like whatsmeow, a chat has settings once any of them (including its + /// WASA root secret ID) was stored. + async fn get_chat_settings(&self, chat: &Jid) -> Result, StoreError> { + Ok(self + .get_value(&self.pool, StoreName::ChatSettings, &chat.to_string()) + .await? + .map(|plaintext| { + decode::(&plaintext).map(|record| LocalChatSettings { + muted_until: record.muted_until, + pinned: record.pinned, + archived: record.archived, + }) + }) + .transpose()?) + } +} diff --git a/crates/whatsapp_store_pg/src/store/contacts/test.rs b/crates/whatsapp_store_pg/src/store/contacts/test.rs new file mode 100644 index 00000000000..788cac08fcd --- /dev/null +++ b/crates/whatsapp_store_pg/src/store/contacts/test.rs @@ -0,0 +1,192 @@ +use std::collections::HashMap; + +use chrono::{TimeZone, Utc}; +use macro_db_migrator::MACRO_DB_MIGRATIONS; +use sqlx::{Pool, Postgres}; +use whatsmeow::store::{ + ChatSettingsStore, ContactEntry, ContactStore, MUTED_FOREVER, RedactedPhoneEntry, +}; +use whatsmeow::types::{ + ContactInfo, DEFAULT_USER_SERVER, GROUP_SERVER, HIDDEN_USER_SERVER, LocalChatSettings, new_jid, +}; + +use crate::store::test::open_new_store; + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn names_report_whether_they_changed(pool: Pool) { + let store = open_new_store(&pool).await; + let alice = new_jid("111", DEFAULT_USER_SERVER); + assert_eq!(store.get_contact(&alice).await.unwrap(), None); + + assert_eq!( + store.put_push_name(&alice, "Alice").await.unwrap(), + (true, String::new()) + ); + assert_eq!( + store.put_push_name(&alice, "Alice").await.unwrap(), + (false, String::new()) + ); + assert_eq!( + store.put_push_name(&alice, "Al").await.unwrap(), + (true, "Alice".to_owned()) + ); + assert_eq!( + store.put_business_name(&alice, "Alice Inc").await.unwrap(), + (true, String::new()) + ); + store + .put_contact_name(&alice, "Alice Smith", "Alice") + .await + .unwrap(); + + assert_eq!( + store.get_contact(&alice).await.unwrap(), + Some(ContactInfo { + first_name: "Alice".to_owned(), + full_name: "Alice Smith".to_owned(), + push_name: "Al".to_owned(), + business_name: "Alice Inc".to_owned(), + redacted_phone: String::new(), + }) + ); + + // An unchanged empty name for an unknown contact stores nothing. + let unknown = new_jid("999", DEFAULT_USER_SERVER); + assert_eq!( + store.put_push_name(&unknown, "").await.unwrap(), + (false, String::new()) + ); + assert_eq!(store.get_contact(&unknown).await.unwrap(), None); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn batches_merge_into_existing_contacts(pool: Pool) { + let store = open_new_store(&pool).await; + let alice = new_jid("111", DEFAULT_USER_SERVER); + let bob = new_jid("222", DEFAULT_USER_SERVER); + let lid_user = new_jid("98765", HIDDEN_USER_SERVER); + store.put_push_name(&alice, "Al").await.unwrap(); + + store + .put_all_contact_names(&[ + ContactEntry { + jid: alice.clone(), + first_name: "Wrong".to_owned(), + full_name: "Wrong".to_owned(), + }, + ContactEntry { + jid: bob.clone(), + first_name: "Bob".to_owned(), + full_name: "Bob Jones".to_owned(), + }, + // Listed twice: the last entry wins. + ContactEntry { + jid: alice.clone(), + first_name: "Alice".to_owned(), + full_name: "Alice Smith".to_owned(), + }, + ]) + .await + .unwrap(); + store + .put_many_redacted_phones(&[RedactedPhoneEntry { + jid: lid_user.clone(), + redacted_phone: "+1∙∙∙∙∙∙∙∙80".to_owned(), + }]) + .await + .unwrap(); + store.put_all_contact_names(&[]).await.unwrap(); + + assert_eq!( + store.get_all_contacts().await.unwrap(), + HashMap::from([ + ( + alice, + ContactInfo { + first_name: "Alice".to_owned(), + full_name: "Alice Smith".to_owned(), + push_name: "Al".to_owned(), + ..ContactInfo::default() + } + ), + ( + bob, + ContactInfo { + first_name: "Bob".to_owned(), + full_name: "Bob Jones".to_owned(), + ..ContactInfo::default() + } + ), + ( + lid_user, + ContactInfo { + redacted_phone: "+1∙∙∙∙∙∙∙∙80".to_owned(), + ..ContactInfo::default() + } + ), + ]) + ); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn chat_settings_are_kept_per_chat(pool: Pool) { + let store = open_new_store(&pool).await; + let group = new_jid("123-456", GROUP_SERVER); + let direct = new_jid("111", DEFAULT_USER_SERVER); + assert_eq!(store.get_chat_settings(&group).await.unwrap(), None); + + let muted_until = Utc.with_ymd_and_hms(2030, 1, 2, 3, 4, 5).unwrap(); + store + .put_muted_until(&group, Some(muted_until)) + .await + .unwrap(); + store.put_pinned(&group, true).await.unwrap(); + store.put_archived(&direct, true).await.unwrap(); + store + .put_muted_until(&direct, Some(*MUTED_FOREVER)) + .await + .unwrap(); + + assert_eq!( + store.get_chat_settings(&group).await.unwrap(), + Some(LocalChatSettings { + muted_until: Some(muted_until), + pinned: true, + archived: false, + }) + ); + assert_eq!( + store.get_chat_settings(&direct).await.unwrap(), + Some(LocalChatSettings { + muted_until: Some(*MUTED_FOREVER), + pinned: false, + archived: true, + }) + ); + + store.put_muted_until(&group, None).await.unwrap(); + assert_eq!( + store.get_chat_settings(&group).await.unwrap(), + Some(LocalChatSettings { + muted_until: None, + pinned: true, + archived: false, + }) + ); + + // Storing only the WASA root secret ID creates the chat's settings. + let other = new_jid("789-012", GROUP_SERVER); + assert_eq!(store.get_wasa_root_secret_id(&other).await.unwrap(), None); + store + .put_wasa_root_secret_id(&other, &"3EB0ABCDEF".to_owned()) + .await + .unwrap(); + assert_eq!( + store.get_wasa_root_secret_id(&other).await.unwrap(), + Some("3EB0ABCDEF".to_owned()) + ); + assert_eq!( + store.get_chat_settings(&other).await.unwrap(), + Some(LocalChatSettings::default()) + ); +} diff --git a/crates/whatsapp_store_pg/src/store/device.rs b/crates/whatsapp_store_pg/src/store/device.rs new file mode 100644 index 00000000000..ece2f800cf3 --- /dev/null +++ b/crates/whatsapp_store_pg/src/store/device.rs @@ -0,0 +1,255 @@ +//! The device's own data (its keys, JIDs and names), and building a whatsmeow +//! [`Device`] backed by this store. + +#[cfg(test)] +mod test; + +use std::sync::Arc; + +use async_trait::async_trait; +use prost::Message; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use whatsmeow::proto::wa_adv::AdvSignedDeviceIdentity; +use whatsmeow::signal::CurveSignatures; +use whatsmeow::store::{Device, DeviceContainer, StoreError}; +use whatsmeow::types::Jid; +use whatsmeow::util::keys::{PreKey, new_key_pair, new_key_pair_from_private_key}; + +use super::PgWhatsAppStore; +use crate::cipher::StoreName; +use crate::error::WhatsAppStoreError; + +/// The device record is one value per account. +const DEVICE_KEY: &str = ""; + +/// The ID of the signed prekey of a new device, as whatsmeow numbers it. +const SIGNED_PRE_KEY_ID: u32 = 1; + +/// A device as sealed in its value. Public keys are derived from the private +/// ones on load, as whatsmeow's `sqlstore` does. +#[derive(Serialize, Deserialize)] +struct DeviceRecord { + noise_private_key: [u8; 32], + identity_private_key: [u8; 32], + signed_pre_key_private_key: [u8; 32], + signed_pre_key_id: u32, + signed_pre_key_signature: Option>, + registration_id: u32, + adv_secret_key: Vec, + jid: String, + lid: String, + /// The protobuf `AdvSignedDeviceIdentity`, when known. + account: Option>, + platform: String, + business_name: String, + push_name: String, + lid_migration_timestamp: i64, + companion_meta_nonce: String, + facebook_uuid: Option, +} + +impl PgWhatsAppStore { + /// The account's device with every store backed by this one: the saved + /// device, or a new unpaired one (not saved until whatsmeow pairs and + /// calls [`Device::save`]), as whatsmeow's `Container` hands out. + /// + /// # Errors + /// If the stored device fails to decrypt or decode, or the database fails. + #[tracing::instrument( + name = "whatsapp_store.load_device", + skip_all, + fields(account_id = %self.account_id, initialized), + err + )] + pub async fn load_device( + self: &Arc, + signatures: &impl CurveSignatures, + ) -> Result { + let stored = self + .get_value(&self.pool, StoreName::Device, DEVICE_KEY) + .await?; + tracing::Span::current().record("initialized", stored.is_some()); + let Some(plaintext) = stored else { + let identity_key = new_key_pair(); + let signed_pre_key = identity_key.create_signed_pre_key(signatures, SIGNED_PRE_KEY_ID); + return Ok(self.device(DeviceParts { + noise_key: new_key_pair(), + identity_key, + signed_pre_key, + registration_id: rand::random(), + adv_secret_key: rand::random::<[u8; 32]>().to_vec(), + id: None, + lid: Jid::default(), + account: None, + platform: String::new(), + business_name: String::new(), + push_name: String::new(), + lid_migration_timestamp: 0, + companion_meta_nonce: String::new(), + facebook_uuid: None, + initialized: false, + })); + }; + let record: DeviceRecord = + postcard::from_bytes(&plaintext).map_err(|_| WhatsAppStoreError::Malformed)?; + let signature = record + .signed_pre_key_signature + .map(|signature| <[u8; 64]>::try_from(signature.as_slice())) + .transpose() + .map_err(|_| WhatsAppStoreError::Malformed)?; + let parse_jid = |jid: &str| -> Result { + jid.parse().map_err(|_| WhatsAppStoreError::Malformed) + }; + Ok(self.device(DeviceParts { + noise_key: new_key_pair_from_private_key(record.noise_private_key), + identity_key: new_key_pair_from_private_key(record.identity_private_key), + signed_pre_key: PreKey { + key_pair: new_key_pair_from_private_key(record.signed_pre_key_private_key), + key_id: record.signed_pre_key_id, + signature, + }, + registration_id: record.registration_id, + adv_secret_key: record.adv_secret_key, + id: Some(parse_jid(&record.jid)?), + lid: parse_jid(&record.lid)?, + account: record + .account + .map(|account| AdvSignedDeviceIdentity::decode(account.as_slice())) + .transpose() + .map_err(|_| WhatsAppStoreError::Malformed)?, + platform: record.platform, + business_name: record.business_name, + push_name: record.push_name, + lid_migration_timestamp: record.lid_migration_timestamp, + companion_meta_nonce: record.companion_meta_nonce, + facebook_uuid: record.facebook_uuid, + initialized: true, + })) + } + + fn device(self: &Arc, parts: DeviceParts) -> Device { + Device { + noise_key: parts.noise_key, + identity_key: parts.identity_key, + signed_pre_key: parts.signed_pre_key, + registration_id: parts.registration_id, + adv_secret_key: parts.adv_secret_key, + id: parts.id, + lid: parts.lid, + account: parts.account, + platform: parts.platform, + business_name: parts.business_name, + push_name: parts.push_name, + lid_migration_timestamp: parts.lid_migration_timestamp, + companion_meta_nonce: parts.companion_meta_nonce, + facebook_uuid: parts.facebook_uuid, + initialized: parts.initialized, + deleted: false, + identities: self.clone(), + sessions: self.clone(), + pre_keys: self.clone(), + sender_keys: self.clone(), + app_state_keys: self.clone(), + app_state: self.clone(), + contacts: self.clone(), + chat_settings: self.clone(), + msg_secrets: self.clone(), + privacy_tokens: self.clone(), + nct_salt: self.clone(), + event_buffer: self.clone(), + lids: self.clone(), + container: self.clone(), + } + } +} + +/// The data fields of a [`Device`], without its store handles. +struct DeviceParts { + noise_key: whatsmeow::util::keys::KeyPair, + identity_key: whatsmeow::util::keys::KeyPair, + signed_pre_key: PreKey, + registration_id: u32, + adv_secret_key: Vec, + id: Option, + lid: Jid, + account: Option, + platform: String, + business_name: String, + push_name: String, + lid_migration_timestamp: i64, + companion_meta_nonce: String, + facebook_uuid: Option, + initialized: bool, +} + +#[async_trait] +impl DeviceContainer for PgWhatsAppStore { + #[tracing::instrument( + name = "whatsapp_store.put_device", + skip_all, + fields(account_id = %self.account_id), + err + )] + async fn put_device(&self, store: &Device) -> Result<(), StoreError> { + let Some(jid) = &store.id else { + return Err(StoreError::InvalidArgument( + "device JID must be known before accessing database".to_owned(), + )); + }; + let record = DeviceRecord { + noise_private_key: store.noise_key.private, + identity_private_key: store.identity_key.private, + signed_pre_key_private_key: store.signed_pre_key.key_pair.private, + signed_pre_key_id: store.signed_pre_key.key_id, + signed_pre_key_signature: store + .signed_pre_key + .signature + .map(|signature| signature.to_vec()), + registration_id: store.registration_id, + adv_secret_key: store.adv_secret_key.clone(), + jid: jid.to_string(), + lid: store.lid.to_string(), + account: store.account.as_ref().map(Message::encode_to_vec), + platform: store.platform.clone(), + business_name: store.business_name.clone(), + push_name: store.push_name.clone(), + lid_migration_timestamp: store.lid_migration_timestamp, + companion_meta_nonce: store.companion_meta_nonce.clone(), + facebook_uuid: store.facebook_uuid, + }; + let plaintext = + postcard::to_allocvec(&record).map_err(|_| WhatsAppStoreError::Malformed)?; + Ok(self + .put_one(StoreName::Device, DEVICE_KEY.to_owned(), plaintext) + .await?) + } + + /// Deletes the device row, which cascades to every key, session and value + /// of the account. This store is unusable afterwards; opening the account + /// again generates a new data key. + #[tracing::instrument( + name = "whatsapp_store.delete_device", + skip_all, + fields(account_id = %self.account_id), + err + )] + async fn delete_device(&self, _store: &Device) -> Result<(), StoreError> { + let mut transaction = self.begin_write().await?; + sqlx::query!( + r#" + DELETE FROM whatsapp_device + WHERE account_id = $1 + "#, + self.account_id, + ) + .execute(&mut *transaction) + .await + .map_err(WhatsAppStoreError::from)?; + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(()) + } +} diff --git a/crates/whatsapp_store_pg/src/store/device/test.rs b/crates/whatsapp_store_pg/src/store/device/test.rs new file mode 100644 index 00000000000..827f527575c --- /dev/null +++ b/crates/whatsapp_store_pg/src/store/device/test.rs @@ -0,0 +1,94 @@ +use macro_db_migrator::MACRO_DB_MIGRATIONS; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use whatsmeow::proto::wa_adv::AdvSignedDeviceIdentity; +use whatsmeow::store::{SessionStore, StoreError}; +use whatsmeow::types::{HIDDEN_USER_SERVER, new_ad_jid, new_jid}; + +use crate::store::test::{FixedSignatures, open_new_store}; + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn a_new_device_is_unpaired_and_needs_a_jid_to_save(pool: Pool) { + let store = open_new_store(&pool).await; + let device = store.load_device(&FixedSignatures).await.unwrap(); + assert!(!device.initialized); + assert_eq!(device.id, None); + assert_eq!(device.signed_pre_key.key_id, 1); + assert_eq!(device.signed_pre_key.signature, Some([7; 64])); + + let error = device.save().await.unwrap_err(); + assert!(matches!(error, StoreError::InvalidArgument(_)), "{error:?}"); + // Still unpaired: a new device (new keys) each time until one is saved. + let again = store.load_device(&FixedSignatures).await.unwrap(); + assert!(!again.initialized); + assert_ne!(again.noise_key, device.noise_key); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn a_saved_device_loads_back(pool: Pool) { + let store = open_new_store(&pool).await; + let mut device = store.load_device(&FixedSignatures).await.unwrap(); + device.id = Some(new_ad_jid("15551234567", 0, 12)); + device.lid = new_jid("98765", HIDDEN_USER_SERVER); + device.account = Some(AdvSignedDeviceIdentity { + details: Some(vec![1, 2, 3]), + account_signature_key: Some(vec![4; 32]), + account_signature: Some(vec![5; 64]), + device_signature: Some(vec![6; 64]), + }); + device.platform = "android".to_owned(); + device.business_name = "Shop".to_owned(); + device.push_name = "Alice".to_owned(); + device.lid_migration_timestamp = 1_700_000_000; + device.companion_meta_nonce = "nonce".to_owned(); + device.facebook_uuid = Some(Uuid::now_v7()); + device.save().await.unwrap(); + + let loaded = store.load_device(&FixedSignatures).await.unwrap(); + assert!(loaded.initialized); + assert_eq!(loaded.noise_key, device.noise_key); + assert_eq!(loaded.identity_key, device.identity_key); + assert_eq!(loaded.signed_pre_key, device.signed_pre_key); + assert_eq!(loaded.registration_id, device.registration_id); + assert_eq!(loaded.adv_secret_key, device.adv_secret_key); + assert_eq!(loaded.id, device.id); + assert_eq!(loaded.lid, device.lid); + assert_eq!(loaded.account, device.account); + assert_eq!(loaded.platform, "android"); + assert_eq!(loaded.business_name, "Shop"); + assert_eq!(loaded.push_name, "Alice"); + assert_eq!(loaded.lid_migration_timestamp, 1_700_000_000); + assert_eq!(loaded.companion_meta_nonce, "nonce"); + assert_eq!(loaded.facebook_uuid, device.facebook_uuid); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn deleting_the_device_deletes_every_key(pool: Pool) { + let store = open_new_store(&pool).await; + let mut device = store.load_device(&FixedSignatures).await.unwrap(); + device.id = Some(new_ad_jid("15551234567", 0, 12)); + device.save().await.unwrap(); + device + .sessions + .put_session("111:0", b"session".to_vec()) + .await + .unwrap(); + + device.delete().await.unwrap(); + assert!(device.deleted); + let remaining = sqlx::query_scalar!( + r#" + SELECT + (SELECT COUNT(*) FROM whatsapp_device WHERE account_id = $1) + + (SELECT COUNT(*) FROM whatsapp_store_value WHERE account_id = $1) + AS "count!" + "#, + store.account_id(), + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(remaining, 0); + // The store itself is useless now; its writes fail. + assert!(store.put_session("111:0", b"late".to_vec()).await.is_err()); +} diff --git a/crates/whatsapp_store_pg/src/store/events.rs b/crates/whatsapp_store_pg/src/store/events.rs new file mode 100644 index 00000000000..01664cd0b40 --- /dev/null +++ b/crates/whatsapp_store_pg/src/store/events.rs @@ -0,0 +1,552 @@ +//! Message secrets, privacy tokens, the NCT salt, and the buffers of incoming +//! and outgoing events. + +#[cfg(test)] +mod test; + +use std::collections::{BTreeMap, HashMap}; + +use async_trait::async_trait; +use chrono::{DateTime, TimeDelta, Utc}; +use serde::{Deserialize, Serialize}; +use whatsmeow::store::{ + BufferedEvent, DecryptionTransaction, EventBuffer, MessageSecretInsert, MsgSecretStore, + NctSaltStore, PrivacyToken, PrivacyTokenStore, StoreError, +}; +use whatsmeow::types::{Jid, MessageId}; + +use super::{PgWhatsAppStore, composite_key}; +use crate::cipher::StoreName; +use crate::error::WhatsAppStoreError; + +/// The NCT salt is one value per account. +const NCT_SALT_KEY: &str = ""; + +/// WhatsApp servers buffer events for 14 days, so older hashes can't recur. +const BUFFERED_EVENT_RETENTION: TimeDelta = TimeDelta::days(14); + +/// How long sent messages are kept for answering retry receipts. +const OUTGOING_EVENT_RETENTION: TimeDelta = TimeDelta::days(7); + +fn message_secret_key(chat: &Jid, sender: &Jid, id: &str) -> Result { + composite_key(&[&chat.to_string(), &sender.to_string(), id]) +} + +#[async_trait] +impl MsgSecretStore for PgWhatsAppStore { + async fn put_message_secrets(&self, inserts: &[MessageSecretInsert]) -> Result<(), StoreError> { + let mut secrets = BTreeMap::new(); + for insert in inserts { + // Skipped as whatsmeow skips them. + if insert.chat.is_empty() + || insert.sender.is_empty() + || insert.id.is_empty() + || insert.secret.is_empty() + { + continue; + } + let key = message_secret_key( + &insert.chat.to_non_ad(), + &insert.sender.to_non_ad(), + &insert.id, + )?; + // The first secret of a message wins, as with `ON CONFLICT DO NOTHING`. + secrets.entry(key).or_insert_with(|| insert.secret.clone()); + } + if secrets.is_empty() { + return Ok(()); + } + let mut transaction = self.begin_write().await?; + self.insert_values_if_absent(&mut transaction, StoreName::MessageSecret, secrets) + .await?; + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(()) + } + + async fn put_message_secret( + &self, + chat: &Jid, + sender: &Jid, + id: &MessageId, + secret: Vec, + ) -> Result<(), StoreError> { + let key = message_secret_key(&chat.to_non_ad(), &sender.to_non_ad(), id)?; + let mut transaction = self.begin_write().await?; + self.insert_values_if_absent( + &mut transaction, + StoreName::MessageSecret, + BTreeMap::from([(key, secret)]), + ) + .await?; + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(()) + } + + /// Like whatsmeow, also finds a secret stored under the other identity + /// (LID or phone number) of the chat or the sender. + async fn get_message_secret( + &self, + chat: &Jid, + sender: &Jid, + id: &MessageId, + ) -> Result, Jid)>, StoreError> { + let chat = chat.to_non_ad(); + let sender = sender.to_non_ad(); + let chats = self.with_alternate(chat).await?; + let senders = self.with_alternate(sender).await?; + let mut candidates = Vec::with_capacity(chats.len() * senders.len()); + for chat in &chats { + for sender in &senders { + candidates.push((message_secret_key(chat, sender, id)?, sender.clone())); + } + } + let keys: Vec = candidates.iter().map(|(key, _)| key.clone()).collect(); + let mut found = self + .get_values(&self.pool, StoreName::MessageSecret, &keys) + .await?; + Ok(candidates + .into_iter() + .find_map(|(key, sender)| Some((found.remove(&key)?, sender)))) + } +} + +/// A privacy token as sealed in its value; the timestamp is the row's +/// plaintext `token_timestamp`, which expiry deletes by. +#[derive(Serialize, Deserialize)] +struct PrivacyTokenRecord { + token: Vec, + sender_timestamp: Option>, +} + +impl PgWhatsAppStore { + fn open_privacy_token( + &self, + user_jid: &str, + nonce: &[u8], + value: &[u8], + ) -> Result { + let plaintext = self.cipher.open( + StoreName::PrivacyToken, + &[user_jid.as_bytes()], + nonce, + value, + )?; + postcard::from_bytes(&plaintext).map_err(|_| WhatsAppStoreError::Malformed) + } +} + +#[async_trait] +impl PrivacyTokenStore for PgWhatsAppStore { + /// As whatsmeow: an older token never replaces a newer one, and a token + /// without a sender timestamp keeps the stored one. + async fn put_privacy_tokens(&self, tokens: &[PrivacyToken]) -> Result<(), StoreError> { + let mut newest: BTreeMap = BTreeMap::new(); + for token in tokens { + let user = token.user.to_non_ad().to_string(); + match newest.get(&user) { + Some(kept) if kept.timestamp > token.timestamp => {} + _ => { + newest.insert(user, token); + } + } + } + if newest.is_empty() { + return Ok(()); + } + let mut transaction = self.begin_write().await?; + self.lock_device(&mut transaction).await?; + let users: Vec = newest.keys().cloned().collect(); + let existing = sqlx::query!( + r#" + SELECT user_jid, token_timestamp, nonce, value + FROM whatsapp_privacy_token + WHERE account_id = $1 AND user_jid = ANY($2) + "#, + self.account_id, + &users, + ) + .fetch_all(&mut *transaction) + .await + .map_err(WhatsAppStoreError::from)?; + let existing: HashMap, Option>)> = existing + .into_iter() + .map(|row| { + let record = self.open_privacy_token(&row.user_jid, &row.nonce, &row.value)?; + Ok((row.user_jid, (row.token_timestamp, record.sender_timestamp))) + }) + .collect::>()?; + + let mut user_jids = Vec::with_capacity(newest.len()); + let mut timestamps = Vec::with_capacity(newest.len()); + let mut nonces = Vec::with_capacity(newest.len()); + let mut values = Vec::with_capacity(newest.len()); + for (user, token) in newest { + let sender_timestamp = match existing.get(&user) { + Some((stored, _)) if token.timestamp < *stored => continue, + Some((_, stored_sender)) => token.sender_timestamp.or(*stored_sender), + None => token.sender_timestamp, + }; + let plaintext = postcard::to_allocvec(&PrivacyTokenRecord { + token: token.token.clone(), + sender_timestamp, + }) + .map_err(|_| WhatsAppStoreError::Malformed)?; + let sealed = + self.cipher + .seal(StoreName::PrivacyToken, &[user.as_bytes()], &plaintext)?; + user_jids.push(user); + timestamps.push(token.timestamp); + nonces.push(sealed.nonce); + values.push(sealed.value); + } + sqlx::query!( + r#" + INSERT INTO whatsapp_privacy_token (account_id, user_jid, token_timestamp, nonce, value) + SELECT $1, entry.user_jid, entry.token_timestamp, entry.nonce, entry.value + FROM UNNEST($2::text[], $3::timestamptz[], $4::bytea[], $5::bytea[]) + AS entry(user_jid, token_timestamp, nonce, value) + ON CONFLICT (account_id, user_jid) DO UPDATE SET + token_timestamp = EXCLUDED.token_timestamp, + nonce = EXCLUDED.nonce, + value = EXCLUDED.value + "#, + self.account_id, + &user_jids, + ×tamps, + &nonces, + &values, + ) + .execute(&mut *transaction) + .await + .map_err(WhatsAppStoreError::from)?; + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(()) + } + + /// Like whatsmeow, also finds a token stored under the user's other + /// identity (LID or phone number), preferring the newest. + async fn get_privacy_token(&self, user: &Jid) -> Result, StoreError> { + let user = user.to_non_ad(); + let candidates: Vec = self + .with_alternate(user.clone()) + .await? + .iter() + .map(Jid::to_string) + .collect(); + let row = sqlx::query!( + r#" + SELECT user_jid, token_timestamp, nonce, value + FROM whatsapp_privacy_token + WHERE account_id = $1 AND user_jid = ANY($2) + ORDER BY token_timestamp DESC + LIMIT 1 + "#, + self.account_id, + &candidates, + ) + .fetch_optional(&self.pool) + .await + .map_err(WhatsAppStoreError::from)?; + let Some(row) = row else { + return Ok(None); + }; + let record = self.open_privacy_token(&row.user_jid, &row.nonce, &row.value)?; + Ok(Some(PrivacyToken { + user, + token: record.token, + timestamp: row.token_timestamp, + sender_timestamp: record.sender_timestamp, + })) + } + + async fn delete_expired_privacy_tokens( + &self, + cutoff: DateTime, + ) -> Result { + let mut transaction = self.begin_write().await?; + let deleted = sqlx::query!( + r#" + DELETE FROM whatsapp_privacy_token + WHERE account_id = $1 AND token_timestamp < $2 + "#, + self.account_id, + cutoff, + ) + .execute(&mut *transaction) + .await + .map_err(WhatsAppStoreError::from)? + .rows_affected(); + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(deleted) + } +} + +#[async_trait] +impl NctSaltStore for PgWhatsAppStore { + async fn put_nct_salt(&self, salt: Vec) -> Result<(), StoreError> { + Ok(self + .put_one(StoreName::NctSalt, NCT_SALT_KEY.to_owned(), salt) + .await?) + } + + async fn get_nct_salt(&self) -> Result>, StoreError> { + Ok(self + .get_value(&self.pool, StoreName::NctSalt, NCT_SALT_KEY) + .await?) + } + + async fn delete_nct_salt(&self) -> Result<(), StoreError> { + Ok(self + .delete_one(StoreName::NctSalt, NCT_SALT_KEY.to_owned()) + .await?) + } +} + +/// A sent event as sealed in its value. +#[derive(Serialize, Deserialize)] +struct OutgoingEventRecord { + format: String, + plaintext: Vec, +} + +#[async_trait] +impl EventBuffer for PgWhatsAppStore { + async fn get_buffered_event( + &self, + ciphertext_hash: [u8; 32], + ) -> Result, StoreError> { + let row = sqlx::query!( + r#" + SELECT server_time, inserted_at, nonce, value + FROM whatsapp_event_buffer + WHERE account_id = $1 AND ciphertext_hash = $2 + "#, + self.account_id, + ciphertext_hash.as_slice(), + ) + .fetch_optional(&self.pool) + .await + .map_err(WhatsAppStoreError::from)?; + let Some(row) = row else { + return Ok(None); + }; + let plaintext = match (row.nonce, row.value) { + (Some(nonce), Some(value)) => self.cipher.open( + StoreName::BufferedEvent, + &[&ciphertext_hash], + &nonce, + &value, + )?, + // Cleared once handled. + _ => Vec::new(), + }; + Ok(Some(BufferedEvent { + plaintext, + insert_time: row.inserted_at, + server_time: row.server_time, + })) + } + + async fn put_buffered_event( + &self, + ciphertext_hash: [u8; 32], + plaintext: Vec, + server_timestamp: DateTime, + ) -> Result<(), StoreError> { + let sealed = self + .cipher + .seal(StoreName::BufferedEvent, &[&ciphertext_hash], &plaintext)?; + let mut transaction = self.begin_write().await?; + sqlx::query!( + r#" + INSERT INTO whatsapp_event_buffer (account_id, ciphertext_hash, server_time, nonce, value) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (account_id, ciphertext_hash) DO UPDATE SET + server_time = EXCLUDED.server_time, + inserted_at = now(), + nonce = EXCLUDED.nonce, + value = EXCLUDED.value + "#, + self.account_id, + ciphertext_hash.as_slice(), + server_timestamp, + sealed.nonce, + sealed.value, + ) + .execute(&mut *transaction) + .await + .map_err(WhatsAppStoreError::from)?; + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(()) + } + + /// whatsmeow runs the body in a database transaction that its store + /// calls join through the context; the port has no context to carry one, + /// so the body's calls run in their own transactions. + async fn do_decryption_txn(&self, body: DecryptionTransaction<'_>) -> Result<(), StoreError> { + body.await + } + + async fn clear_buffered_event_plaintext( + &self, + ciphertext_hash: [u8; 32], + ) -> Result<(), StoreError> { + let mut transaction = self.begin_write().await?; + sqlx::query!( + r#" + UPDATE whatsapp_event_buffer + SET nonce = NULL, value = NULL + WHERE account_id = $1 AND ciphertext_hash = $2 + "#, + self.account_id, + ciphertext_hash.as_slice(), + ) + .execute(&mut *transaction) + .await + .map_err(WhatsAppStoreError::from)?; + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(()) + } + + async fn delete_old_buffered_hashes(&self) -> Result<(), StoreError> { + let mut transaction = self.begin_write().await?; + sqlx::query!( + r#" + DELETE FROM whatsapp_event_buffer + WHERE account_id = $1 AND inserted_at < $2 + "#, + self.account_id, + Utc::now() - BUFFERED_EVENT_RETENTION, + ) + .execute(&mut *transaction) + .await + .map_err(WhatsAppStoreError::from)?; + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(()) + } + + async fn get_outgoing_event( + &self, + chat_jid: &Jid, + alt_chat_jid: &Jid, + id: &MessageId, + ) -> Result)>, StoreError> { + let chat_jid = chat_jid.to_string(); + let candidates = [chat_jid.clone(), alt_chat_jid.to_string()]; + let rows = sqlx::query!( + r#" + SELECT chat_jid, nonce, value + FROM whatsapp_outgoing_event + WHERE account_id = $1 AND chat_jid = ANY($2) AND message_id = $3 + "#, + self.account_id, + &candidates, + id, + ) + .fetch_all(&self.pool) + .await + .map_err(WhatsAppStoreError::from)?; + // Prefer the event stored under the chat JID itself. + let Some(row) = rows + .iter() + .find(|row| row.chat_jid == chat_jid) + .or_else(|| rows.first()) + else { + return Ok(None); + }; + let plaintext = self.cipher.open( + StoreName::OutgoingEvent, + &[row.chat_jid.as_bytes(), id.as_bytes()], + &row.nonce, + &row.value, + )?; + let record: OutgoingEventRecord = + postcard::from_bytes(&plaintext).map_err(|_| WhatsAppStoreError::Malformed)?; + Ok(Some((record.format, record.plaintext))) + } + + async fn add_outgoing_event( + &self, + chat_jid: &Jid, + id: &MessageId, + format: &str, + plaintext: Vec, + ) -> Result<(), StoreError> { + let chat_jid = chat_jid.to_string(); + let record = postcard::to_allocvec(&OutgoingEventRecord { + format: format.to_owned(), + plaintext, + }) + .map_err(|_| WhatsAppStoreError::Malformed)?; + let sealed = self.cipher.seal( + StoreName::OutgoingEvent, + &[chat_jid.as_bytes(), id.as_bytes()], + &record, + )?; + let mut transaction = self.begin_write().await?; + sqlx::query!( + r#" + INSERT INTO whatsapp_outgoing_event (account_id, chat_jid, message_id, nonce, value) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (account_id, chat_jid, message_id) DO UPDATE SET + created_at = now(), + nonce = EXCLUDED.nonce, + value = EXCLUDED.value + "#, + self.account_id, + chat_jid, + id, + sealed.nonce, + sealed.value, + ) + .execute(&mut *transaction) + .await + .map_err(WhatsAppStoreError::from)?; + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(()) + } + + async fn delete_old_outgoing_events(&self) -> Result<(), StoreError> { + let mut transaction = self.begin_write().await?; + sqlx::query!( + r#" + DELETE FROM whatsapp_outgoing_event + WHERE account_id = $1 AND created_at < $2 + "#, + self.account_id, + Utc::now() - OUTGOING_EVENT_RETENTION, + ) + .execute(&mut *transaction) + .await + .map_err(WhatsAppStoreError::from)?; + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(()) + } +} diff --git a/crates/whatsapp_store_pg/src/store/events/test.rs b/crates/whatsapp_store_pg/src/store/events/test.rs new file mode 100644 index 00000000000..931fd490c37 --- /dev/null +++ b/crates/whatsapp_store_pg/src/store/events/test.rs @@ -0,0 +1,285 @@ +use chrono::{TimeZone, Utc}; +use macro_db_migrator::MACRO_DB_MIGRATIONS; +use sqlx::{Pool, Postgres}; +use whatsmeow::store::{ + EventBuffer, LidStore, MessageSecretInsert, MsgSecretStore, NctSaltStore, PrivacyToken, + PrivacyTokenStore, +}; +use whatsmeow::types::{ + DEFAULT_USER_SERVER, GROUP_SERVER, HIDDEN_USER_SERVER, new_ad_jid, new_jid, +}; + +use crate::store::test::open_new_store; + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn message_secrets_keep_the_first_and_are_found_by_either_identity(pool: Pool) { + let store = open_new_store(&pool).await; + let group = new_jid("123-456", GROUP_SERVER); + let sender_pn = new_jid("111", DEFAULT_USER_SERVER); + let sender_lid = new_jid("98765", HIDDEN_USER_SERVER); + let sender_device = new_ad_jid("111", 0, 3); + + store + .put_message_secrets(&[ + MessageSecretInsert { + chat: group.clone(), + sender: sender_device.clone(), + id: "A1".to_owned(), + secret: vec![1], + }, + MessageSecretInsert { + chat: group.clone(), + sender: sender_pn.clone(), + id: "A1".to_owned(), + secret: vec![2], + }, + // Skipped: no secret. + MessageSecretInsert { + chat: group.clone(), + sender: sender_pn.clone(), + id: "A2".to_owned(), + secret: vec![], + }, + ]) + .await + .unwrap(); + store + .put_message_secret(&group, &sender_pn, &"A1".to_owned(), vec![3]) + .await + .unwrap(); + + assert_eq!( + store + .get_message_secret(&group, &sender_device, &"A1".to_owned()) + .await + .unwrap(), + Some((vec![1], sender_pn.clone())) + ); + assert_eq!( + store + .get_message_secret(&group, &sender_pn, &"A2".to_owned()) + .await + .unwrap(), + None + ); + // Not found by the LID until the mapping is known. + assert_eq!( + store + .get_message_secret(&group, &sender_lid, &"A1".to_owned()) + .await + .unwrap(), + None + ); + store + .put_lid_mapping(&sender_lid, &sender_pn) + .await + .unwrap(); + assert_eq!( + store + .get_message_secret(&group, &sender_lid, &"A1".to_owned()) + .await + .unwrap(), + Some((vec![1], sender_pn)) + ); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn privacy_tokens_only_move_forward_in_time(pool: Pool) { + let store = open_new_store(&pool).await; + let alice = new_jid("111", DEFAULT_USER_SERVER); + let alice_lid = new_jid("98765", HIDDEN_USER_SERVER); + let bob = new_jid("222", DEFAULT_USER_SERVER); + let at = |seconds: i64| Utc.timestamp_opt(1_700_000_000 + seconds, 0).unwrap(); + assert_eq!(store.get_privacy_token(&alice).await.unwrap(), None); + + store + .put_privacy_tokens(&[ + PrivacyToken { + user: new_ad_jid("111", 0, 2), + token: vec![1], + timestamp: at(10), + sender_timestamp: Some(at(5)), + }, + PrivacyToken { + user: bob.clone(), + token: vec![2], + timestamp: at(10), + sender_timestamp: None, + }, + ]) + .await + .unwrap(); + // Older: ignored. + store + .put_privacy_tokens(&[PrivacyToken { + user: alice.clone(), + token: vec![9], + timestamp: at(1), + sender_timestamp: None, + }]) + .await + .unwrap(); + assert_eq!( + store.get_privacy_token(&alice).await.unwrap(), + Some(PrivacyToken { + user: alice.clone(), + token: vec![1], + timestamp: at(10), + sender_timestamp: Some(at(5)), + }) + ); + // Newer without a sender timestamp: keeps the stored sender timestamp. + store + .put_privacy_tokens(&[PrivacyToken { + user: alice.clone(), + token: vec![3], + timestamp: at(20), + sender_timestamp: None, + }]) + .await + .unwrap(); + let expected = PrivacyToken { + user: alice.clone(), + token: vec![3], + timestamp: at(20), + sender_timestamp: Some(at(5)), + }; + assert_eq!( + store.get_privacy_token(&alice).await.unwrap(), + Some(expected.clone()) + ); + + // Found by the LID once mapped, reported under the asked-for user. + store.put_lid_mapping(&alice_lid, &alice).await.unwrap(); + assert_eq!( + store.get_privacy_token(&alice_lid).await.unwrap(), + Some(PrivacyToken { + user: alice_lid, + ..expected + }) + ); + + assert_eq!( + store.delete_expired_privacy_tokens(at(15)).await.unwrap(), + 1 + ); + assert_eq!(store.get_privacy_token(&bob).await.unwrap(), None); + assert!(store.get_privacy_token(&alice).await.unwrap().is_some()); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn the_nct_salt_round_trips(pool: Pool) { + let store = open_new_store(&pool).await; + assert_eq!(store.get_nct_salt().await.unwrap(), None); + store.put_nct_salt(vec![1, 2, 3]).await.unwrap(); + store.put_nct_salt(vec![4, 5]).await.unwrap(); + assert_eq!(store.get_nct_salt().await.unwrap(), Some(vec![4, 5])); + store.delete_nct_salt().await.unwrap(); + assert_eq!(store.get_nct_salt().await.unwrap(), None); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn buffered_events_are_cleared_and_expire(pool: Pool) { + let store = open_new_store(&pool).await; + let server_time = Utc.timestamp_opt(1_700_000_000, 0).unwrap(); + assert_eq!(store.get_buffered_event([1; 32]).await.unwrap(), None); + + store + .put_buffered_event([1; 32], b"decrypted".to_vec(), server_time) + .await + .unwrap(); + store + .put_buffered_event([2; 32], b"other".to_vec(), server_time) + .await + .unwrap(); + let buffered = store.get_buffered_event([1; 32]).await.unwrap().unwrap(); + assert_eq!(buffered.plaintext, b"decrypted".to_vec()); + assert_eq!(buffered.server_time, server_time); + + store.clear_buffered_event_plaintext([1; 32]).await.unwrap(); + let cleared = store.get_buffered_event([1; 32]).await.unwrap().unwrap(); + assert_eq!(cleared.plaintext, Vec::::new()); + assert_eq!(cleared.insert_time, buffered.insert_time); + + let ran = std::sync::atomic::AtomicBool::new(false); + store + .do_decryption_txn(Box::pin(async { + ran.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + })) + .await + .unwrap(); + assert!(ran.into_inner()); + + sqlx::query!( + r#" + UPDATE whatsapp_event_buffer + SET inserted_at = now() - interval '15 days' + WHERE account_id = $1 AND ciphertext_hash = $2 + "#, + store.account_id(), + [2u8; 32].as_slice(), + ) + .execute(&pool) + .await + .unwrap(); + store.delete_old_buffered_hashes().await.unwrap(); + assert_eq!(store.get_buffered_event([2; 32]).await.unwrap(), None); + assert!(store.get_buffered_event([1; 32]).await.unwrap().is_some()); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn outgoing_events_are_found_by_either_chat_and_expire(pool: Pool) { + let store = open_new_store(&pool).await; + let chat = new_jid("111", DEFAULT_USER_SERVER); + let alt_chat = new_jid("98765", HIDDEN_USER_SERVER); + let other = new_jid("222", DEFAULT_USER_SERVER); + let id = "3EB0AA".to_owned(); + + store + .add_outgoing_event(&alt_chat, &id, "e2e", b"sent".to_vec()) + .await + .unwrap(); + assert_eq!( + store + .get_outgoing_event(&chat, &alt_chat, &id) + .await + .unwrap(), + Some(("e2e".to_owned(), b"sent".to_vec())) + ); + store + .add_outgoing_event(&chat, &id, "fb", b"sent to pn".to_vec()) + .await + .unwrap(); + assert_eq!( + store + .get_outgoing_event(&chat, &alt_chat, &id) + .await + .unwrap(), + Some(("fb".to_owned(), b"sent to pn".to_vec())) + ); + assert_eq!( + store.get_outgoing_event(&other, &other, &id).await.unwrap(), + None + ); + + sqlx::query!( + r#" + UPDATE whatsapp_outgoing_event + SET created_at = now() - interval '8 days' + WHERE account_id = $1 AND chat_jid = '111@s.whatsapp.net' + "#, + store.account_id(), + ) + .execute(&pool) + .await + .unwrap(); + store.delete_old_outgoing_events().await.unwrap(); + assert_eq!( + store + .get_outgoing_event(&chat, &alt_chat, &id) + .await + .unwrap(), + Some(("e2e".to_owned(), b"sent".to_vec())) + ); +} diff --git a/crates/whatsapp_store_pg/src/store/lid.rs b/crates/whatsapp_store_pg/src/store/lid.rs new file mode 100644 index 00000000000..88755b43ac3 --- /dev/null +++ b/crates/whatsapp_store_pg/src/store/lid.rs @@ -0,0 +1,225 @@ +//! LID ↔ phone number mappings. +//! +//! whatsmeow keeps one mapping table shared by every device; here each +//! account has its own, since accounts belong to different people. Which LID +//! belongs to which phone number is personal data (it de-pseudonymizes a +//! LID), so each direction is a sealed value: `lid_by_pn` keyed by the phone +//! number user, `pn_by_lid` keyed by the LID user. The users themselves are +//! plaintext keys, as JIDs are everywhere else in the store. + +#[cfg(test)] +mod test; + +use std::collections::{BTreeMap, HashMap}; + +use async_trait::async_trait; +use sqlx::PgConnection; +use whatsmeow::store::{LidMapping, LidStore, StoreError}; +use whatsmeow::types::{DEFAULT_USER_SERVER, HIDDEN_USER_SERVER, Jid}; + +use super::PgWhatsAppStore; +use crate::cipher::StoreName; +use crate::error::WhatsAppStoreError; + +fn user_jid(user: Vec, device: u16, server: &str) -> Result { + Ok(Jid { + user: String::from_utf8(user).map_err(|_| WhatsAppStoreError::Malformed)?, + device, + server: server.to_owned(), + ..Jid::default() + }) +} + +fn is_valid_mapping(lid: &Jid, pn: &Jid) -> bool { + lid.server == HIDDEN_USER_SERVER && pn.server == DEFAULT_USER_SERVER +} + +impl PgWhatsAppStore { + /// The JID followed by its other identity (LID or phone number) when one + /// is known, for lookups that accept either. + pub(super) async fn with_alternate(&self, jid: Jid) -> Result, WhatsAppStoreError> { + let alternate = match jid.server.as_str() { + DEFAULT_USER_SERVER => { + self.lookup(&jid, StoreName::LidByPn, HIDDEN_USER_SERVER) + .await? + } + HIDDEN_USER_SERVER => { + self.lookup(&jid, StoreName::PnByLid, DEFAULT_USER_SERVER) + .await? + } + _ => None, + }; + Ok(std::iter::once(jid).chain(alternate).collect()) + } + + async fn lookup( + &self, + source: &Jid, + store: StoreName, + target_server: &str, + ) -> Result, WhatsAppStoreError> { + self.get_value(&self.pool, store, &source.user) + .await? + .map(|user| user_jid(user, source.device, target_server)) + .transpose() + } + + /// Applies mappings in order, each replacing any older mapping of either + /// side, as whatsmeow's `PutLIDMapping` does. Reads both directions of + /// every user in one query each, then writes and deletes in one each. + async fn put_mappings( + &self, + connection: &mut PgConnection, + mappings: &[&LidMapping], + ) -> Result<(), WhatsAppStoreError> { + let pns: Vec = mappings + .iter() + .map(|mapping| mapping.pn.user.clone()) + .collect(); + let lids: Vec = mappings + .iter() + .map(|mapping| mapping.lid.user.clone()) + .collect(); + // `None` marks a mapping deleted in this batch. + let mut lid_by_pn: HashMap> = HashMap::new(); + let mut pn_by_lid: HashMap> = HashMap::new(); + for (pn, lid) in self + .get_values(&mut *connection, StoreName::LidByPn, &pns) + .await? + { + lid_by_pn.insert( + pn, + Some(String::from_utf8(lid).map_err(|_| WhatsAppStoreError::Malformed)?), + ); + } + for (lid, pn) in self + .get_values(&mut *connection, StoreName::PnByLid, &lids) + .await? + { + pn_by_lid.insert( + lid, + Some(String::from_utf8(pn).map_err(|_| WhatsAppStoreError::Malformed)?), + ); + } + + for mapping in mappings { + let (lid, pn) = (&mapping.lid.user, &mapping.pn.user); + let old_lid = lid_by_pn.insert(pn.clone(), Some(lid.clone())).flatten(); + let old_pn = pn_by_lid.insert(lid.clone(), Some(pn.clone())).flatten(); + if let Some(old_lid) = old_lid + && old_lid != *lid + { + pn_by_lid.insert(old_lid, None); + } + if let Some(old_pn) = old_pn + && old_pn != *pn + { + lid_by_pn.insert(old_pn, None); + } + } + + for (store, entries) in [ + (StoreName::LidByPn, lid_by_pn), + (StoreName::PnByLid, pn_by_lid), + ] { + let mut puts = BTreeMap::new(); + let mut deletes = Vec::new(); + for (key, value) in entries { + match value { + Some(value) => { + puts.insert(key, value.into_bytes()); + } + None => deletes.push(key), + } + } + self.delete_values(connection, store, &deletes).await?; + self.put_values(connection, store, puts).await?; + } + Ok(()) + } +} + +#[async_trait] +impl LidStore for PgWhatsAppStore { + async fn put_many_lid_mappings(&self, mappings: &[LidMapping]) -> Result<(), StoreError> { + // Invalid entries are skipped, as whatsmeow skips them. + let valid: Vec<&LidMapping> = mappings + .iter() + .filter(|mapping| is_valid_mapping(&mapping.lid, &mapping.pn)) + .collect(); + if valid.is_empty() { + return Ok(()); + } + let mut transaction = self.begin_write().await?; + self.lock_device(&mut transaction).await?; + self.put_mappings(&mut transaction, &valid).await?; + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(()) + } + + async fn put_lid_mapping(&self, lid: &Jid, jid: &Jid) -> Result<(), StoreError> { + if !is_valid_mapping(lid, jid) { + return Err(StoreError::InvalidArgument( + "invalid PutLIDMapping call: expected a LID and a phone number JID".to_owned(), + )); + } + let mapping = LidMapping { + lid: lid.clone(), + pn: jid.clone(), + }; + let mut transaction = self.begin_write().await?; + self.lock_device(&mut transaction).await?; + self.put_mappings(&mut transaction, &[&mapping]).await?; + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(()) + } + + async fn get_pn_for_lid(&self, lid: &Jid) -> Result, StoreError> { + if lid.server != HIDDEN_USER_SERVER { + return Err(StoreError::InvalidArgument( + "invalid GetPNForLID call with non-LID JID".to_owned(), + )); + } + Ok(self + .lookup(lid, StoreName::PnByLid, DEFAULT_USER_SERVER) + .await?) + } + + async fn get_lid_for_pn(&self, pn: &Jid) -> Result, StoreError> { + if pn.server != DEFAULT_USER_SERVER { + return Err(StoreError::InvalidArgument( + "invalid GetLIDForPN call with non-PN JID".to_owned(), + )); + } + Ok(self + .lookup(pn, StoreName::LidByPn, HIDDEN_USER_SERVER) + .await?) + } + + async fn get_many_lids_for_pns(&self, pns: &[Jid]) -> Result, StoreError> { + let pns: Vec<&Jid> = pns + .iter() + .filter(|pn| pn.server == DEFAULT_USER_SERVER) + .collect(); + let users: Vec = pns.iter().map(|pn| pn.user.clone()).collect(); + let lids = self + .get_values(&self.pool, StoreName::LidByPn, &users) + .await?; + Ok(pns + .into_iter() + .filter_map(|pn| { + let lid = lids.get(&pn.user)?; + Some( + user_jid(lid.clone(), pn.device, HIDDEN_USER_SERVER) + .map(|lid| (pn.clone(), lid)), + ) + }) + .collect::>()?) + } +} diff --git a/crates/whatsapp_store_pg/src/store/lid/test.rs b/crates/whatsapp_store_pg/src/store/lid/test.rs new file mode 100644 index 00000000000..d7e16b967bb --- /dev/null +++ b/crates/whatsapp_store_pg/src/store/lid/test.rs @@ -0,0 +1,130 @@ +use std::collections::HashMap; + +use macro_db_migrator::MACRO_DB_MIGRATIONS; +use sqlx::{Pool, Postgres}; +use whatsmeow::store::{LidMapping, LidStore, StoreError}; +use whatsmeow::types::{ + DEFAULT_USER_SERVER, GROUP_SERVER, HIDDEN_USER_SERVER, new_ad_jid, new_jid, +}; + +use crate::store::test::open_new_store; + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn mappings_resolve_both_ways_keeping_the_device(pool: Pool) { + let store = open_new_store(&pool).await; + let pn = new_jid("111", DEFAULT_USER_SERVER); + let lid = new_jid("98765", HIDDEN_USER_SERVER); + assert_eq!(store.get_lid_for_pn(&pn).await.unwrap(), None); + + store.put_lid_mapping(&lid, &pn).await.unwrap(); + assert_eq!(store.get_lid_for_pn(&pn).await.unwrap(), Some(lid.clone())); + assert_eq!(store.get_pn_for_lid(&lid).await.unwrap(), Some(pn.clone())); + + let pn_device = new_ad_jid("111", 0, 4); + let mut lid_device = lid.clone(); + lid_device.device = 4; + assert_eq!( + store.get_lid_for_pn(&pn_device).await.unwrap(), + Some(lid_device.clone()) + ); + assert_eq!( + store.get_pn_for_lid(&lid_device).await.unwrap(), + Some(pn_device) + ); + + assert!(matches!( + store.get_lid_for_pn(&lid).await.unwrap_err(), + StoreError::InvalidArgument(_) + )); + assert!(matches!( + store.get_pn_for_lid(&pn).await.unwrap_err(), + StoreError::InvalidArgument(_) + )); + assert!(matches!( + store.put_lid_mapping(&pn, &lid).await.unwrap_err(), + StoreError::InvalidArgument(_) + )); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn a_new_mapping_replaces_older_ones_of_either_side(pool: Pool) { + let store = open_new_store(&pool).await; + let pn = new_jid("111", DEFAULT_USER_SERVER); + let other_pn = new_jid("222", DEFAULT_USER_SERVER); + let lid = new_jid("98765", HIDDEN_USER_SERVER); + let new_lid = new_jid("55555", HIDDEN_USER_SERVER); + + store.put_lid_mapping(&lid, &pn).await.unwrap(); + // The phone number got a new LID: the old LID maps to nothing. + store.put_lid_mapping(&new_lid, &pn).await.unwrap(); + assert_eq!( + store.get_lid_for_pn(&pn).await.unwrap(), + Some(new_lid.clone()) + ); + assert_eq!(store.get_pn_for_lid(&lid).await.unwrap(), None); + // The LID moved to another phone number: the old number maps to nothing. + store.put_lid_mapping(&new_lid, &other_pn).await.unwrap(); + assert_eq!( + store.get_pn_for_lid(&new_lid).await.unwrap(), + Some(other_pn.clone()) + ); + assert_eq!(store.get_lid_for_pn(&pn).await.unwrap(), None); + assert_eq!( + store.get_lid_for_pn(&other_pn).await.unwrap(), + Some(new_lid) + ); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn batches_skip_invalid_mappings_and_apply_in_order(pool: Pool) { + let store = open_new_store(&pool).await; + let pns: Vec<_> = (0..300) + .map(|index| new_jid(&format!("1555{index:04}"), DEFAULT_USER_SERVER)) + .collect(); + let lids: Vec<_> = (0..300) + .map(|index| new_jid(&format!("9000{index:04}"), HIDDEN_USER_SERVER)) + .collect(); + let mut mappings: Vec = pns + .iter() + .zip(&lids) + .map(|(pn, lid)| LidMapping { + lid: lid.clone(), + pn: pn.clone(), + }) + .collect(); + mappings.push(LidMapping { + lid: new_jid("123-456", GROUP_SERVER), + pn: pns[0].clone(), + }); + // A later mapping in the same batch moves the first phone number. + let moved_lid = new_jid("77777", HIDDEN_USER_SERVER); + mappings.push(LidMapping { + lid: moved_lid.clone(), + pn: pns[0].clone(), + }); + store.put_many_lid_mappings(&mappings).await.unwrap(); + store.put_many_lid_mappings(&[]).await.unwrap(); + + assert_eq!( + store.get_lid_for_pn(&pns[0]).await.unwrap(), + Some(moved_lid) + ); + assert_eq!(store.get_pn_for_lid(&lids[0]).await.unwrap(), None); + assert_eq!( + store.get_pn_for_lid(&lids[299]).await.unwrap(), + Some(pns[299].clone()) + ); + + let unknown = new_jid("1999", DEFAULT_USER_SERVER); + let device = new_ad_jid("15550007", 0, 2); + let mut device_lid = lids[7].clone(); + device_lid.device = 2; + let many = store + .get_many_lids_for_pns(&[pns[1].clone(), unknown, device.clone(), lids[2].clone()]) + .await + .unwrap(); + assert_eq!( + many, + HashMap::from([(pns[1].clone(), lids[1].clone()), (device, device_lid)]) + ); +} diff --git a/crates/whatsapp_store_pg/src/store/signal.rs b/crates/whatsapp_store_pg/src/store/signal.rs new file mode 100644 index 00000000000..f2dcba8c4d0 --- /dev/null +++ b/crates/whatsapp_store_pg/src/store/signal.rs @@ -0,0 +1,456 @@ +//! Identities, sessions, prekeys and sender keys: what libsignal reads and +//! writes, following whatsmeow's `store/sqlstore`. + +#[cfg(test)] +mod test; + +use std::collections::{BTreeMap, HashMap}; + +use async_trait::async_trait; +use whatsmeow::store::{IdentityStore, PreKeyStore, SenderKeyStore, SessionStore, StoreError}; +use whatsmeow::types::Jid; +use whatsmeow::util::keys::{PreKey, new_key_pair_from_private_key, new_pre_key}; + +use super::{PgWhatsAppStore, composite_key}; +use crate::cipher::StoreName; +use crate::error::WhatsAppStoreError; + +/// The prefix of every Signal address of a user: `user:device`. +fn address_prefix(user: &str) -> String { + format!("{user}:") +} + +#[async_trait] +impl IdentityStore for PgWhatsAppStore { + async fn put_identity(&self, address: &str, key: [u8; 32]) -> Result<(), StoreError> { + Ok(self + .put_one(StoreName::Identity, address.to_owned(), key.to_vec()) + .await?) + } + + async fn delete_all_identities(&self, phone: &str) -> Result<(), StoreError> { + let mut transaction = self.begin_write().await?; + self.delete_prefix( + &mut transaction, + StoreName::Identity, + &address_prefix(phone), + ) + .await?; + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(()) + } + + async fn delete_identity(&self, address: &str) -> Result<(), StoreError> { + Ok(self + .delete_one(StoreName::Identity, address.to_owned()) + .await?) + } + + async fn is_trusted_identity(&self, address: &str, key: [u8; 32]) -> Result { + let Some(existing) = self + .get_value(&self.pool, StoreName::Identity, address) + .await? + else { + // Trust if not known; it is saved automatically later. + return Ok(true); + }; + let existing: [u8; 32] = existing + .try_into() + .map_err(|_| WhatsAppStoreError::Malformed)?; + Ok(existing == key) + } +} + +#[async_trait] +impl SessionStore for PgWhatsAppStore { + async fn get_session(&self, address: &str) -> Result>, StoreError> { + Ok(self + .get_value(&self.pool, StoreName::Session, address) + .await?) + } + + async fn has_session(&self, address: &str) -> Result { + let exists = sqlx::query_scalar!( + r#" + SELECT EXISTS ( + SELECT 1 + FROM whatsapp_store_value + WHERE account_id = $1 AND store = $2 AND key = $3 + ) AS "exists!" + "#, + self.account_id, + StoreName::Session.as_str(), + address, + ) + .fetch_one(&self.pool) + .await + .map_err(WhatsAppStoreError::from)?; + Ok(exists) + } + + async fn get_many_sessions( + &self, + addresses: &[String], + ) -> Result>>, StoreError> { + let mut found = self + .get_values(&self.pool, StoreName::Session, addresses) + .await?; + Ok(addresses + .iter() + .map(|address| (address.clone(), found.remove(address))) + .collect()) + } + + async fn put_session(&self, address: &str, session: Vec) -> Result<(), StoreError> { + Ok(self + .put_one(StoreName::Session, address.to_owned(), session) + .await?) + } + + async fn put_many_sessions( + &self, + sessions: HashMap>, + ) -> Result<(), StoreError> { + let mut transaction = self.begin_write().await?; + self.put_values( + &mut transaction, + StoreName::Session, + sessions.into_iter().collect(), + ) + .await?; + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(()) + } + + async fn delete_all_sessions(&self, phone: &str) -> Result<(), StoreError> { + let mut transaction = self.begin_write().await?; + self.delete_prefix(&mut transaction, StoreName::Session, &address_prefix(phone)) + .await?; + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(()) + } + + async fn delete_session(&self, address: &str) -> Result<(), StoreError> { + Ok(self + .delete_one(StoreName::Session, address.to_owned()) + .await?) + } + + #[tracing::instrument( + name = "whatsapp_store.migrate_pn_to_lid", + skip_all, + fields(account_id = %self.account_id), + err + )] + async fn migrate_pn_to_lid(&self, pn: &Jid, lid: &Jid) -> Result<(), StoreError> { + let pn_user = pn.signal_address_user(); + if !self.migrated_users().insert(pn_user.clone()) { + return Ok(()); + } + let migrated = self + .move_signal_user( + &address_prefix(&pn_user), + &address_prefix(&lid.signal_address_user()), + ) + .await; + if migrated.is_err() { + // Not migrated after all: let the next call try again. + self.migrated_users().remove(&pn_user); + } + Ok(migrated?) + } +} + +impl PgWhatsAppStore { + fn migrated_users(&self) -> std::sync::MutexGuard<'_, std::collections::HashSet> { + // Every use is one set operation, so a panic can't leave it half-written. + self.migrated_pn_sessions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Moves the sessions, identities and sender keys of one Signal user to + /// another, re-encrypting each under its new key, replacing what the new + /// user already has. Sender keys are keyed sender first, so the same + /// prefix finds them. + async fn move_signal_user(&self, from: &str, to: &str) -> Result<(), WhatsAppStoreError> { + let mut transaction = self.begin_write().await?; + for store in [ + StoreName::Session, + StoreName::Identity, + StoreName::SenderKey, + ] { + let moved: BTreeMap> = self + .list_values(&mut *transaction, store, from) + .await? + .into_iter() + .filter_map(|(key, value)| { + let rest = key.strip_prefix(from)?; + Some((format!("{to}{rest}"), value)) + }) + .collect(); + if moved.is_empty() { + continue; + } + tracing::debug!( + store = store.as_str(), + count = moved.len(), + "moving signal user" + ); + self.put_values(&mut transaction, store, moved).await?; + self.delete_prefix(&mut transaction, store, from).await?; + } + transaction.commit().await?; + Ok(()) + } + + /// The next prekey ID: one past the highest ever stored, as whatsmeow + /// numbers them (from 1). + async fn next_pre_key_id( + &self, + connection: &mut sqlx::PgConnection, + ) -> Result { + let last = sqlx::query_scalar!( + r#" + SELECT MAX(key_id) + FROM whatsapp_pre_key + WHERE account_id = $1 + "#, + self.account_id, + ) + .fetch_one(connection) + .await? + .unwrap_or(0); + u32::try_from(last + 1) + .map_err(|_| WhatsAppStoreError::InvalidArgument("prekey IDs exhausted".to_owned())) + } + + /// Stores new prekeys in one statement. + async fn insert_pre_keys( + &self, + connection: &mut sqlx::PgConnection, + keys: &[PreKey], + uploaded: bool, + ) -> Result<(), WhatsAppStoreError> { + let mut key_ids = Vec::with_capacity(keys.len()); + let mut nonces = Vec::with_capacity(keys.len()); + let mut values = Vec::with_capacity(keys.len()); + for key in keys { + let sealed = self.cipher.seal( + StoreName::PreKey, + &[&key.key_id.to_be_bytes()], + &key.key_pair.private, + )?; + key_ids.push(i64::from(key.key_id)); + nonces.push(sealed.nonce); + values.push(sealed.value); + } + sqlx::query!( + r#" + INSERT INTO whatsapp_pre_key (account_id, key_id, uploaded, nonce, value) + SELECT $1, entry.key_id, $2, entry.nonce, entry.value + FROM UNNEST($3::bigint[], $4::bytea[], $5::bytea[]) AS entry(key_id, nonce, value) + "#, + self.account_id, + uploaded, + &key_ids, + &nonces, + &values, + ) + .execute(connection) + .await?; + Ok(()) + } + + fn open_pre_key( + &self, + key_id: i64, + nonce: &[u8], + value: &[u8], + ) -> Result { + let key_id = u32::try_from(key_id).map_err(|_| WhatsAppStoreError::Malformed)?; + let private: [u8; 32] = self + .cipher + .open(StoreName::PreKey, &[&key_id.to_be_bytes()], nonce, value)? + .try_into() + .map_err(|_| WhatsAppStoreError::Malformed)?; + Ok(PreKey { + key_pair: new_key_pair_from_private_key(private), + key_id, + signature: None, + }) + } +} + +#[async_trait] +impl PreKeyStore for PgWhatsAppStore { + #[tracing::instrument( + name = "whatsapp_store.get_or_gen_pre_keys", + skip(self), + fields(account_id = %self.account_id, generated), + err + )] + async fn get_or_gen_pre_keys(&self, count: u32) -> Result, StoreError> { + let mut transaction = self.begin_write().await?; + // Serializes prekey numbering, as whatsmeow's prekey lock does. + self.lock_device(&mut transaction).await?; + let rows = sqlx::query!( + r#" + SELECT key_id, nonce, value + FROM whatsapp_pre_key + WHERE account_id = $1 AND NOT uploaded + ORDER BY key_id + LIMIT $2 + "#, + self.account_id, + i64::from(count), + ) + .fetch_all(&mut *transaction) + .await + .map_err(WhatsAppStoreError::from)?; + let mut keys = rows + .iter() + .map(|row| self.open_pre_key(row.key_id, &row.nonce, &row.value)) + .collect::, _>>()?; + let missing = count.saturating_sub(u32::try_from(keys.len()).unwrap_or(u32::MAX)); + tracing::Span::current().record("generated", missing); + if missing > 0 { + let first_id = self.next_pre_key_id(&mut transaction).await?; + let new_keys = (0..missing) + .map(|offset| { + first_id + .checked_add(offset) + .map(new_pre_key) + .ok_or_else(|| { + WhatsAppStoreError::InvalidArgument("prekey IDs exhausted".to_owned()) + }) + }) + .collect::, _>>()?; + self.insert_pre_keys(&mut transaction, &new_keys, false) + .await?; + keys.extend(new_keys); + } + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(keys) + } + + async fn gen_one_pre_key(&self) -> Result { + let mut transaction = self.begin_write().await?; + self.lock_device(&mut transaction).await?; + let key = new_pre_key(self.next_pre_key_id(&mut transaction).await?); + self.insert_pre_keys(&mut transaction, std::slice::from_ref(&key), true) + .await?; + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(key) + } + + async fn get_pre_key(&self, id: u32) -> Result, StoreError> { + let row = sqlx::query!( + r#" + SELECT key_id, nonce, value + FROM whatsapp_pre_key + WHERE account_id = $1 AND key_id = $2 + "#, + self.account_id, + i64::from(id), + ) + .fetch_optional(&self.pool) + .await + .map_err(WhatsAppStoreError::from)?; + Ok(row + .map(|row| self.open_pre_key(row.key_id, &row.nonce, &row.value)) + .transpose()?) + } + + async fn remove_pre_key(&self, id: u32) -> Result<(), StoreError> { + let mut transaction = self.begin_write().await?; + sqlx::query!( + r#" + DELETE FROM whatsapp_pre_key + WHERE account_id = $1 AND key_id = $2 + "#, + self.account_id, + i64::from(id), + ) + .execute(&mut *transaction) + .await + .map_err(WhatsAppStoreError::from)?; + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(()) + } + + async fn mark_pre_keys_as_uploaded(&self, up_to_id: u32) -> Result<(), StoreError> { + let mut transaction = self.begin_write().await?; + sqlx::query!( + r#" + UPDATE whatsapp_pre_key + SET uploaded = true + WHERE account_id = $1 AND key_id <= $2 AND NOT uploaded + "#, + self.account_id, + i64::from(up_to_id), + ) + .execute(&mut *transaction) + .await + .map_err(WhatsAppStoreError::from)?; + transaction + .commit() + .await + .map_err(WhatsAppStoreError::from)?; + Ok(()) + } + + async fn uploaded_pre_key_count(&self) -> Result { + let count = sqlx::query_scalar!( + r#" + SELECT COUNT(*) AS "count!" + FROM whatsapp_pre_key + WHERE account_id = $1 AND uploaded + "#, + self.account_id, + ) + .fetch_one(&self.pool) + .await + .map_err(WhatsAppStoreError::from)?; + Ok(usize::try_from(count).map_err(|_| WhatsAppStoreError::Malformed)?) + } +} + +#[async_trait] +impl SenderKeyStore for PgWhatsAppStore { + async fn put_sender_key( + &self, + group: &str, + user: &str, + session: Vec, + ) -> Result<(), StoreError> { + // Sender first, so a user's sender keys share a key prefix. + let key = composite_key(&[user, group])?; + Ok(self.put_one(StoreName::SenderKey, key, session).await?) + } + + async fn get_sender_key(&self, group: &str, user: &str) -> Result>, StoreError> { + let key = composite_key(&[user, group])?; + Ok(self + .get_value(&self.pool, StoreName::SenderKey, &key) + .await?) + } +} diff --git a/crates/whatsapp_store_pg/src/store/signal/test.rs b/crates/whatsapp_store_pg/src/store/signal/test.rs new file mode 100644 index 00000000000..bee366bb063 --- /dev/null +++ b/crates/whatsapp_store_pg/src/store/signal/test.rs @@ -0,0 +1,258 @@ +use std::collections::HashMap; + +use macro_db_migrator::MACRO_DB_MIGRATIONS; +use sqlx::{Pool, Postgres}; +use whatsmeow::store::{IdentityStore, PreKeyStore, SenderKeyStore, SessionStore, StoreError}; +use whatsmeow::types::{DEFAULT_USER_SERVER, HIDDEN_USER_SERVER, new_jid}; +use whatsmeow::util::keys::new_key_pair_from_private_key; + +use crate::store::test::open_new_store; + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn identities_are_trusted_on_first_use_and_deleted_by_user(pool: Pool) { + let store = open_new_store(&pool).await; + assert!(store.is_trusted_identity("111:0", [1; 32]).await.unwrap()); + store.put_identity("111:0", [1; 32]).await.unwrap(); + assert!(store.is_trusted_identity("111:0", [1; 32]).await.unwrap()); + assert!(!store.is_trusted_identity("111:0", [2; 32]).await.unwrap()); + + store.put_identity("111:3", [3; 32]).await.unwrap(); + store.put_identity("1112:0", [4; 32]).await.unwrap(); + // `_` is a LIKE wildcard: "111_1:0" must not match the prefix "1111:". + store.put_identity("111_1:0", [5; 32]).await.unwrap(); + store.put_identity("1111:0", [6; 32]).await.unwrap(); + store.delete_all_identities("111").await.unwrap(); + store.delete_all_identities("111_1").await.unwrap(); + assert!(store.is_trusted_identity("111:0", [9; 32]).await.unwrap()); + assert!(store.is_trusted_identity("111:3", [9; 32]).await.unwrap()); + assert!(store.is_trusted_identity("111_1:0", [9; 32]).await.unwrap()); + assert!(!store.is_trusted_identity("1112:0", [9; 32]).await.unwrap()); + assert!(!store.is_trusted_identity("1111:0", [9; 32]).await.unwrap()); + + store.delete_identity("1112:0").await.unwrap(); + assert!(store.is_trusted_identity("1112:0", [9; 32]).await.unwrap()); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn sessions_round_trip_singly_and_in_batches(pool: Pool) { + let store = open_new_store(&pool).await; + assert_eq!(store.get_session("111:0").await.unwrap(), None); + assert!(!store.has_session("111:0").await.unwrap()); + + store.put_session("111:0", b"one".to_vec()).await.unwrap(); + assert!(store.has_session("111:0").await.unwrap()); + store + .put_session("111:0", b"one again".to_vec()) + .await + .unwrap(); + assert_eq!( + store.get_session("111:0").await.unwrap(), + Some(b"one again".to_vec()) + ); + + store + .put_many_sessions(HashMap::from([ + ("111:1".to_owned(), b"two".to_vec()), + ("222:0".to_owned(), b"three".to_vec()), + ])) + .await + .unwrap(); + let many = store + .get_many_sessions(&["111:0".to_owned(), "111:1".to_owned(), "333:0".to_owned()]) + .await + .unwrap(); + assert_eq!( + many, + HashMap::from([ + ("111:0".to_owned(), Some(b"one again".to_vec())), + ("111:1".to_owned(), Some(b"two".to_vec())), + ("333:0".to_owned(), None), + ]) + ); + assert_eq!(store.get_many_sessions(&[]).await.unwrap(), HashMap::new()); + + store.delete_all_sessions("111").await.unwrap(); + assert_eq!(store.get_session("111:0").await.unwrap(), None); + assert_eq!(store.get_session("111:1").await.unwrap(), None); + assert_eq!( + store.get_session("222:0").await.unwrap(), + Some(b"three".to_vec()) + ); + store.delete_session("222:0").await.unwrap(); + assert!(!store.has_session("222:0").await.unwrap()); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn migrate_pn_to_lid_moves_sessions_identities_and_sender_keys(pool: Pool) { + let store = open_new_store(&pool).await; + let pn = new_jid("1234567890", DEFAULT_USER_SERVER); + let lid = new_jid("98765432101234", HIDDEN_USER_SERVER); + store + .put_session("1234567890:0", b"main".to_vec()) + .await + .unwrap(); + store + .put_session("1234567890:5", b"companion".to_vec()) + .await + .unwrap(); + store + .put_session("98765432101234_1:0", b"stale lid".to_vec()) + .await + .unwrap(); + store.put_identity("1234567890:0", [1; 32]).await.unwrap(); + store + .put_sender_key("group@g.us", "1234567890:0", b"sender".to_vec()) + .await + .unwrap(); + store + .put_session("12345678901:0", b"someone else".to_vec()) + .await + .unwrap(); + + store.migrate_pn_to_lid(&pn, &lid).await.unwrap(); + + assert_eq!(store.get_session("1234567890:0").await.unwrap(), None); + assert_eq!(store.get_session("1234567890:5").await.unwrap(), None); + assert_eq!( + store.get_session("98765432101234_1:0").await.unwrap(), + Some(b"main".to_vec()) + ); + assert_eq!( + store.get_session("98765432101234_1:5").await.unwrap(), + Some(b"companion".to_vec()) + ); + assert!( + store + .is_trusted_identity("1234567890:0", [9; 32]) + .await + .unwrap() + ); + assert!( + !store + .is_trusted_identity("98765432101234_1:0", [9; 32]) + .await + .unwrap() + ); + assert_eq!( + store + .get_sender_key("group@g.us", "98765432101234_1:0") + .await + .unwrap(), + Some(b"sender".to_vec()) + ); + assert_eq!( + store + .get_sender_key("group@g.us", "1234567890:0") + .await + .unwrap(), + None + ); + assert_eq!( + store.get_session("12345678901:0").await.unwrap(), + Some(b"someone else".to_vec()) + ); + + // Migrated once per process: a session stored under the phone number + // afterwards stays where it is. + store + .put_session("1234567890:0", b"new".to_vec()) + .await + .unwrap(); + store.migrate_pn_to_lid(&pn, &lid).await.unwrap(); + assert_eq!( + store.get_session("1234567890:0").await.unwrap(), + Some(b"new".to_vec()) + ); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn pre_keys_are_numbered_from_one_and_marked_uploaded(pool: Pool) { + let store = open_new_store(&pool).await; + let first_batch = store.get_or_gen_pre_keys(3).await.unwrap(); + assert_eq!( + first_batch.iter().map(|key| key.key_id).collect::>(), + vec![1, 2, 3] + ); + for key in &first_batch { + assert_eq!( + key.key_pair, + new_key_pair_from_private_key(key.key_pair.private) + ); + assert_eq!(key.signature, None); + } + // Not uploaded yet, so the same keys come back. + let again = store.get_or_gen_pre_keys(2).await.unwrap(); + assert_eq!(again, first_batch[..2]); + + store.mark_pre_keys_as_uploaded(2).await.unwrap(); + assert_eq!(store.uploaded_pre_key_count().await.unwrap(), 2); + let next_batch = store.get_or_gen_pre_keys(2).await.unwrap(); + assert_eq!( + next_batch.iter().map(|key| key.key_id).collect::>(), + vec![3, 4] + ); + assert_eq!(next_batch[0], first_batch[2]); + assert_eq!(store.gen_one_pre_key().await.unwrap().key_id, 5); + assert_eq!(store.uploaded_pre_key_count().await.unwrap(), 3); + + store.remove_pre_key(1).await.unwrap(); + assert_eq!(store.get_pre_key(1).await.unwrap(), None); + assert_eq!( + store.get_pre_key(2).await.unwrap(), + Some(first_batch[1].clone()) + ); + // IDs keep counting up after removals. + store.remove_pre_key(5).await.unwrap(); + assert_eq!(store.gen_one_pre_key().await.unwrap().key_id, 5); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn a_large_pre_key_batch_is_generated_and_uploaded_at_once(pool: Pool) { + let store = open_new_store(&pool).await; + let keys = store.get_or_gen_pre_keys(5000).await.unwrap(); + assert_eq!( + keys.iter().map(|key| key.key_id).collect::>(), + (1..=5000).collect::>() + ); + assert_eq!(store.get_or_gen_pre_keys(5000).await.unwrap(), keys); + + store.mark_pre_keys_as_uploaded(5000).await.unwrap(); + assert_eq!(store.uploaded_pre_key_count().await.unwrap(), 5000); + assert_eq!( + store.get_pre_key(4321).await.unwrap(), + Some(keys[4320].clone()) + ); + assert_eq!(store.get_or_gen_pre_keys(1).await.unwrap()[0].key_id, 5001); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn sender_keys_are_per_group_and_sender(pool: Pool) { + let store = open_new_store(&pool).await; + store + .put_sender_key("1@g.us", "111:0", b"first".to_vec()) + .await + .unwrap(); + store + .put_sender_key("2@g.us", "111:0", b"second".to_vec()) + .await + .unwrap(); + store + .put_sender_key("1@g.us", "111:0", b"first again".to_vec()) + .await + .unwrap(); + assert_eq!( + store.get_sender_key("1@g.us", "111:0").await.unwrap(), + Some(b"first again".to_vec()) + ); + assert_eq!( + store.get_sender_key("2@g.us", "111:0").await.unwrap(), + Some(b"second".to_vec()) + ); + assert_eq!(store.get_sender_key("1@g.us", "222:0").await.unwrap(), None); + + let error = store + .put_sender_key("1@g.us", "111|0", b"ambiguous".to_vec()) + .await + .unwrap_err(); + assert!(matches!(error, StoreError::InvalidArgument(_)), "{error:?}"); +} diff --git a/crates/whatsapp_store_pg/src/store/test.rs b/crates/whatsapp_store_pg/src/store/test.rs new file mode 100644 index 00000000000..27fa59e2174 --- /dev/null +++ b/crates/whatsapp_store_pg/src/store/test.rs @@ -0,0 +1,628 @@ +//! Opening, encryption at rest, fencing and concurrency, against a real +//! Postgres. The fixtures here are shared with the per-store tests. + +use std::sync::Arc; + +use macro_db_migrator::MACRO_DB_MIGRATIONS; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use whatsmeow::signal::CurveSignatures; +use whatsmeow::store::{IdentityStore, PreKeyStore, SessionStore}; + +use super::PgWhatsAppStore; +use crate::error::{WhatsAppStoreError, is_fenced}; +use crate::kms::FakeWhatsAppKms; + +pub(crate) const KMS_KEY_ID: &str = "arn:aws:kms:us-east-1:123456789012:key/whatsapp-test"; + +/// Signatures are only used to sign a new device's prekey here. +pub(crate) struct FixedSignatures; + +impl CurveSignatures for FixedSignatures { + fn calculate_signature(&self, _private_key: &[u8; 32], _message: &[u8]) -> [u8; 64] { + [7; 64] + } + + fn verify_signature( + &self, + _public_key: &[u8; 32], + _message: &[u8], + _signature: &[u8; 64], + ) -> bool { + true + } +} + +/// A Macro user: a `macro_user` identity and the `"User"` profile the +/// account references. +pub(crate) async fn insert_user(pool: &Pool, user_id: &str) { + let email = format!("{user_id}@example.com"); + sqlx::query!( + r#" + WITH new_macro_user AS ( + INSERT INTO macro_user (id, username, email, stripe_customer_id) + VALUES (gen_random_uuid(), $2, $2, $3) + RETURNING id + ) + INSERT INTO "User" ("id", "email", "macro_user_id") + SELECT $1, $2, new_macro_user.id FROM new_macro_user + "#, + user_id, + email, + format!("stripe_{user_id}"), + ) + .execute(pool) + .await + .expect("the user is new"); +} + +/// A Macro user and a linked account of theirs, at lease epoch 0. +pub(crate) async fn insert_account(pool: &Pool, user_id: &str) -> Uuid { + insert_user(pool, user_id).await; + let account_id = Uuid::now_v7(); + sqlx::query!( + r#" + INSERT INTO whatsapp_linked_account (id, user_id, state) + VALUES ($1, $2, 'pairing') + "#, + account_id, + user_id, + ) + .execute(pool) + .await + .expect("the account is new"); + account_id +} + +/// A store of a new account, at lease epoch 0. +pub(crate) async fn open_new_store(pool: &Pool) -> Arc { + let account_id = insert_account(pool, "macro|whatsapp-store-owner").await; + let kms = FakeWhatsAppKms::new(KMS_KEY_ID); + Arc::new( + PgWhatsAppStore::open(pool.clone(), account_id, 0, &kms) + .await + .expect("the account is new"), + ) +} + +async fn set_lease_epoch(pool: &Pool, account_id: Uuid, lease_epoch: i64) { + sqlx::query!( + "UPDATE whatsapp_linked_account SET lease_epoch = $2 WHERE id = $1", + account_id, + lease_epoch, + ) + .execute(pool) + .await + .expect("the account exists"); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn the_data_key_is_generated_once_and_unwrapped_on_every_later_open(pool: Pool) { + let account_id = insert_account(&pool, "macro|data-key").await; + let kms = FakeWhatsAppKms::new(KMS_KEY_ID); + + let first = PgWhatsAppStore::open(pool.clone(), account_id, 0, &kms) + .await + .unwrap(); + first + .put_session("111:0", b"session".to_vec()) + .await + .unwrap(); + assert_eq!((kms.generate_calls(), kms.decrypt_calls()), (1, 0)); + + let second = PgWhatsAppStore::open(pool.clone(), account_id, 0, &kms) + .await + .unwrap(); + assert_eq!((kms.generate_calls(), kms.decrypt_calls()), (1, 1)); + assert_eq!( + second.get_session("111:0").await.unwrap(), + Some(b"session".to_vec()) + ); + // Reads and writes never call KMS again. + second + .put_session("222:0", b"other".to_vec()) + .await + .unwrap(); + assert_eq!((kms.generate_calls(), kms.decrypt_calls()), (1, 1)); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn concurrent_first_opens_agree_on_one_data_key(pool: Pool) { + let account_id = insert_account(&pool, "macro|first-opens").await; + let kms = FakeWhatsAppKms::new(KMS_KEY_ID); + + let (first, second) = tokio::join!( + PgWhatsAppStore::open(pool.clone(), account_id, 0, &kms), + PgWhatsAppStore::open(pool.clone(), account_id, 0, &kms), + ); + let (first, second) = (first.unwrap(), second.unwrap()); + + first + .put_session("111:0", b"from first".to_vec()) + .await + .unwrap(); + second + .put_session("222:0", b"from second".to_vec()) + .await + .unwrap(); + assert_eq!( + second.get_session("111:0").await.unwrap(), + Some(b"from first".to_vec()) + ); + assert_eq!( + first.get_session("222:0").await.unwrap(), + Some(b"from second".to_vec()) + ); + let devices = sqlx::query_scalar!( + r#"SELECT COUNT(*) AS "count!" FROM whatsapp_device WHERE account_id = $1"#, + account_id, + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(devices, 1); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn values_are_stored_as_ciphertext(pool: Pool) { + let store = open_new_store(&pool).await; + let session = b"a very recognizable session record".to_vec(); + store.put_session("111:0", session.clone()).await.unwrap(); + + let stored = sqlx::query!( + r#" + SELECT nonce, value + FROM whatsapp_store_value + WHERE account_id = $1 AND store = 'session' AND key = '111:0' + "#, + store.account_id(), + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stored.nonce.len(), 12); + // Ciphertext plus the 16-byte tag. + assert_eq!(stored.value.len(), session.len() + 16); + assert!( + !stored + .value + .windows(8) + .any(|window| session.windows(8).any(|plain| plain == window)), + "no run of the plaintext appears in the stored value" + ); + + // A fresh nonce per write: writing the same value again changes both. + store.put_session("111:0", session.clone()).await.unwrap(); + let rewritten = sqlx::query!( + r#" + SELECT nonce, value + FROM whatsapp_store_value + WHERE account_id = $1 AND store = 'session' AND key = '111:0' + "#, + store.account_id(), + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_ne!(rewritten.nonce, stored.nonce); + assert_ne!(rewritten.value, stored.value); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn a_value_moved_to_another_key_does_not_decrypt(pool: Pool) { + let store = open_new_store(&pool).await; + store.put_session("111:0", b"alice".to_vec()).await.unwrap(); + store.put_session("222:0", b"bob".to_vec()).await.unwrap(); + + sqlx::query!( + r#" + UPDATE whatsapp_store_value AS target + SET nonce = source.nonce, value = source.value + FROM whatsapp_store_value AS source + WHERE target.account_id = $1 AND target.store = 'session' AND target.key = '222:0' + AND source.account_id = $1 AND source.store = 'session' AND source.key = '111:0' + "#, + store.account_id(), + ) + .execute(&pool) + .await + .unwrap(); + + let error = store.get_session("222:0").await.unwrap_err(); + let whatsmeow::store::StoreError::Internal(report) = &error else { + panic!("unexpected error: {error:?}"); + }; + assert!( + matches!( + report.downcast_current_context::(), + Some(WhatsAppStoreError::Decrypt) + ), + "unexpected error: {error:?}" + ); + assert_eq!( + store.get_session("111:0").await.unwrap(), + Some(b"alice".to_vec()) + ); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn a_value_moved_to_another_store_does_not_decrypt(pool: Pool) { + let store = open_new_store(&pool).await; + store.put_identity("111:0", [9; 32]).await.unwrap(); + + sqlx::query!( + r#" + INSERT INTO whatsapp_store_value (account_id, store, key, nonce, value) + SELECT account_id, 'session', key, nonce, value + FROM whatsapp_store_value + WHERE account_id = $1 AND store = 'identity' AND key = '111:0' + "#, + store.account_id(), + ) + .execute(&pool) + .await + .unwrap(); + + assert!(store.get_session("111:0").await.is_err()); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn a_value_moved_to_another_account_does_not_decrypt(pool: Pool) { + let kms = FakeWhatsAppKms::new(KMS_KEY_ID); + let alice_account = insert_account(&pool, "macro|alice").await; + let bob_account = insert_account(&pool, "macro|bob").await; + let alice = PgWhatsAppStore::open(pool.clone(), alice_account, 0, &kms) + .await + .unwrap(); + let bob = PgWhatsAppStore::open(pool.clone(), bob_account, 0, &kms) + .await + .unwrap(); + alice + .put_session("111:0", b"alice's".to_vec()) + .await + .unwrap(); + + sqlx::query!( + r#" + INSERT INTO whatsapp_store_value (account_id, store, key, nonce, value) + SELECT $2, store, key, nonce, value + FROM whatsapp_store_value + WHERE account_id = $1 AND store = 'session' AND key = '111:0' + "#, + alice_account, + bob_account, + ) + .execute(&pool) + .await + .unwrap(); + + assert!(bob.get_session("111:0").await.is_err()); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn a_data_key_moved_to_another_account_does_not_unwrap(pool: Pool) { + let kms = FakeWhatsAppKms::new(KMS_KEY_ID); + let alice_account = insert_account(&pool, "macro|alice").await; + let bob_account = insert_account(&pool, "macro|bob").await; + PgWhatsAppStore::open(pool.clone(), alice_account, 0, &kms) + .await + .unwrap(); + PgWhatsAppStore::open(pool.clone(), bob_account, 0, &kms) + .await + .unwrap(); + + sqlx::query!( + r#" + UPDATE whatsapp_device AS bob + SET data_key_ciphertext = alice.data_key_ciphertext + FROM whatsapp_device AS alice + WHERE bob.account_id = $2 AND alice.account_id = $1 + "#, + alice_account, + bob_account, + ) + .execute(&pool) + .await + .unwrap(); + + let error = PgWhatsAppStore::open(pool.clone(), bob_account, 0, &kms) + .await + .unwrap_err(); + assert!(matches!(error, WhatsAppStoreError::Kms(_)), "{error:?}"); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn a_data_key_does_not_unwrap_for_another_owner_or_kms_key(pool: Pool) { + let kms = FakeWhatsAppKms::new(KMS_KEY_ID); + let account_id = insert_account(&pool, "macro|owner").await; + PgWhatsAppStore::open(pool.clone(), account_id, 0, &kms) + .await + .unwrap(); + + // Another KMS key (or another key altogether) refuses the wrapped key. + let other_kms = FakeWhatsAppKms::new(KMS_KEY_ID); + let error = PgWhatsAppStore::open(pool.clone(), account_id, 0, &other_kms) + .await + .unwrap_err(); + assert!(matches!(error, WhatsAppStoreError::Kms(_)), "{error:?}"); + + // The account handed to another user: the context no longer matches. + insert_user(&pool, "macro|new-owner").await; + sqlx::query!( + "UPDATE whatsapp_linked_account SET user_id = 'macro|new-owner' WHERE id = $1", + account_id, + ) + .execute(&pool) + .await + .unwrap(); + let error = PgWhatsAppStore::open(pool.clone(), account_id, 0, &kms) + .await + .unwrap_err(); + assert!(matches!(error, WhatsAppStoreError::Kms(_)), "{error:?}"); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn an_unknown_encryption_version_is_refused_before_calling_kms(pool: Pool) { + let kms = FakeWhatsAppKms::new(KMS_KEY_ID); + let account_id = insert_account(&pool, "macro|version").await; + PgWhatsAppStore::open(pool.clone(), account_id, 0, &kms) + .await + .unwrap(); + sqlx::query!( + "UPDATE whatsapp_device SET encryption_version = 2 WHERE account_id = $1", + account_id, + ) + .execute(&pool) + .await + .unwrap(); + + let error = PgWhatsAppStore::open(pool.clone(), account_id, 0, &kms) + .await + .unwrap_err(); + assert!( + matches!(error, WhatsAppStoreError::UnsupportedVersion(2)), + "{error:?}" + ); + assert_eq!(kms.decrypt_calls(), 0); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn opening_a_missing_account_fails(pool: Pool) { + let kms = FakeWhatsAppKms::new(KMS_KEY_ID); + let error = PgWhatsAppStore::open(pool.clone(), Uuid::now_v7(), 0, &kms) + .await + .unwrap_err(); + assert!( + matches!(error, WhatsAppStoreError::AccountNotFound), + "{error:?}" + ); + assert_eq!(kms.generate_calls(), 0); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn a_takeover_fences_out_the_previous_holder(pool: Pool) { + let kms = FakeWhatsAppKms::new(KMS_KEY_ID); + let account_id = insert_account(&pool, "macro|fencing").await; + let previous = PgWhatsAppStore::open(pool.clone(), account_id, 0, &kms) + .await + .unwrap(); + previous + .put_session("111:0", b"before".to_vec()) + .await + .unwrap(); + + set_lease_epoch(&pool, account_id, 1).await; + + let error = previous + .put_session("111:0", b"stale".to_vec()) + .await + .unwrap_err(); + assert!(is_fenced(&error), "{error:?}"); + let error = previous.get_or_gen_pre_keys(5).await.unwrap_err(); + assert!(is_fenced(&error), "{error:?}"); + assert_eq!( + sqlx::query_scalar!( + r#"SELECT COUNT(*) AS "count!" FROM whatsapp_pre_key WHERE account_id = $1"#, + account_id, + ) + .fetch_one(&pool) + .await + .unwrap(), + 0 + ); + + let error = PgWhatsAppStore::open(pool.clone(), account_id, 0, &kms) + .await + .unwrap_err(); + assert!(matches!(error, WhatsAppStoreError::Fenced), "{error:?}"); + + let next = PgWhatsAppStore::open(pool.clone(), account_id, 1, &kms) + .await + .unwrap(); + assert_eq!( + next.get_session("111:0").await.unwrap(), + Some(b"before".to_vec()) + ); + next.put_session("111:0", b"after".to_vec()).await.unwrap(); + assert_eq!( + previous.get_session("111:0").await.unwrap(), + Some(b"after".to_vec()), + "reads are not fenced" + ); +} + +/// The epoch bump of a takeover waits for an in-flight write of the previous +/// holder, so the write either lands before the takeover or not at all. +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn a_takeover_waits_for_an_in_flight_write(pool: Pool) { + let kms = FakeWhatsAppKms::new(KMS_KEY_ID); + let account_id = insert_account(&pool, "macro|in-flight").await; + let previous = PgWhatsAppStore::open(pool.clone(), account_id, 0, &kms) + .await + .unwrap(); + + let mut in_flight = previous.begin_write().await.unwrap(); + // While the write is open, the takeover's epoch bump cannot take the row. + let mut takeover = pool.acquire().await.unwrap(); + sqlx::query!("SET lock_timeout = '200ms'") + .execute(&mut *takeover) + .await + .unwrap(); + let blocked = sqlx::query!( + "UPDATE whatsapp_linked_account SET lease_epoch = 1 WHERE id = $1", + account_id, + ) + .execute(&mut *takeover) + .await + .unwrap_err(); + assert!( + blocked.to_string().contains("lock timeout"), + "the bump waited on the write: {blocked}" + ); + previous + .put_values( + &mut in_flight, + crate::cipher::StoreName::Session, + std::collections::BTreeMap::from([("111:0".to_owned(), b"landed".to_vec())]), + ) + .await + .unwrap(); + in_flight.commit().await.unwrap(); + sqlx::query!( + "UPDATE whatsapp_linked_account SET lease_epoch = 1 WHERE id = $1", + account_id, + ) + .execute(&mut *takeover) + .await + .unwrap(); + + assert_eq!( + previous.get_session("111:0").await.unwrap(), + Some(b"landed".to_vec()) + ); + assert!(is_fenced( + &previous + .put_session("111:0", b"late".to_vec()) + .await + .unwrap_err() + )); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn concurrent_writers_do_not_lose_writes_or_reuse_prekey_ids(pool: Pool) { + let kms = FakeWhatsAppKms::new(KMS_KEY_ID); + let account_id = insert_account(&pool, "macro|concurrent").await; + let first = Arc::new( + PgWhatsAppStore::open(pool.clone(), account_id, 0, &kms) + .await + .unwrap(), + ); + let second = Arc::new( + PgWhatsAppStore::open(pool.clone(), account_id, 0, &kms) + .await + .unwrap(), + ); + + let sessions = (0..40u8).map(|index| { + let store = if index % 2 == 0 { + first.clone() + } else { + second.clone() + }; + async move { store.put_session(&format!("{index}:0"), vec![index]).await } + }); + futures::future::try_join_all(sessions).await.unwrap(); + + let upload_rounds = |store: &Arc| { + let store = store.clone(); + async move { + for _ in 0..5 { + let keys = store.get_or_gen_pre_keys(10).await?; + let last = keys.last().map_or(0, |key| key.key_id); + store.mark_pre_keys_as_uploaded(last).await?; + } + Ok::<_, whatsmeow::store::StoreError>(()) + } + }; + let (first_rounds, second_rounds) = tokio::join!(upload_rounds(&first), upload_rounds(&second)); + first_rounds.unwrap(); + second_rounds.unwrap(); + + let all: Vec = (0..40).map(|index| format!("{index}:0")).collect(); + let stored = first.get_many_sessions(&all).await.unwrap(); + for index in 0..40u8 { + assert_eq!(stored[&format!("{index}:0")], Some(vec![index])); + } + // Numbering was serialized: IDs are 1..=n with no gap or duplicate. + let ids = sqlx::query_scalar!( + "SELECT key_id FROM whatsapp_pre_key WHERE account_id = $1 ORDER BY key_id", + account_id, + ) + .fetch_all(&pool) + .await + .unwrap(); + assert!(ids.len() >= 50, "every round had keys to upload"); + assert_eq!(ids, (1..=ids.len() as i64).collect::>()); + assert_eq!(first.uploaded_pre_key_count().await.unwrap(), ids.len()); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn sealed_blobs_open_only_for_their_account_and_purpose(pool: Pool) { + let kms = FakeWhatsAppKms::new(KMS_KEY_ID); + let alice_account = insert_account(&pool, "macro|alice").await; + let bob_account = insert_account(&pool, "macro|bob").await; + let alice = PgWhatsAppStore::open(pool.clone(), alice_account, 0, &kms) + .await + .unwrap(); + let bob = PgWhatsAppStore::open(pool.clone(), bob_account, 0, &kms) + .await + .unwrap(); + + let sealed = alice.seal("media-locator", b"https://mmg/locator").unwrap(); + assert_eq!(sealed[0], 1); + assert_eq!(sealed.len(), 1 + 12 + 19 + 16); + assert_eq!( + *alice.unseal("media-locator", &sealed).unwrap(), + b"https://mmg/locator".to_vec() + ); + assert_ne!( + alice.seal("media-locator", b"https://mmg/locator").unwrap(), + sealed + ); + + assert!(matches!( + alice.unseal("other-purpose", &sealed), + Err(WhatsAppStoreError::Decrypt) + )); + assert!(matches!( + bob.unseal("media-locator", &sealed), + Err(WhatsAppStoreError::Decrypt) + )); + for tampered_at in [0, 5, sealed.len() - 1] { + let mut tampered = sealed.clone(); + tampered[tampered_at] ^= 1; + assert!(alice.unseal("media-locator", &tampered).is_err()); + } + assert!(alice.unseal("media-locator", &sealed[..10]).is_err()); + assert!(alice.unseal("media-locator", &[]).is_err()); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn a_sealed_blob_is_not_a_store_value(pool: Pool) { + let store = open_new_store(&pool).await; + // A blob sealed for purpose "111:0" can't be planted as the session "111:0". + let sealed = store.seal("111:0", b"planted").unwrap(); + sqlx::query!( + r#" + INSERT INTO whatsapp_store_value (account_id, store, key, nonce, value) + VALUES ($1, 'session', '111:0', $2, $3) + "#, + store.account_id(), + &sealed[1..13], + &sealed[13..], + ) + .execute(&pool) + .await + .unwrap(); + assert!(store.get_session("111:0").await.is_err()); +}