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