From a09b2d92a553286a540741f87c4c1e3a69115238 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 28 Oct 2026 02:10:10 +0200 Subject: [PATCH 01/10] Persist hosted account state after HOSTED pairing WhatsApp Web marks the companion as a hosted (coexistence) account when the pair-success ADVSignedDeviceIdentityHMAC has accountType HOSTED and verifies its HMAC with the [6,5] prefix (WAWebHandlePairSuccess:89-93, WAWebAdvSignatureConstants:3-4, bundle whatsapp-1051908825), storing it as isHostedMeAccount. HyperMeow verified the prefix but dropped the fact. Add store.Device.IsHosted, set it during pairing, report it on events.PairSuccess, and persist it in sqlstore. The account type is only available in the pair-success container, which is not stored, so the device table needs a column: schema v20 adds is_hosted BOOLEAN NOT NULL DEFAULT false. Existing rows default to false and the change stays compatible with v8+. --- pair.go | 4 +- pair_hosted_test.go | 111 ++++++++++++++++++ store/sqlstore/container.go | 13 +- store/sqlstore/upgrades/00-latest-schema.sql | 6 +- store/sqlstore/upgrades/20-hosted-account.sql | 2 + store/sqlstore/upgrades/upgrades_test.go | 32 ++++- store/store.go | 5 + types/events/events.go | 4 + 8 files changed, 163 insertions(+), 14 deletions(-) create mode 100644 pair_hosted_test.go create mode 100644 store/sqlstore/upgrades/20-hosted-account.sql diff --git a/pair.go b/pair.go index 7e25a1b68..1ffe8b35b 100644 --- a/pair.go +++ b/pair.go @@ -154,7 +154,7 @@ func (cli *Client) handlePairSuccess(ctx context.Context, node *waBinary.Node) { } else { cli.Log.Infof("Successfully paired %s", cli.Store.ID) go cli.sendUnifiedSession(false) - cli.dispatchEvent(&events.PairSuccess{ID: jid, LID: lid, BusinessName: businessName, Platform: platform, Props: &props}) + cli.dispatchEvent(&events.PairSuccess{ID: jid, LID: lid, BusinessName: businessName, Platform: platform, Props: &props, IsHosted: cli.Store.IsHosted}) } }() } @@ -170,7 +170,6 @@ func (cli *Client) handlePair(ctx context.Context, deviceIdentityBytes []byte, r h := hmac.New(sha256.New, cli.Store.AdvSecretKey) if deviceIdentityContainer.GetAccountType() == waAdv.ADVEncryptionType_HOSTED { h.Write(AdvHostedAccountSignaturePrefix) - //cli.Store.IsHosted = true } h.Write(deviceIdentityContainer.Details) @@ -232,6 +231,7 @@ func (cli *Client) handlePair(ctx context.Context, deviceIdentityBytes []byte, r cli.Store.LID = lid cli.Store.BusinessName = businessName cli.Store.Platform = platform + cli.Store.IsHosted = deviceIdentityContainer.GetAccountType() == waAdv.ADVEncryptionType_HOSTED err = cli.Store.Save(ctx) if err != nil { cli.sendPairError(ctx, reqID, 500, "internal-error") diff --git a/pair_hosted_test.go b/pair_hosted_test.go new file mode 100644 index 000000000..65912726a --- /dev/null +++ b/pair_hosted_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + +package whatsmeow + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "testing" + + "github.com/polymorfa/libsignal-protocol-go/ecc" + "google.golang.org/protobuf/proto" + + "github.com/polymorfa/hypermeow/proto/waAdv" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" + waLog "github.com/polymorfa/hypermeow/util/log" + "github.com/polymorfa/hypermeow/util/keys" +) + +type hostedPairContainer struct { + store.NoopStore + saved int + isHosted bool +} + +func (c *hostedPairContainer) PutDevice(_ context.Context, device *store.Device) error { + c.saved++ + c.isHosted = device.IsHosted + return nil +} + +// buildPairDeviceIdentity builds the content of a pair-success +// stanza the way the primary does: an ADVSignedDeviceIdentityHMAC whose HMAC +// covers [6,5] || details for HOSTED accounts (WAWebHandlePairSuccess:89-93). +func buildPairDeviceIdentity(t *testing.T, device *store.Device, accountType waAdv.ADVEncryptionType) []byte { + t.Helper() + primary := keys.NewKeyPair() + details, err := proto.Marshal(&waAdv.ADVDeviceIdentity{ + RawID: proto.Uint32(1), + Timestamp: proto.Uint64(1700000000), + KeyIndex: proto.Uint32(1), + AccountType: accountType.Enum(), + DeviceType: waAdv.ADVEncryptionType_E2EE.Enum(), + }) + if err != nil { + t.Fatal(err) + } + accountSig := ecc.CalculateSignature(ecc.NewDjbECPrivateKey(*primary.Priv), concatBytes(AdvAccountSignaturePrefix, details, device.IdentityKey.Pub[:])) + signed, err := proto.Marshal(&waAdv.ADVSignedDeviceIdentity{ + Details: details, + AccountSignatureKey: primary.Pub[:], + AccountSignature: accountSig[:], + }) + if err != nil { + t.Fatal(err) + } + mac := hmac.New(sha256.New, device.AdvSecretKey) + if accountType == waAdv.ADVEncryptionType_HOSTED { + mac.Write(AdvHostedAccountSignaturePrefix) + } + mac.Write(signed) + container, err := proto.Marshal(&waAdv.ADVSignedDeviceIdentityHMAC{ + Details: signed, + HMAC: mac.Sum(nil), + AccountType: accountType.Enum(), + }) + if err != nil { + t.Fatal(err) + } + return container +} + +func TestHandlePairPersistsHostedAccount(t *testing.T) { + for _, tc := range []struct { + name string + accountType waAdv.ADVEncryptionType + want bool + }{ + {"hosted", waAdv.ADVEncryptionType_HOSTED, true}, + {"e2ee", waAdv.ADVEncryptionType_E2EE, false}, + } { + t.Run(tc.name, func(t *testing.T) { + container := &hostedPairContainer{} + device := &store.Device{ + NoiseKey: keys.NewKeyPair(), + IdentityKey: keys.NewKeyPair(), + AdvSecretKey: make([]byte, 32), + Container: container, + LIDs: &store.NoopStore{}, + } + device.SetAllStores(&store.NoopStore{}) + cli := NewClient(device, waLog.Noop) + jid := types.NewJID("15551234567", types.DefaultUserServer) + jid.Device = 12 + lid := types.NewJID("123456789012345", types.HiddenUserServer) + lid.Device = 12 + + // The client is offline, so the final pair-device-sign send fails after the store is saved. + _ = cli.handlePair(context.Background(), buildPairDeviceIdentity(t, device, tc.accountType), "pair-1", "", "smba", jid, lid) + if container.saved == 0 { + t.Fatal("device was not saved") + } + if container.isHosted != tc.want { + t.Fatalf("saved IsHosted = %v, want %v", container.isHosted, tc.want) + } + }) + } +} diff --git a/store/sqlstore/container.go b/store/sqlstore/container.go index 3ef6bf294..8f9d84f53 100644 --- a/store/sqlstore/container.go +++ b/store/sqlstore/container.go @@ -116,7 +116,7 @@ const getAllDevicesQuery = ` SELECT jid, lid, registration_id, noise_key, identity_key, signed_pre_key, signed_pre_key_id, signed_pre_key_sig, adv_key, adv_details, adv_account_sig, adv_account_sig_key, adv_device_sig, - platform, business_name, push_name, facebook_uuid, lid_migration_ts, companion_meta_nonce + platform, business_name, push_name, facebook_uuid, lid_migration_ts, companion_meta_nonce, is_hosted FROM whatsmeow_device ` @@ -134,7 +134,7 @@ func (c *Container) scanDevice(row dbutil.Scannable) (*store.Device, error) { &device.ID, &device.LID, &device.RegistrationID, &noisePriv, &identityPriv, &preKeyPriv, &device.SignedPreKey.KeyID, &preKeySig, &device.AdvSecretKey, &account.Details, &account.AccountSignature, &account.AccountSignatureKey, &account.DeviceSignature, - &device.Platform, &device.BusinessName, &device.PushName, &fbUUID, &device.LIDMigrationTimestamp, &device.CompanionMetaNonce) + &device.Platform, &device.BusinessName, &device.PushName, &fbUUID, &device.LIDMigrationTimestamp, &device.CompanionMetaNonce, &device.IsHosted) if err != nil { return nil, fmt.Errorf("failed to scan session: %w", err) } else if len(noisePriv) != 32 || len(identityPriv) != 32 || len(preKeyPriv) != 32 || len(preKeySig) != 64 { @@ -192,15 +192,16 @@ const ( INSERT INTO whatsmeow_device (jid, lid, registration_id, noise_key, identity_key, signed_pre_key, signed_pre_key_id, signed_pre_key_sig, adv_key, adv_details, adv_account_sig, adv_account_sig_key, adv_device_sig, - platform, business_name, push_name, facebook_uuid, lid_migration_ts, companion_meta_nonce) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + platform, business_name, push_name, facebook_uuid, lid_migration_ts, companion_meta_nonce, is_hosted) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) ON CONFLICT (jid) DO UPDATE SET lid=excluded.lid, platform=excluded.platform, business_name=excluded.business_name, push_name=excluded.push_name, lid_migration_ts=excluded.lid_migration_ts, - companion_meta_nonce=excluded.companion_meta_nonce + companion_meta_nonce=excluded.companion_meta_nonce, + is_hosted=excluded.is_hosted ` deleteDeviceQuery = `DELETE FROM whatsmeow_device WHERE jid=$1` ) @@ -245,7 +246,7 @@ func (c *Container) PutDevice(ctx context.Context, device *store.Device) error { device.SignedPreKey.Priv[:], device.SignedPreKey.KeyID, device.SignedPreKey.Signature[:], device.AdvSecretKey, device.Account.Details, device.Account.AccountSignature, device.Account.AccountSignatureKey, device.Account.DeviceSignature, device.Platform, device.BusinessName, device.PushName, uuid.NullUUID{UUID: device.FacebookUUID, Valid: device.FacebookUUID != uuid.Nil}, - device.LIDMigrationTimestamp, device.CompanionMetaNonce, + device.LIDMigrationTimestamp, device.CompanionMetaNonce, device.IsHosted, ) if !device.Initialized { diff --git a/store/sqlstore/upgrades/00-latest-schema.sql b/store/sqlstore/upgrades/00-latest-schema.sql index 6ac21239e..fc7269023 100644 --- a/store/sqlstore/upgrades/00-latest-schema.sql +++ b/store/sqlstore/upgrades/00-latest-schema.sql @@ -1,4 +1,4 @@ --- v0 -> v19 (compatible with v8+): Latest schema +-- v0 -> v20 (compatible with v8+): Latest schema CREATE TABLE whatsmeow_device ( jid TEXT PRIMARY KEY, lid TEXT, @@ -26,7 +26,9 @@ CREATE TABLE whatsmeow_device ( lid_migration_ts BIGINT NOT NULL DEFAULT 0, - companion_meta_nonce TEXT NOT NULL DEFAULT '' + companion_meta_nonce TEXT NOT NULL DEFAULT '', + + is_hosted BOOLEAN NOT NULL DEFAULT false ); CREATE TABLE whatsmeow_identity_keys ( diff --git a/store/sqlstore/upgrades/20-hosted-account.sql b/store/sqlstore/upgrades/20-hosted-account.sql new file mode 100644 index 000000000..087340be9 --- /dev/null +++ b/store/sqlstore/upgrades/20-hosted-account.sql @@ -0,0 +1,2 @@ +-- v20 (compatible with v8+): Remember whether the companion is paired to a hosted (coexistence) account +ALTER TABLE whatsmeow_device ADD COLUMN is_hosted BOOLEAN NOT NULL DEFAULT false; diff --git a/store/sqlstore/upgrades/upgrades_test.go b/store/sqlstore/upgrades/upgrades_test.go index 867768c74..3976deb7e 100644 --- a/store/sqlstore/upgrades/upgrades_test.go +++ b/store/sqlstore/upgrades/upgrades_test.go @@ -157,8 +157,8 @@ func TestUpgradeFromCurrentDevSchemaAddsUsername(t *testing.T) { if err = db.Upgrade(context.Background()); err != nil { t.Fatal(err) } - if state.version != 19 || state.compatVersion != 8 { - t.Fatalf("schema version = %d/%d, want 19/8", state.version, state.compatVersion) + if state.version != 20 || state.compatVersion != 8 { + t.Fatalf("schema version = %d/%d, want 20/8", state.version, state.compatVersion) } want := "ALTER TABLE whatsmeow_contacts ADD COLUMN username TEXT;" for _, query := range state.executed { @@ -182,8 +182,8 @@ func TestUpgradeFromPublishedSchemaAddsWASARootSecret(t *testing.T) { if err = db.Upgrade(t.Context()); err != nil { t.Fatal(err) } - if state.version != 19 || state.compatVersion != 8 { - t.Fatalf("schema version = %d/%d, want 19/8", state.version, state.compatVersion) + if state.version != 20 || state.compatVersion != 8 { + t.Fatalf("schema version = %d/%d, want 20/8", state.version, state.compatVersion) } for _, query := range state.executed { if strings.Contains(query, "ALTER TABLE whatsmeow_chat_settings ADD COLUMN wasa_root_secret_id TEXT NOT NULL DEFAULT '';") { @@ -192,3 +192,27 @@ func TestUpgradeFromPublishedSchemaAddsWASARootSecret(t *testing.T) { } t.Fatalf("WASA migration was not executed: %q", state.executed) } + +func TestUpgradeFromV19AddsHostedAccountFlag(t *testing.T) { + state := &upgradeTestState{version: 19, compatVersion: 8} + rawDB := sql.OpenDB(&upgradeTestConnector{state: state}) + t.Cleanup(func() { _ = rawDB.Close() }) + db, err := dbutil.NewWithDB(rawDB, "postgres") + if err != nil { + t.Fatal(err) + } + db.VersionTable = "whatsmeow_version" + db.UpgradeTable = Table + if err = db.Upgrade(t.Context()); err != nil { + t.Fatal(err) + } + if state.version != 20 || state.compatVersion != 8 { + t.Fatalf("schema version = %d/%d, want 20/8", state.version, state.compatVersion) + } + for _, query := range state.executed { + if strings.Contains(query, "ALTER TABLE whatsmeow_device ADD COLUMN is_hosted BOOLEAN NOT NULL DEFAULT false;") { + return + } + } + t.Fatalf("hosted account migration was not executed: %q", state.executed) +} diff --git a/store/store.go b/store/store.go index d05be9fa4..af14dd195 100644 --- a/store/store.go +++ b/store/store.go @@ -265,6 +265,11 @@ type Device struct { LIDMigrationTimestamp int64 CompanionMetaNonce string + // IsHosted is true when this companion was paired to a hosted (Cloud API + // coexistence) account, i.e. the pair-success ADV identity used + // accountType HOSTED. WhatsApp Web stores the same fact as isHostedMeAccount. + IsHosted bool + FacebookUUID uuid.UUID Initialized bool diff --git a/types/events/events.go b/types/events/events.go index f3bb28894..4e0d864cf 100644 --- a/types/events/events.go +++ b/types/events/events.go @@ -57,6 +57,10 @@ type PairSuccess struct { BusinessName string Platform string Props *waCompanionReg.ClientPairingProps + // IsHosted is true when the account is hosted (Cloud API coexistence) and + // the ADV identity was signed with the hosted prefix. Also persisted as + // store.Device.IsHosted. + IsHosted bool } // PairError is emitted when a pair-success event is received from the server, but finishing the pairing locally fails. From 421d402fc986a1e81066d373ebb444c7e454b21b Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 28 Oct 2026 02:11:15 +0200 Subject: [PATCH 02/10] Parse business AEA account type Business profile (w:biz) and usync business results carry inside the business node (WAWebQueryBusinessProfileJob:14-25, WAWebUsyncBusiness:10-20, WAWebAeaAccountState:6-11, bundle whatsapp-1051908825). Add types.AEAAccountType with the four values and IsAEA, and expose it on BusinessProfile, UserInfo and IsOnWhatsAppResponse. GetUserInfo also now returns the verified name it already parsed, which it previously dropped. --- business_identity_test.go | 86 +++++++++++++++++++++++++++++++++++++++ pair_hosted_test.go | 2 +- types/user.go | 36 +++++++++++++--- user.go | 20 +++++++++ 4 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 business_identity_test.go diff --git a/business_identity_test.go b/business_identity_test.go new file mode 100644 index 000000000..8ccbbb932 --- /dev/null +++ b/business_identity_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + +package whatsmeow + +import ( + "testing" + + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/types" +) + +func TestParseBusinessProfileAEAAccountType(t *testing.T) { + jid := types.NewJID("15551234567", types.DefaultUserServer) + for _, value := range []types.AEAAccountType{ + types.AEAAccountTypeNotSet, + types.AEAAccountTypeE2EE, + types.AEAAccountTypeHybridE2EE, + types.AEAAccountTypeNonE2EE, + } { + // ... + // (WAWebQueryBusinessProfileJob:14-25) + node := waBinary.Node{ + Tag: "business_profile", + Content: []waBinary.Node{{ + Tag: "profile", + Attrs: waBinary.Attrs{"jid": jid, "tag": "1234567890"}, + Content: []waBinary.Node{ + {Tag: "aea_account_type", Attrs: waBinary.Attrs{"value": string(value)}}, + {Tag: "description", Content: []byte("Synthetic tea shop")}, + }, + }}, + } + profile, err := (&Client{}).parseBusinessProfile(&node) + if err != nil { + t.Fatal(err) + } + if profile.AEAAccountType != value { + t.Fatalf("AEAAccountType = %q, want %q", profile.AEAAccountType, value) + } + if want := value == types.AEAAccountTypeHybridE2EE || value == types.AEAAccountTypeNonE2EE; profile.AEAAccountType.IsAEA() != want { + t.Fatalf("%q IsAEA = %v, want %v", value, !want, want) + } + } +} + +func TestParseBusinessProfileWithoutAEAAccountType(t *testing.T) { + node := waBinary.Node{ + Tag: "business_profile", + Content: []waBinary.Node{{ + Tag: "profile", + Attrs: waBinary.Attrs{"jid": types.NewJID("15551234567", types.DefaultUserServer)}, + }}, + } + profile, err := (&Client{}).parseBusinessProfile(&node) + if err != nil { + t.Fatal(err) + } + if profile.AEAAccountType != types.AEAAccountTypeUnknown { + t.Fatalf("AEAAccountType = %q, want empty", profile.AEAAccountType) + } +} + +func TestParseUSyncAEAAccountType(t *testing.T) { + // + // (WAWebUsyncBusiness:10-20) + business := waBinary.Node{ + Tag: "business", + Content: []waBinary.Node{ + {Tag: "verified_name"}, + {Tag: "profile", Content: []waBinary.Node{ + {Tag: "aea_account_type", Attrs: waBinary.Attrs{"value": "hybrid_e2ee"}}, + }}, + }, + } + if got := parseUSyncAEAAccountType(business); got != types.AEAAccountTypeHybridE2EE { + t.Fatalf("usync AEA = %q, want hybrid_e2ee", got) + } + if got := parseUSyncAEAAccountType(waBinary.Node{Tag: "business"}); got != types.AEAAccountTypeUnknown { + t.Fatalf("usync AEA without profile = %q, want empty", got) + } + if got := parseUSyncAEAAccountType(waBinary.Node{}); got != types.AEAAccountTypeUnknown { + t.Fatalf("usync AEA without business = %q, want empty", got) + } +} diff --git a/pair_hosted_test.go b/pair_hosted_test.go index 65912726a..96dae2cc6 100644 --- a/pair_hosted_test.go +++ b/pair_hosted_test.go @@ -16,8 +16,8 @@ import ( "github.com/polymorfa/hypermeow/proto/waAdv" "github.com/polymorfa/hypermeow/store" "github.com/polymorfa/hypermeow/types" - waLog "github.com/polymorfa/hypermeow/util/log" "github.com/polymorfa/hypermeow/util/keys" + waLog "github.com/polymorfa/hypermeow/util/log" ) type hostedPairContainer struct { diff --git a/types/user.go b/types/user.go index b5e8305ca..ba34ac0bf 100644 --- a/types/user.go +++ b/types/user.go @@ -15,6 +15,26 @@ import ( "github.com/polymorfa/hypermeow/proto/waVnameCert" ) +// AEAAccountType is the end-to-end encryption mode of a business account, as +// reported in by business profile and usync +// business queries (WAWebAeaAccountState). +type AEAAccountType string + +const ( + AEAAccountTypeUnknown AEAAccountType = "" // Not reported by the server. + AEAAccountTypeNotSet AEAAccountType = "not_set" // The server reported no AEA mode. + AEAAccountTypeE2EE AEAAccountType = "e2ee" // Messages are end-to-end encrypted. + AEAAccountTypeHybridE2EE AEAAccountType = "hybrid_e2ee" // Hybrid: some messages are handled outside end-to-end encryption. + AEAAccountTypeNonE2EE AEAAccountType = "non_e2ee" // Messages are not end-to-end encrypted. +) + +// IsAEA reports whether the business uses an account-level encryption mode +// other than plain end-to-end encryption (hybrid_e2ee or non_e2ee), matching +// WhatsApp Web's isAeaAccount check. +func (t AEAAccountType) IsAEA() bool { + return t == AEAAccountTypeHybridE2EE || t == AEAAccountTypeNonE2EE +} + // VerifiedName contains verified WhatsApp business details. type VerifiedName struct { Certificate *waVnameCert.VerifiedNameCertificate @@ -30,11 +50,14 @@ type VerifiedName struct { // UserInfo contains info about a WhatsApp user. type UserInfo struct { VerifiedName *VerifiedName - Status string - PictureID string - Devices []JID - LID JID - Username string + // AEAAccountType is set when the usync result includes + // . + AEAAccountType AEAAccountType + Status string + PictureID string + Devices []JID + LID JID + Username string } type BotListInfo struct { @@ -103,6 +126,8 @@ type IsOnWhatsAppResponse struct { Username string VerifiedName *VerifiedName // If the phone is a business, the verified business details. + + AEAAccountType AEAAccountType // Set when the usync business result includes an AEA account type. } type UsernameResolution struct { @@ -273,6 +298,7 @@ type Category struct { // BusinessProfile contains the profile information of a WhatsApp business. type BusinessProfile struct { JID JID + AEAAccountType AEAAccountType // The business's end-to-end encryption mode, if the server reported one. Address string Email string Description string diff --git a/user.go b/user.go index b0726ac2b..b05cce90e 100644 --- a/user.go +++ b/user.go @@ -214,6 +214,7 @@ func (cli *Client) IsOnWhatsApp(ctx context.Context, phones []string) ([]types.I if err != nil { cli.Log.Warnf("Failed to parse %s's verified name details: %v", jid, err) } + info.AEAAccountType = parseUSyncAEAAccountType(child.GetChildByTag("business")) contactNode := child.GetChildByTag("contact") info.IsIn = contactNode.AttrGetter().String("type") == "in" info.Username = parseUSyncUsername(child) @@ -301,6 +302,8 @@ func (cli *Client) GetUserInfo(ctx context.Context, jids []types.JID) (map[types if err != nil { cli.Log.Warnf("Failed to parse %s's verified name details: %v", jid, err) } + info.VerifiedName = verifiedName + info.AEAAccountType = parseUSyncAEAAccountType(child.GetChildByTag("business")) status, _ := child.GetChildByTag("status").Content.([]byte) info.Status = string(status) info.PictureID, _ = child.GetChildByTag("picture").Attrs["id"].(string) @@ -540,6 +543,7 @@ func (cli *Client) parseBusinessProfile(node *waBinary.Node) (*types.BusinessPro if !ok { return nil, errors.New("missing jid in business profile") } + aeaAccountType := parseAEAAccountType(profileNode) address, _ := profileNode.GetChildByTag("address").Content.([]byte) email, _ := profileNode.GetChildByTag("email").Content.([]byte) description, _ := profileNode.GetChildByTag("description").Content.([]byte) @@ -592,6 +596,7 @@ func (cli *Client) parseBusinessProfile(node *waBinary.Node) (*types.BusinessPro } return &types.BusinessProfile{ JID: jid, + AEAAccountType: aeaAccountType, Email: string(email), Address: string(address), Description: string(description), @@ -921,6 +926,21 @@ func (cli *Client) updateBusinessName(ctx context.Context, user, userAlt types.J } } +// parseAEAAccountType reads from a business +// node (WAWebQueryBusinessProfileJob:14-25, WAWebUsyncBusiness:10-20). +func parseAEAAccountType(profileNode waBinary.Node) types.AEAAccountType { + value, _ := profileNode.GetChildByTag("aea_account_type").Attrs["value"].(string) + return types.AEAAccountType(value) +} + +// parseUSyncAEAAccountType reads the AEA account type from a usync result. +func parseUSyncAEAAccountType(businessNode waBinary.Node) types.AEAAccountType { + if businessNode.Tag != "business" { + return types.AEAAccountTypeUnknown + } + return parseAEAAccountType(businessNode.GetChildByTag("profile")) +} + func parseVerifiedName(businessNode waBinary.Node) (*types.VerifiedName, error) { if businessNode.Tag != "business" { return nil, nil From c84ccb04d97cb4d23785d1166cd70184568c9ab1 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 28 Oct 2026 02:13:05 +0200 Subject: [PATCH 03/10] Expose verified name issuer, serial and business privacy mode WhatsApp Web derives isApi/isSmb from the verified name certificate issuer (ent:wa or smb:wa), reads the serial attribute with the certificate serial as fallback, and builds a privacy mode only when actual_actors, host_storage and privacy_mode_ts are all present with known enum values (WAWebValidateBusinessCertificate:6-24, WAWebCommonParsersVerifiedName:19-32, WAWebHandleMsgTypes:2-9). Business messages carry the same privacy mode on , ignored for bot authors, and the level and serial on the message stanza (WAWebHandleMsgParser:309-327). Bundle whatsapp-1051908825. Add VerifiedName.Serial, Issuer, PrivacyMode, IsAPI and IsBusinessApp, typed issuer, actual-actor and host-storage enums, VerifiedLevel constants, and MessageInfo.BizPrivacyMode. Existing VerifiedName fields keep their values. --- business_identity_test.go | 158 ++++++++++++++++++++++++++++++++++++++ message.go | 16 ++++ types/message.go | 6 +- types/user.go | 60 ++++++++++++++- user.go | 50 ++++++++++-- 5 files changed, 283 insertions(+), 7 deletions(-) diff --git a/business_identity_test.go b/business_identity_test.go index 8ccbbb932..c226a8d18 100644 --- a/business_identity_test.go +++ b/business_identity_test.go @@ -6,9 +6,15 @@ package whatsmeow import ( "testing" + "time" + + "google.golang.org/protobuf/proto" waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/proto/waVnameCert" + "github.com/polymorfa/hypermeow/store" "github.com/polymorfa/hypermeow/types" + waLog "github.com/polymorfa/hypermeow/util/log" ) func TestParseBusinessProfileAEAAccountType(t *testing.T) { @@ -84,3 +90,155 @@ func TestParseUSyncAEAAccountType(t *testing.T) { t.Fatalf("usync AEA without business = %q, want empty", got) } } + +func buildVerifiedNameCert(t *testing.T, issuer string, serial uint64) []byte { + t.Helper() + details, err := proto.Marshal(&waVnameCert.VerifiedNameCertificate_Details{ + Serial: proto.Uint64(serial), + Issuer: proto.String(issuer), + VerifiedName: proto.String("Synthetic Tea"), + }) + if err != nil { + t.Fatal(err) + } + cert, err := proto.Marshal(&waVnameCert.VerifiedNameCertificate{Details: details}) + if err != nil { + t.Fatal(err) + } + return cert +} + +func TestParseVerifiedNameIssuerLevelSerialAndPrivacyMode(t *testing.T) { + // cert + // (WAWebCommonParsersVerifiedName:19-32, WAWebValidateBusinessCertificate:6-24) + node := waBinary.Node{ + Tag: "verified_name", + Attrs: waBinary.Attrs{ + "verified_level": "high", + "serial": "998877", + "actual_actors": "3", + "host_storage": "2", + "privacy_mode_ts": "1700000000", + "v": "2", + }, + Content: buildVerifiedNameCert(t, "ent:wa", 112233), + } + vn, err := parseVerifiedNameContent(node) + if err != nil { + t.Fatal(err) + } + if vn.Issuer != types.VerifiedNameIssuerAPI || !vn.IsAPI() || vn.IsBusinessApp() { + t.Fatalf("issuer = %q, IsAPI=%v IsBusinessApp=%v", vn.Issuer, vn.IsAPI(), vn.IsBusinessApp()) + } + if vn.VerifiedLevel != types.VerifiedLevelHigh || vn.Serial != "998877" || vn.Version != 2 { + t.Fatalf("level/serial/version = %q/%q/%d", vn.VerifiedLevel, vn.Serial, vn.Version) + } + want := types.BusinessPrivacyMode{ + ActualActors: types.BusinessActualActorsCAPI, + HostStorage: types.BusinessHostStorageFacebook, + Timestamp: time.Unix(1700000000, 0), + } + if vn.PrivacyMode == nil || *vn.PrivacyMode != want { + t.Fatalf("privacy mode = %+v, want %+v", vn.PrivacyMode, want) + } + if vn.ActualActors != 3 || vn.HostStorage != 2 || !vn.PrivacyModeTS.Equal(want.Timestamp) { + t.Fatalf("legacy fields changed: %d/%d/%v", vn.ActualActors, vn.HostStorage, vn.PrivacyModeTS) + } +} + +func TestParseVerifiedNameBusinessAppFallsBackToCertificateSerial(t *testing.T) { + node := waBinary.Node{ + Tag: "verified_name", + Attrs: waBinary.Attrs{"verified_level": "low"}, + Content: buildVerifiedNameCert(t, "smb:wa", 112233), + } + vn, err := parseVerifiedNameContent(node) + if err != nil { + t.Fatal(err) + } + if !vn.IsBusinessApp() || vn.IsAPI() || vn.Serial != "112233" || vn.VerifiedLevel != types.VerifiedLevelLow { + t.Fatalf("unexpected verified name: issuer=%q serial=%q level=%q", vn.Issuer, vn.Serial, vn.VerifiedLevel) + } + if vn.PrivacyMode != nil { + t.Fatalf("privacy mode = %+v, want nil without attributes", vn.PrivacyMode) + } +} + +func TestParseBusinessPrivacyModeRequiresKnownCompleteValues(t *testing.T) { + for _, attrs := range []waBinary.Attrs{ + {"actual_actors": "2", "host_storage": "2"}, + {"actual_actors": "2", "privacy_mode_ts": "1700000000"}, + {"actual_actors": "4", "host_storage": "2", "privacy_mode_ts": "1700000000"}, + {"actual_actors": "1", "host_storage": "3", "privacy_mode_ts": "1700000000"}, + } { + if mode := parseBusinessPrivacyMode(waBinary.Node{Tag: "biz", Attrs: attrs}); mode != nil { + t.Fatalf("attrs %v parsed as %+v, want nil", attrs, mode) + } + } +} + +func newBusinessIdentityTestClient() *Client { + own := types.NewJID("15550000000", types.DefaultUserServer) + own.Device = 3 + return NewClient(&store.Device{ID: &own, LIDs: &store.NoopStore{}}, waLog.Noop) +} + +func TestParseMessageInfoBizPrivacyModeAndVerifiedAttrs(t *testing.T) { + cli := newBusinessIdentityTestClient() + business := types.NewJID("15551234567", types.DefaultUserServer) + // cert + // (WAWebHandleMsgParser:309-327) + node := &waBinary.Node{ + Tag: "message", + Attrs: waBinary.Attrs{ + "from": business, + "id": "3EB0BIZ", + "t": "1700000100", + "type": "text", + "verified_level": "high", + "verified_name": "445566", + }, + Content: []waBinary.Node{ + {Tag: "verified_name", Content: buildVerifiedNameCert(t, "smb:wa", 112233)}, + {Tag: "biz", Attrs: waBinary.Attrs{"actual_actors": "2", "host_storage": "1", "privacy_mode_ts": "1700000000"}}, + {Tag: "enc", Attrs: waBinary.Attrs{"type": "msg", "v": "2"}}, + }, + } + info, err := cli.parseMessageInfo(node) + if err != nil { + t.Fatal(err) + } + want := types.BusinessPrivacyMode{ + ActualActors: types.BusinessActualActorsBSP, + HostStorage: types.BusinessHostStorageOnPremise, + Timestamp: time.Unix(1700000000, 0), + } + if info.BizPrivacyMode == nil || *info.BizPrivacyMode != want { + t.Fatalf("biz privacy mode = %+v, want %+v", info.BizPrivacyMode, want) + } + if info.VerifiedName == nil || info.VerifiedName.VerifiedLevel != types.VerifiedLevelHigh || info.VerifiedName.Serial != "445566" || !info.VerifiedName.IsBusinessApp() { + t.Fatalf("verified name = %+v", info.VerifiedName) + } +} + +func TestParseMessageInfoIgnoresBotBizPrivacyMode(t *testing.T) { + cli := newBusinessIdentityTestClient() + node := &waBinary.Node{ + Tag: "message", + Attrs: waBinary.Attrs{ + "from": types.NewJID("867051314767696", types.BotServer), + "id": "3EB0BOT", + "t": "1700000100", + }, + Content: []waBinary.Node{ + {Tag: "biz", Attrs: waBinary.Attrs{"actual_actors": "2", "host_storage": "1", "privacy_mode_ts": "1700000000"}}, + }, + } + info, err := cli.parseMessageInfo(node) + if err != nil { + t.Fatal(err) + } + if info.BizPrivacyMode != nil { + t.Fatalf("bot message privacy mode = %+v, want nil", info.BizPrivacyMode) + } +} diff --git a/message.go b/message.go index 53a93d9b1..d18d584ed 100644 --- a/message.go +++ b/message.go @@ -273,6 +273,11 @@ func (cli *Client) parseMessageInfo(node *waBinary.Node) (*types.MessageInfo, er if err != nil { cli.Log.Warnf("Failed to parse verified_name node in %s: %v", info.ID, err) } + case "biz": + // WAWebHandleMsgParser:316-327 ignores privacy mode on bot messages. + if !info.Sender.IsBot() { + info.BizPrivacyMode = parseBusinessPrivacyMode(child) + } case "bot": info.MsgBotInfo, err = cli.parseMsgBotInfo(child) if err != nil { @@ -293,6 +298,17 @@ func (cli *Client) parseMessageInfo(node *waBinary.Node) (*types.MessageInfo, er } } } + if info.VerifiedName != nil { + // Web reads the level and serial from the message stanza itself + // (); fill them in when the + // certificate node doesn't carry them. + if info.VerifiedName.VerifiedLevel == "" { + info.VerifiedName.VerifiedLevel = ag.OptionalString("verified_level") + } + if serial := ag.OptionalString("verified_name"); serial != "" { + info.VerifiedName.Serial = serial + } + } return &info, nil } diff --git a/types/message.go b/types/message.go index 9df138bd3..57ecfdd1c 100644 --- a/types/message.go +++ b/types/message.go @@ -113,7 +113,11 @@ type MessageInfo struct { IsNewsletterStatus bool - VerifiedName *VerifiedName + VerifiedName *VerifiedName + // BizPrivacyMode is the privacy mode from on a message from a business. It is nil when the node or + // any attribute is missing, an enum value is unknown, or the sender is a bot. + BizPrivacyMode *BusinessPrivacyMode DeviceSentMeta *DeviceSentMeta // Metadata for direct messages sent from another one of the user's own devices. } diff --git a/types/user.go b/types/user.go index ba34ac0bf..787a0adf3 100644 --- a/types/user.go +++ b/types/user.go @@ -40,11 +40,69 @@ type VerifiedName struct { Certificate *waVnameCert.VerifiedNameCertificate Details *waVnameCert.VerifiedNameCertificate_Details - VerifiedLevel string + VerifiedLevel string // One of the VerifiedLevel* constants, or empty when not reported. Version int HostStorage int ActualActors int PrivacyModeTS time.Time + + // Serial is the verified_name serial attribute, or the certificate serial when the attribute is absent. + Serial string + // Issuer is the certificate issuer: smb:wa for the Business app or ent:wa for the API. + Issuer VerifiedNameIssuer + // PrivacyMode is set only when actual_actors, host_storage and + // privacy_mode_ts are all present with known values, as in WhatsApp Web. + PrivacyMode *BusinessPrivacyMode +} + +// IsAPI reports whether the certificate was issued to a WhatsApp Business API (Cloud API or on-premises) account. +func (vn *VerifiedName) IsAPI() bool { + return vn != nil && vn.Issuer == VerifiedNameIssuerAPI +} + +// IsBusinessApp reports whether the certificate was issued to a WhatsApp Business app account. +func (vn *VerifiedName) IsBusinessApp() bool { + return vn != nil && vn.Issuer == VerifiedNameIssuerBusinessApp +} + +// Verified name levels reported in the verified_level attribute (WAWebHandleMsgCommon MSG_VERIFIED_LEVEL). +const ( + VerifiedLevelUnknown = "unknown" + VerifiedLevelLow = "low" + VerifiedLevelHigh = "high" +) + +// VerifiedNameIssuer is the issuer field of a verified name certificate (WAWebValidateBusinessCertificate). +type VerifiedNameIssuer string + +const ( + VerifiedNameIssuerBusinessApp VerifiedNameIssuer = "smb:wa" // WhatsApp Business app + VerifiedNameIssuerAPI VerifiedNameIssuer = "ent:wa" // WhatsApp Business API +) + +// BusinessActualActors says who handles a business's messages (WAWebHandleMsgTypes ActualActorsEnumType). +type BusinessActualActors int + +const ( + BusinessActualActorsSelf BusinessActualActors = 1 // The business itself. + BusinessActualActorsBSP BusinessActualActors = 2 // A business solution provider. + BusinessActualActorsCAPI BusinessActualActors = 3 // Meta's Cloud API. +) + +// BusinessHostStorage says where a business's messages are stored (WAWebHandleMsgTypes HostStorageEnumType). +type BusinessHostStorage int + +const ( + BusinessHostStorageOnPremise BusinessHostStorage = 1 + BusinessHostStorageFacebook BusinessHostStorage = 2 +) + +// BusinessPrivacyMode is the privacy mode a business reports on its verified +// name or on in messages. +type BusinessPrivacyMode struct { + ActualActors BusinessActualActors + HostStorage BusinessHostStorage + Timestamp time.Time } // UserInfo contains info about a WhatsApp user. diff --git a/user.go b/user.go index b05cce90e..0c688c229 100644 --- a/user.go +++ b/user.go @@ -12,7 +12,9 @@ import ( "errors" "fmt" "slices" + "strconv" "strings" + "time" "google.golang.org/protobuf/proto" @@ -969,18 +971,56 @@ func parseVerifiedNameContent(verifiedNameNode waBinary.Node) (*types.VerifiedNa return nil, err } ag := verifiedNameNode.AttrGetter() + serial := ag.OptionalString("serial") + if serial == "" && certDetails.Serial != nil { + serial = strconv.FormatUint(certDetails.GetSerial(), 10) + } return &types.VerifiedName{ Certificate: &cert, Details: &certDetails, - VerifiedLevel: ag.String("verified_level"), - Version: ag.Int("v"), - HostStorage: ag.Int("host_storage"), - ActualActors: ag.Int("actual_actors"), - PrivacyModeTS: ag.UnixTime("privacy_mode_ts"), + VerifiedLevel: ag.OptionalString("verified_level"), + Version: ag.OptionalInt("v"), + HostStorage: ag.OptionalInt("host_storage"), + ActualActors: ag.OptionalInt("actual_actors"), + PrivacyModeTS: ag.OptionalUnixTime("privacy_mode_ts"), + + Serial: serial, + Issuer: types.VerifiedNameIssuer(certDetails.GetIssuer()), + PrivacyMode: parseBusinessPrivacyMode(verifiedNameNode), }, nil } +// parseBusinessPrivacyMode reads actual_actors, host_storage and privacy_mode_ts +// from a or message node. Like WhatsApp Web +// (WAWebCommonParsersVerifiedName, WAWebHandleMsgParser:316-327), it returns +// nil unless all three are present and both enums have known values. +func parseBusinessPrivacyMode(node waBinary.Node) *types.BusinessPrivacyMode { + ag := node.AttrGetter() + actors, hasActors := ag.GetInt64("actual_actors", false) + storage, hasStorage := ag.GetInt64("host_storage", false) + ts, hasTS := ag.GetInt64("privacy_mode_ts", false) + if !hasActors || !hasStorage || !hasTS { + return nil + } + mode := &types.BusinessPrivacyMode{ + ActualActors: types.BusinessActualActors(actors), + HostStorage: types.BusinessHostStorage(storage), + Timestamp: time.Unix(ts, 0), + } + switch mode.ActualActors { + case types.BusinessActualActorsSelf, types.BusinessActualActorsBSP, types.BusinessActualActorsCAPI: + default: + return nil + } + switch mode.HostStorage { + case types.BusinessHostStorageOnPremise, types.BusinessHostStorageFacebook: + default: + return nil + } + return mode +} + func parseDeviceList(user types.JID, deviceNode waBinary.Node) []types.JID { deviceList := deviceNode.GetChildByTag("device-list") if deviceNode.Tag != "devices" || deviceList.Tag != "device-list" { From 6a682e4c4727de621336e84f8e67d871985d60dd Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 28 Oct 2026 02:14:17 +0200 Subject: [PATCH 04/10] Flag hosted unavailable messages means the hosted (Cloud API) side of a coexistence business sent a message this companion cannot decrypt; WhatsApp Web shows a hosted-unavailable placeholder for it (WAWebHandleMsgParser:245-248, WAWebUnavailableFanoutPlaceholderType, bundle whatsapp-1051908825). Add UndecryptableMessage.IsHostedUnavailable. The ack and phone re-request behavior is unchanged. --- business_identity_test.go | 44 +++++++++++++++++++++++++++++++++++++++ message.go | 9 +++++--- types/events/events.go | 5 +++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/business_identity_test.go b/business_identity_test.go index c226a8d18..5c0058e75 100644 --- a/business_identity_test.go +++ b/business_identity_test.go @@ -5,6 +5,9 @@ package whatsmeow import ( + "context" + "slices" + "strings" "testing" "time" @@ -14,6 +17,7 @@ import ( "github.com/polymorfa/hypermeow/proto/waVnameCert" "github.com/polymorfa/hypermeow/store" "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" waLog "github.com/polymorfa/hypermeow/util/log" ) @@ -242,3 +246,43 @@ func TestParseMessageInfoIgnoresBotBizPrivacyMode(t *testing.T) { t.Fatalf("bot message privacy mode = %+v, want nil", info.BizPrivacyMode) } } + +func TestUnavailableHostedMessageEvent(t *testing.T) { + for _, tc := range []struct { + name string + attrs waBinary.Attrs + want bool + }{ + // (WAWebHandleMsgParser:245-248) + {"hosted", waBinary.Attrs{"hosted": "true"}, true}, + {"view once", waBinary.Attrs{"type": "view_once"}, false}, + {"plain", nil, false}, + } { + t.Run(tc.name, func(t *testing.T) { + cli, _, sent := newTestRemote(t) + var got []*events.UndecryptableMessage + cli.AddEventHandler(func(evt any) { + if e, ok := evt.(*events.UndecryptableMessage); ok { + got = append(got, e) + } + }) + sender := types.NewJID("15551234567", types.DefaultUserServer) + info := &types.MessageInfo{MessageSource: types.MessageSource{Chat: sender, Sender: sender}, ID: "3EB0HOSTED"} + node := &waBinary.Node{ + Tag: "message", + Attrs: waBinary.Attrs{"id": "3EB0HOSTED", "from": sender, "t": "1700000000", "type": "text"}, + Content: []waBinary.Node{{Tag: "unavailable", Attrs: tc.attrs}}, + } + cli.decryptMessages(context.Background(), info, node) + if len(got) != 1 || !got[0].IsUnavailable { + t.Fatalf("UndecryptableMessage events = %+v, want one unavailable event", got) + } + if got[0].IsHostedUnavailable != tc.want { + t.Fatalf("IsHostedUnavailable = %v, want %v", got[0].IsHostedUnavailable, tc.want) + } + if tags := sent.tags(); !slices.ContainsFunc(tags, func(tag string) bool { return strings.HasPrefix(tag, "ack:") }) { + t.Fatalf("sent %v, want the stanza acknowledged", tags) + } + }) + } +} diff --git a/message.go b/message.go index d18d584ed..fc97b2302 100644 --- a/message.go +++ b/message.go @@ -367,13 +367,16 @@ func (cli *Client) decryptMessages(ctx context.Context, info *types.MessageInfo, }() unavailableNode, ok := node.GetOptionalChildByTag("unavailable") if ok && len(node.GetChildrenByTag("enc")) == 0 { - uType := events.UnavailableType(unavailableNode.AttrGetter().String("type")) - cli.Log.Warnf("Unavailable message %s from %s (type: %q)", info.ID, info.SourceString(), uType) + uag := unavailableNode.AttrGetter() + uType := events.UnavailableType(uag.OptionalString("type")) + // WAWebHandleMsgParser:245-248 compares the raw attribute to "true". + isHosted := uag.OptionalString("hosted") == "true" + cli.Log.Warnf("Unavailable message %s from %s (type: %q, hosted: %t)", info.ID, info.SourceString(), uType, isHosted) cli.backgroundIfAsyncAck(func() { cli.immediateRequestMessageFromPhone(ctx, info) cli.sendAck(ctx, node, 0) }) - cli.dispatchEvent(&events.UndecryptableMessage{Info: *info, IsUnavailable: true, UnavailableType: uType}) + cli.dispatchEvent(&events.UndecryptableMessage{Info: *info, IsUnavailable: true, UnavailableType: uType, IsHostedUnavailable: isHosted}) return } diff --git a/types/events/events.go b/types/events/events.go index 4e0d864cf..41a566917 100644 --- a/types/events/events.go +++ b/types/events/events.go @@ -326,6 +326,11 @@ type UndecryptableMessage struct { IsUnavailable bool // Some message types are intentionally unavailable. Such types usually have a type specified here. UnavailableType UnavailableType + // IsHostedUnavailable is true for : the hosted + // (Cloud API) side of a coexistence business sent the message, and this + // companion cannot decrypt it. WhatsApp Web shows a "hosted unavailable" + // placeholder for these. + IsHostedUnavailable bool DecryptFailMode DecryptFailMode } From 98989bc7b4533f0f1c290d438e4e478e00db102e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 28 Oct 2026 02:16:00 +0200 Subject: [PATCH 05/10] Test hosted device 99 parsing, encoding and receipts Hosted (coexistence) devices use device 99 on the hosted and hosted.lid servers (WAWebWid:20,173-174, WAWebHandleAccountSyncNotification:47-55, WAWebUsyncDevice:20, bundle whatsapp-1051908825). Cover usync device lists with is_hosted, the binary AD-JID round trip, and receipts from a hosted device. The existing handling was correct; no behavior change. --- business_identity_test.go | 71 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/business_identity_test.go b/business_identity_test.go index 5c0058e75..01fe7d439 100644 --- a/business_identity_test.go +++ b/business_identity_test.go @@ -286,3 +286,74 @@ func TestUnavailableHostedMessageEvent(t *testing.T) { }) } } + +func TestParseDeviceListHostedDevice(t *testing.T) { + // + // (WAWebUsyncDevice:20, WAWebWid:20,173-174) + devices := waBinary.Node{ + Tag: "devices", + Content: []waBinary.Node{{ + Tag: "device-list", + Content: []waBinary.Node{ + {Tag: "device", Attrs: waBinary.Attrs{"id": "0"}}, + {Tag: "device", Attrs: waBinary.Attrs{"id": "99", "is_hosted": "true"}}, + }, + }}, + } + pn := types.NewJID("15551234567", types.DefaultUserServer) + got := parseDeviceList(pn, devices) + want := []types.JID{ + {User: pn.User, Server: types.DefaultUserServer}, + {User: pn.User, Device: 99, Server: types.HostedServer}, + } + if !slices.Equal(got, want) { + t.Fatalf("PN devices = %v, want %v", got, want) + } + lid := types.NewJID("123456789012345", types.HiddenUserServer) + got = parseDeviceList(lid, devices) + if len(got) != 2 || got[1] != (types.JID{User: lid.User, Device: 99, Server: types.HostedLIDServer}) { + t.Fatalf("LID devices = %v", got) + } +} + +func TestHostedDeviceJIDRoundTripsThroughBinary(t *testing.T) { + for _, jid := range []types.JID{ + {User: "15551234567", Device: 99, Server: types.HostedServer}, + {User: "123456789012345", Device: 99, Server: types.HostedLIDServer}, + } { + encoded, err := waBinary.Marshal(waBinary.Node{Tag: "to", Attrs: waBinary.Attrs{"jid": jid}}) + if err != nil { + t.Fatal(err) + } + decoded, err := waBinary.Unmarshal(encoded[1:]) + if err != nil { + t.Fatal(err) + } + if got := decoded.AttrGetter().JID("jid"); got != jid { + t.Fatalf("round trip = %#v, want %#v", got, jid) + } + if got := jid.String(); got != jid.User+":99@"+jid.Server { + t.Fatalf("String() = %q", got) + } + } +} + +func TestParseReceiptFromHostedDevice(t *testing.T) { + cli := newBusinessIdentityTestClient() + hosted := types.JID{User: "15551234567", Device: 99, Server: types.HostedServer} + receipt, _, err := cli.parseReceipt(&waBinary.Node{ + Tag: "receipt", + Attrs: waBinary.Attrs{"from": hosted, "id": "3EB0SENT", "t": "1700000000", "type": "read"}, + }) + if err != nil { + t.Fatal(err) + } + // Incoming hosted JIDs are normalized to the account's regular server and keep device 99. + want := types.JID{User: hosted.User, Device: 99, Server: types.DefaultUserServer} + if receipt.Sender != want || receipt.Chat != want.ToNonAD() { + t.Fatalf("receipt source = %v in %v, want %v in %v", receipt.Sender, receipt.Chat, want, want.ToNonAD()) + } + if len(receipt.MessageIDs) != 1 || receipt.MessageIDs[0] != "3EB0SENT" || receipt.Type != types.ReceiptTypeRead { + t.Fatalf("unexpected receipt: %+v", receipt) + } +} From 870f16c45de37609b39d64b6fa525778637be1bb Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 28 Oct 2026 02:16:44 +0200 Subject: [PATCH 06/10] Document business identity fields and schema v20 --- CHANGELOG.md | 8 ++++++++ README.md | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70f32f06c..97e5875e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,16 @@ All notable HyperMeow changes are documented here. HyperMeow uses commit pseudo- ## [Unreleased] +### Added + +- Persist hosted (coexistence) pairing as `store.Device.IsHosted` and report it on `events.PairSuccess.IsHosted`. SQL-backed stores upgrade to schema version 20, which adds `whatsmeow_device.is_hosted` (default `false`). +- Add `types.AEAAccountType` (`not_set`, `e2ee`, `hybrid_e2ee`, `non_e2ee`) on `BusinessProfile`, `UserInfo` and `IsOnWhatsAppResponse`, read from `` in business profile and usync business results. +- Add `VerifiedName.Serial`, `Issuer`, `PrivacyMode`, `IsAPI` and `IsBusinessApp`, typed actual-actor and host-storage values, and `MessageInfo.BizPrivacyMode` from `` on business messages. +- Add `UndecryptableMessage.IsHostedUnavailable` for ``. + ### Fixed +- `GetUserInfo` returns the parsed verified business name in `UserInfo.VerifiedName`. - Stop automatic reconnect and emit `CATRefreshError` to generic and connection-scoped observers when a 413/414 failure cannot refresh its token, including when `RefreshCAT` is unset. The missing callback returns `ErrCATRefreshUnavailable` instead of panicking or retrying unchanged credentials. - Protect the upstream unified-session interval check during concurrent sends. - Parse newsletter live updates inside `messages` wrappers and retain forward counts, while preserving the legacy unwrapped form. diff --git a/README.md b/README.md index 8f13134c6..1920602c1 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,8 @@ decode error. Custom `store.ChatSettingsStore` implementations must add `PutWASARootSecretID` and `GetWASARootSecretID` for bot root-secret state. `sqlstore` adds this field -through schema version 19; its existing migrations remain unchanged. +through schema version 19; its existing migrations remain unchanged. Schema +version 20 adds the device `is_hosted` flag for hosted (coexistence) pairing. ## Discord From cce05486e2ca38aa43576bb76b5a6b55a327ec47 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 28 Oct 2026 02:26:05 +0200 Subject: [PATCH 07/10] Use the hosted Signal address for hosted device 99 WhatsApp Web addresses a hosted device's Signal session as :99@hosted.lid when the account's LID is known and :99@hosted otherwise, distinct from a regular :99@lid or :99@c.us device (WAWebSignalAddress:14-33, bundle whatsapp-1051908825). HyperMeow normalized incoming hosted senders to s.whatsapp.net/lid before choosing the decryption address, so a message from X:99@hosted.lid used the regular X:99@lid session while sends to the same device used the hosted one. Retry receipts from hosted devices had the same mismatch. Add hostedSignalJID and rawHostedSender and use them when decrypting, in send fanout, and when answering local and remote retry receipts. A PN hosted address moves its sessions to the LID hosted address once the LID is known. MessageInfo.Sender keeps its existing normalization. JID parsing, JID constructors and LID-map storage are unchanged; the helper only reads the existing GetLIDForPN lookup. The test runs a real in-memory prekey exchange from X:99@hosted.lid and a regular X:99@lid sender, a reply through encryptMessageForDevices and a retry receipt, and checks that the two sessions stay separate. --- hosted_signal.go | 60 ++++++ hosted_signal_test.go | 433 ++++++++++++++++++++++++++++++++++++++++++ message.go | 4 +- remote_signal.go | 4 +- retry.go | 23 ++- send.go | 4 +- 6 files changed, 518 insertions(+), 10 deletions(-) create mode 100644 hosted_signal.go create mode 100644 hosted_signal_test.go diff --git a/hosted_signal.go b/hosted_signal.go new file mode 100644 index 000000000..1a98ac5b7 --- /dev/null +++ b/hosted_signal.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + +package whatsmeow + +import ( + "context" + + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/types" +) + +// HostedDeviceID is the device ID of a hosted (Cloud API coexistence) device +// on the hosted and hosted.lid servers (WAWebWid:20, WAWebBizCoexUtils). +const HostedDeviceID = 99 + +func isHostedJID(jid types.JID) bool { + return jid.Server == types.HostedServer || jid.Server == types.HostedLIDServer +} + +// rawHostedSender returns the hosted device that sent an incoming stanza, read +// from the raw participant or from attribute. parseMessageSource normalizes +// hosted senders to the regular servers for the public MessageInfo, which +// loses the distinction the Signal address needs. +func rawHostedSender(node *waBinary.Node) (types.JID, bool) { + ag := node.AttrGetter() + jid := ag.OptionalJIDOrEmpty("participant") + if jid.IsEmpty() { + jid = ag.OptionalJIDOrEmpty("from") + } + if !isHostedJID(jid) { + return types.EmptyJID, false + } + return jid, true +} + +// hostedSignalJID returns the Signal identity of a hosted device. Like +// WAWebSignalAddress:14-33 it is :99@hosted.lid when the account's LID is +// known and :99@hosted otherwise, so its sessions never share an address +// with a regular device 99 (:99@lid or :99@s.whatsapp.net). Sessions +// already stored under the PN hosted address move to the LID hosted address. +func (cli *Client) hostedSignalJID(ctx context.Context, jid types.JID) types.JID { + if jid.Device != HostedDeviceID { + cli.Log.Warnf("Hosted JID %s does not use device %d", jid, HostedDeviceID) + } + if jid.Server != types.HostedServer { + return jid + } + lid, err := cli.Store.LIDs.GetLIDForPN(ctx, types.JID{User: jid.User, Server: types.DefaultUserServer}) + if err != nil { + cli.Log.Warnf("Failed to get LID for hosted device %s: %v", jid, err) + return jid + } else if lid.IsEmpty() { + return jid + } + hostedLID := types.JID{User: lid.User, Device: jid.Device, Server: types.HostedLIDServer} + cli.migrateSessionStore(ctx, jid, hostedLID) + return hostedLID +} diff --git a/hosted_signal_test.go b/hosted_signal_test.go new file mode 100644 index 000000000..ccb206abd --- /dev/null +++ b/hosted_signal_test.go @@ -0,0 +1,433 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + +package whatsmeow + +import ( + "context" + "slices" + "sync" + "testing" + + "github.com/polymorfa/libsignal-protocol-go/ecc" + "github.com/polymorfa/libsignal-protocol-go/keys/identity" + "github.com/polymorfa/libsignal-protocol-go/keys/prekey" + "github.com/polymorfa/libsignal-protocol-go/protocol" + "github.com/polymorfa/libsignal-protocol-go/session" + "github.com/polymorfa/libsignal-protocol-go/util/optional" + "google.golang.org/protobuf/proto" + + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/proto/waE2E" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" + "github.com/polymorfa/hypermeow/util/keys" + waLog "github.com/polymorfa/hypermeow/util/log" +) + +// memSignalStore keeps the Signal state needed for a real prekey exchange in memory. +type memSignalStore struct { + store.NoopStore + lock sync.Mutex + sessions map[string][]byte + identities map[string][32]byte + preKeys map[uint32]*keys.PreKey + nextPreKey uint32 +} + +func newMemSignalStore() *memSignalStore { + return &memSignalStore{ + sessions: make(map[string][]byte), + identities: make(map[string][32]byte), + preKeys: make(map[uint32]*keys.PreKey), + nextPreKey: 1, + } +} + +func (m *memSignalStore) PutIdentity(_ context.Context, address string, key [32]byte) error { + m.lock.Lock() + defer m.lock.Unlock() + m.identities[address] = key + return nil +} + +func (m *memSignalStore) IsTrustedIdentity(context.Context, string, [32]byte) (bool, error) { + return true, nil +} + +func (m *memSignalStore) GetSession(_ context.Context, address string) ([]byte, error) { + m.lock.Lock() + defer m.lock.Unlock() + return m.sessions[address], nil +} + +func (m *memSignalStore) HasSession(_ context.Context, address string) (bool, error) { + m.lock.Lock() + defer m.lock.Unlock() + _, ok := m.sessions[address] + return ok, nil +} + +func (m *memSignalStore) GetManySessions(_ context.Context, addresses []string) (map[string][]byte, error) { + m.lock.Lock() + defer m.lock.Unlock() + out := make(map[string][]byte, len(addresses)) + for _, addr := range addresses { + out[addr] = m.sessions[addr] + } + return out, nil +} + +func (m *memSignalStore) PutSession(_ context.Context, address string, sess []byte) error { + m.lock.Lock() + defer m.lock.Unlock() + m.sessions[address] = sess + return nil +} + +func (m *memSignalStore) PutManySessions(_ context.Context, sessions map[string][]byte) error { + m.lock.Lock() + defer m.lock.Unlock() + for addr, sess := range sessions { + m.sessions[addr] = sess + } + return nil +} + +func (m *memSignalStore) DeleteSession(_ context.Context, address string) error { + m.lock.Lock() + defer m.lock.Unlock() + delete(m.sessions, address) + return nil +} + +func (m *memSignalStore) GenOnePreKey(context.Context) (*keys.PreKey, error) { + m.lock.Lock() + defer m.lock.Unlock() + key := keys.NewPreKey(m.nextPreKey) + m.preKeys[key.KeyID] = key + m.nextPreKey++ + return key, nil +} + +func (m *memSignalStore) GetPreKey(_ context.Context, id uint32) (*keys.PreKey, error) { + m.lock.Lock() + defer m.lock.Unlock() + return m.preKeys[id], nil +} + +func (m *memSignalStore) RemovePreKey(_ context.Context, id uint32) error { + m.lock.Lock() + defer m.lock.Unlock() + delete(m.preKeys, id) + return nil +} + +func (m *memSignalStore) sessionAddresses() []string { + m.lock.Lock() + defer m.lock.Unlock() + out := make([]string, 0, len(m.sessions)) + for addr := range m.sessions { + out = append(out, addr) + } + slices.Sort(out) + return out +} + +// lidStub maps one PN user to one LID user. +type lidStub struct { + store.NoopStore + pn, lid types.JID +} + +func (l *lidStub) GetLIDForPN(_ context.Context, pn types.JID) (types.JID, error) { + if pn.User == l.pn.User { + return types.JID{User: l.lid.User, Device: pn.Device, Server: types.HiddenUserServer}, nil + } + return types.EmptyJID, nil +} + +func newSignalTestDevice(id types.JID, lids store.LIDStore) (*store.Device, *memSignalStore) { + identityKey := keys.NewKeyPair() + mem := newMemSignalStore() + device := &store.Device{ + NoiseKey: keys.NewKeyPair(), + IdentityKey: identityKey, + SignedPreKey: identityKey.CreateSignedPreKey(1), + RegistrationID: 4321, + ID: &id, + LIDs: lids, + } + device.SetAllStores(mem) + return device, mem +} + +func signalTestBundle(t *testing.T, device *store.Device, deviceID uint32) *prekey.Bundle { + t.Helper() + preKey, err := device.PreKeys.GenOnePreKey(context.Background()) + if err != nil { + t.Fatal(err) + } + return prekey.NewBundle(device.RegistrationID, deviceID, + optional.NewOptionalUint32(preKey.KeyID), device.SignedPreKey.KeyID, + ecc.NewDjbECPublicKey(*preKey.Pub), ecc.NewDjbECPublicKey(*device.SignedPreKey.Pub), *device.SignedPreKey.Signature, + identity.NewKey(ecc.NewDjbECPublicKey(*device.IdentityKey.Pub))) +} + +type signalTestPeer struct { + device *store.Device + builder *session.Builder + cipher *session.Cipher +} + +// newSignalTestPeer starts a Signal session from a remote device to the companion. +func newSignalTestPeer(t *testing.T, companion *store.Device, peerID types.JID) *signalTestPeer { + t.Helper() + device, _ := newSignalTestDevice(peerID, &store.NoopStore{}) + companionAddr := companion.ID.SignalAddress() + builder := session.NewBuilderFromSignal(device, companionAddr, pbSerializer) + if err := builder.ProcessBundle(context.Background(), signalTestBundle(t, companion, uint32(companion.ID.Device))); err != nil { + t.Fatal(err) + } + return &signalTestPeer{device: device, builder: builder, cipher: session.NewCipher(builder, companionAddr)} +} + +func (p *signalTestPeer) encrypt(t *testing.T, text string) (string, []byte) { + t.Helper() + plaintext, err := proto.Marshal(&waE2E.Message{Conversation: proto.String(text)}) + if err != nil { + t.Fatal(err) + } + ciphertext, err := p.cipher.Encrypt(context.Background(), padMessage(plaintext)) + if err != nil { + t.Fatal(err) + } + encType := "msg" + if ciphertext.Type() == protocol.PREKEY_TYPE { + encType = "pkmsg" + } + return encType, ciphertext.Serialize() +} + +func deliverSignalTestMessage(t *testing.T, cli *Client, from types.JID, id, encType string, content []byte) *types.MessageInfo { + t.Helper() + node := &waBinary.Node{ + Tag: "message", + Attrs: waBinary.Attrs{"from": from, "id": id, "t": "1700000000", "type": "text"}, + Content: []waBinary.Node{{ + Tag: "enc", + Attrs: waBinary.Attrs{"type": encType, "v": "2"}, + Content: content, + }}, + } + info, err := cli.parseMessageInfo(node) + if err != nil { + t.Fatal(err) + } + cli.decryptMessages(context.Background(), info, node) + return info +} + +func TestHostedSenderDecryptsUnderHostedSignalAddress(t *testing.T) { + ownPN := types.JID{User: "15550000000", Device: 7, Server: types.DefaultUserServer} + ownLID := types.JID{User: "100000000000000", Device: 7, Server: types.HiddenUserServer} + business := types.NewJID("15551234567", types.DefaultUserServer) + businessLID := types.NewJID("123456789012345", types.HiddenUserServer) + lids := &lidStub{pn: business, lid: businessLID} + companion, mem := newSignalTestDevice(ownPN, lids) + companion.LID = ownLID + cli := NewClient(companion, waLog.Noop) + cli.SynchronousAck = true + sent := &sentNodes{} + cli.testSendHook = sent.hook + + var got []string + var undecryptable int + cli.AddEventHandler(func(evt any) { + switch e := evt.(type) { + case *events.Message: + got = append(got, e.Message.GetConversation()) + case *events.UndecryptableMessage: + undecryptable++ + } + }) + + hostedLID := types.JID{User: businessLID.User, Device: HostedDeviceID, Server: types.HostedLIDServer} + regularLID := types.JID{User: businessLID.User, Device: HostedDeviceID, Server: types.HiddenUserServer} + hosted := newSignalTestPeer(t, companion, hostedLID) + regular := newSignalTestPeer(t, companion, regularLID) + + encType, content := hosted.encrypt(t, "hosted 1") + if encType != "pkmsg" { + t.Fatalf("first hosted message type = %s, want pkmsg", encType) + } + info := deliverSignalTestMessage(t, cli, hostedLID, "3EB0H1", encType, content) + // The public sender keeps the existing normalization. + if info.Sender != regularLID { + t.Fatalf("MessageInfo.Sender = %s, want %s", info.Sender, regularLID) + } + encType, content = regular.encrypt(t, "regular 1") + deliverSignalTestMessage(t, cli, regularLID, "3EB0R1", encType, content) + + // The companion replies through the send fanout path, addressed to the PN + // hosted device from a device list. It must reuse the hosted session. + hostedPN := types.JID{User: business.User, Device: HostedDeviceID, Server: types.HostedServer} + reply, err := proto.Marshal(&waE2E.Message{Conversation: proto.String("reply")}) + if err != nil { + t.Fatal(err) + } + participants, _, err := cli.encryptMessageForDevices(context.Background(), []types.JID{hostedPN}, "3EB0REPLY", reply, nil, waBinary.Attrs{}) + if err != nil { + t.Fatal(err) + } + if len(participants) != 1 || participants[0].AttrGetter().JID("jid") != hostedPN { + t.Fatalf("reply participants = %v", participants) + } + enc := participants[0].GetChildByTag("enc") + if enc.AttrGetter().String("type") != "msg" { + t.Fatalf("reply enc type = %v, want msg on the existing hosted session", enc.Attrs["type"]) + } + replyMsg, err := protocol.NewSignalMessageFromBytes(enc.Content.([]byte), pbSerializer.SignalMessage) + if err != nil { + t.Fatal(err) + } + if _, err = hosted.cipher.Decrypt(context.Background(), replyMsg); err != nil { + t.Fatalf("hosted peer could not decrypt the reply: %v", err) + } + + // After the reply, the hosted peer sends a normal msg. + encType, content = hosted.encrypt(t, "hosted 2") + if encType != "msg" { + t.Fatalf("second hosted message type = %s, want msg", encType) + } + deliverSignalTestMessage(t, cli, hostedLID, "3EB0H2", encType, content) + encType, content = regular.encrypt(t, "regular 2") + deliverSignalTestMessage(t, cli, regularLID, "3EB0R2", encType, content) + + if undecryptable != 0 { + t.Fatalf("got %d undecryptable messages", undecryptable) + } + if want := []string{"hosted 1", "regular 1", "hosted 2", "regular 2"}; !slices.Equal(got, want) { + t.Fatalf("decrypted = %q, want %q", got, want) + } + wantAddrs := []string{hostedLID.SignalAddress().String(), regularLID.SignalAddress().String()} + slices.Sort(wantAddrs) + if addrs := mem.sessionAddresses(); !slices.Equal(addrs, wantAddrs) { + t.Fatalf("session addresses = %v, want %v", addrs, wantAddrs) + } + if wantAddrs[0] == wantAddrs[1] { + t.Fatal("hosted and regular device 99 share a Signal address") + } + + // A retry receipt from the hosted device is answered on the hosted session + // and addressed to the raw hosted JID. + retried, err := proto.Marshal(&waE2E.Message{Conversation: proto.String("retried")}) + if err != nil { + t.Fatal(err) + } + var retriedMsg waE2E.Message + if err = proto.Unmarshal(retried, &retriedMsg); err != nil { + t.Fatal(err) + } + retryNode := &waBinary.Node{ + Tag: "receipt", + Attrs: waBinary.Attrs{"from": hostedLID, "id": "3EB0OUT", "t": "1700000200", "type": "retry"}, + Content: []waBinary.Node{ + {Tag: "retry", Attrs: waBinary.Attrs{"count": "1", "id": "3EB0OUT", "t": "1700000100", "v": "1"}}, + }, + } + receipt, _, err := cli.parseReceipt(retryNode) + if err != nil { + t.Fatal(err) + } + if err = cli.addRecentMessage(context.Background(), receipt.Chat, "3EB0OUT", &retriedMsg, nil); err != nil { + t.Fatal(err) + } + before := len(sent.nodes) + if err = cli.handleRetryReceipt(context.Background(), receipt, retryNode); err != nil { + t.Fatal(err) + } + var resent *waBinary.Node + for _, node := range sent.nodes[before:] { + if node.Tag == "message" { + resent = node + } + } + if resent == nil || resent.AttrGetter().JID("to") != hostedLID { + t.Fatalf("retry message = %v, want one addressed to %s", resent, hostedLID) + } + retryEnc := resent.GetChildByTag("enc") + if retryEnc.AttrGetter().String("type") != "msg" { + t.Fatalf("retry enc type = %v, want msg on the hosted session", retryEnc.Attrs["type"]) + } + retryMsg, err := protocol.NewSignalMessageFromBytes(retryEnc.Content.([]byte), pbSerializer.SignalMessage) + if err != nil { + t.Fatal(err) + } + padded, err := hosted.cipher.Decrypt(context.Background(), retryMsg) + if err != nil { + t.Fatalf("hosted peer could not decrypt the retry: %v", err) + } + plain, err := unpadMessage(padded, 2) + if err != nil { + t.Fatal(err) + } + var got2 waE2E.Message + if err = proto.Unmarshal(plain, &got2); err != nil || got2.GetConversation() != "retried" { + t.Fatalf("retry plaintext = %v, %v", got2.GetConversation(), err) + } +} + +func TestHostedSignalJIDMatchesWebAddressing(t *testing.T) { + business := types.NewJID("15551234567", types.DefaultUserServer) + businessLID := types.NewJID("123456789012345", types.HiddenUserServer) + hostedPN := types.JID{User: business.User, Device: HostedDeviceID, Server: types.HostedServer} + hostedLID := types.JID{User: businessLID.User, Device: HostedDeviceID, Server: types.HostedLIDServer} + ownPN := types.JID{User: "15550000000", Device: 7, Server: types.DefaultUserServer} + + withLID, _ := newSignalTestDevice(ownPN, &lidStub{pn: business, lid: businessLID}) + cli := NewClient(withLID, waLog.Noop) + // WAWebSignalAddress:14-33: :99@hosted.lid when the LID is known. + if got := cli.hostedSignalJID(context.Background(), hostedPN); got != hostedLID { + t.Fatalf("hosted PN with known LID = %s, want %s", got, hostedLID) + } + if got := cli.hostedSignalJID(context.Background(), hostedLID); got != hostedLID { + t.Fatalf("hosted LID = %s, want %s", got, hostedLID) + } + withoutLID, _ := newSignalTestDevice(ownPN, &store.NoopStore{}) + cli = NewClient(withoutLID, waLog.Noop) + // ...and :99@hosted otherwise. + if got := cli.hostedSignalJID(context.Background(), hostedPN); got != hostedPN { + t.Fatalf("hosted PN without LID = %s, want %s", got, hostedPN) + } + for _, regular := range []types.JID{ + {User: businessLID.User, Device: HostedDeviceID, Server: types.HiddenUserServer}, + {User: business.User, Device: HostedDeviceID, Server: types.DefaultUserServer}, + } { + for _, hosted := range []types.JID{hostedPN, hostedLID} { + if regular.SignalAddress().String() == hosted.SignalAddress().String() { + t.Fatalf("%s and %s share Signal address %s", regular, hosted, regular.SignalAddress()) + } + } + } +} + +func TestRawHostedSender(t *testing.T) { + hosted := types.JID{User: "123456789012345", Device: HostedDeviceID, Server: types.HostedLIDServer} + for _, tc := range []struct { + attrs waBinary.Attrs + want types.JID + ok bool + }{ + {waBinary.Attrs{"from": hosted}, hosted, true}, + {waBinary.Attrs{"from": types.NewJID("1203630000", types.GroupServer), "participant": hosted}, hosted, true}, + {waBinary.Attrs{"from": types.JID{User: hosted.User, Device: 99, Server: types.HiddenUserServer}}, types.EmptyJID, false}, + } { + got, ok := rawHostedSender(&waBinary.Node{Tag: "message", Attrs: tc.attrs}) + if got != tc.want || ok != tc.ok { + t.Fatalf("rawHostedSender(%v) = %s, %v; want %s, %v", tc.attrs, got, ok, tc.want, tc.ok) + } + } +} diff --git a/message.go b/message.go index fc97b2302..e68497ee5 100644 --- a/message.go +++ b/message.go @@ -384,7 +384,9 @@ func (cli *Client) decryptMessages(ctx context.Context, info *types.MessageInfo, cli.Log.Debugf("Decrypting message from %s", info.SourceString()) containsDirectMsg := false senderEncryptionJID := info.Sender - if info.Sender.Server == types.DefaultUserServer && !info.Sender.IsBot() { + if hostedSender, ok := rawHostedSender(node); ok { + senderEncryptionJID = cli.hostedSignalJID(ctx, hostedSender) + } else if info.Sender.Server == types.DefaultUserServer && !info.Sender.IsBot() { if info.SenderAlt.Server == types.HiddenUserServer { senderEncryptionJID = info.SenderAlt cli.migrateSessionStore(ctx, info.Sender, info.SenderAlt) diff --git a/remote_signal.go b/remote_signal.go index 26c2b8041..e6c22d45b 100644 --- a/remote_signal.go +++ b/remote_signal.go @@ -521,7 +521,9 @@ func (cli *Client) handleRetryReceiptRemote(ctx context.Context, receipt *events } req.Bundle = bundle } - if receipt.Sender.Server == types.DefaultUserServer { + if hostedSender, ok := rawHostedSender(node); ok { + req.EncryptionJID = cli.hostedSignalJID(ctx, hostedSender) + } else if receipt.Sender.Server == types.DefaultUserServer { if lid, err := cli.Store.LIDs.GetLIDForPN(ctx, receipt.Sender); err != nil { cli.Log.Warnf("Failed to get LID for %s: %v", receipt.Sender, err) } else if !lid.IsEmpty() { diff --git a/retry.go b/retry.go index 89463d5d5..f4838ea0c 100644 --- a/retry.go +++ b/retry.go @@ -355,25 +355,32 @@ func (cli *Client) handleRetryReceipt(ctx context.Context, receipt *events.Recei frankingHash.Write(plaintext) frankingTag = frankingHash.Sum(nil) } + // A hosted device keeps its own Signal address; receipt.Sender is normalized to the regular server. + retryDevice, sessionJID := receipt.Sender, receipt.Sender + hostedSender, isHostedSender := rawHostedSender(node) + if isHostedSender { + retryDevice = hostedSender + sessionJID = cli.hostedSignalJID(ctx, hostedSender) + } _, hasKeys := node.GetOptionalChildByTag("keys") var bundle *prekey.Bundle if hasKeys { - bundle, err = nodeToPreKeyBundle(uint32(receipt.Sender.Device), *node) + bundle, err = nodeToPreKeyBundle(uint32(retryDevice.Device), *node) if err != nil { return fmt.Errorf("failed to read prekey bundle in retry receipt: %w", err) } - } else if reason, recreate := cli.shouldRecreateSession(ctx, retryCount, receipt.Sender); recreate { - cli.Log.Debugf("Fetching prekeys for %s for handling retry receipt with no prekey bundle because %s", receipt.Sender, reason) + } else if reason, recreate := cli.shouldRecreateSession(ctx, retryCount, sessionJID); recreate { + cli.Log.Debugf("Fetching prekeys for %s for handling retry receipt with no prekey bundle because %s", retryDevice, reason) var keys map[types.JID]preKeyResp - keys, err = cli.fetchPreKeys(ctx, []types.JID{receipt.Sender}) + keys, err = cli.fetchPreKeys(ctx, []types.JID{retryDevice}) if err != nil { return err } - bundle, err = keys[receipt.Sender].bundle, keys[receipt.Sender].err + bundle, err = keys[retryDevice].bundle, keys[retryDevice].err if err != nil { return fmt.Errorf("failed to fetch prekeys: %w", err) } else if bundle == nil { - return fmt.Errorf("didn't get prekey bundle for %s (response size: %d)", receipt.Sender, len(keys)) + return fmt.Errorf("didn't get prekey bundle for %s (response size: %d)", retryDevice, len(keys)) } } encAttrs := waBinary.Attrs{} @@ -393,7 +400,9 @@ func (cli *Client) handleRetryReceipt(ctx context.Context, receipt *events.Recei var includeDeviceIdentity bool if msg.wa != nil { encryptionIdentity := receipt.Sender - if receipt.Sender.Server == types.DefaultUserServer { + if isHostedSender { + encryptionIdentity = sessionJID + } else if receipt.Sender.Server == types.DefaultUserServer { lidForPN, err := cli.Store.LIDs.GetLIDForPN(ctx, receipt.Sender) if err != nil { cli.Log.Warnf("Failed to get LID for %s: %v", receipt.Sender, err) diff --git a/send.go b/send.go index a32338fdb..77624f2d5 100644 --- a/send.go +++ b/send.go @@ -1376,7 +1376,9 @@ func (cli *Client) encryptMessageForDevices( continue } encryptionIdentity := jid - if jid.Server == types.DefaultUserServer { + if isHostedJID(jid) { + encryptionIdentity = cli.hostedSignalJID(ctx, jid) + } else if jid.Server == types.DefaultUserServer { // TODO query LID from server for missing entries if lidForPN, ok := lidMappings[jid]; ok && !lidForPN.IsEmpty() { cli.migrateSessionStore(ctx, jid, lidForPN) From c84c0f3fed87effb7f272d05ad56c83f185bff77 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 28 Oct 2026 02:26:05 +0200 Subject: [PATCH 08/10] Test hosted device fanout filtering prepareMessageNode keeps a hosted device 99 in 1:1 fanout and drops it from group fanout (send.go). Cover both with in-memory Signal sessions so no prekey IQ is needed. --- hosted_fanout_test.go | 83 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 hosted_fanout_test.go diff --git a/hosted_fanout_test.go b/hosted_fanout_test.go new file mode 100644 index 000000000..b21f3a523 --- /dev/null +++ b/hosted_fanout_test.go @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + +package whatsmeow + +import ( + "context" + "testing" + + "github.com/polymorfa/libsignal-protocol-go/session" + "google.golang.org/protobuf/proto" + + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/proto/waE2E" + "github.com/polymorfa/hypermeow/types" + waLog "github.com/polymorfa/hypermeow/util/log" +) + +// TestPrepareMessageNodeHostedFanout checks send.go's device filtering: a +// hosted device 99 receives 1:1 messages but is dropped from group fanout. +// Sessions are established in memory so no prekey IQ is needed. +func TestPrepareMessageNodeHostedFanout(t *testing.T) { + ownPN := types.JID{User: "15550000000", Device: 7, Server: types.DefaultUserServer} + business := types.NewJID("15551234567", types.DefaultUserServer) + businessLID := types.NewJID("123456789012345", types.HiddenUserServer) + companion, _ := newSignalTestDevice(ownPN, &lidStub{pn: business, lid: businessLID}) + cli := NewClient(companion, waLog.Noop) + + primary := types.JID{User: business.User, Server: types.DefaultUserServer} + hosted := types.JID{User: business.User, Device: HostedDeviceID, Server: types.HostedServer} + ensureMap(&cli.userDevicesCache)[business] = deviceCache{devices: []types.JID{primary, hosted}} + + // Outgoing sessions under the addresses the send path derives. The stub + // has no batch PN->LID lookup, so the primary stays on its PN address. + for _, peer := range []types.JID{ + primary, + {User: businessLID.User, Device: HostedDeviceID, Server: types.HostedLIDServer}, + } { + peerDevice, _ := newSignalTestDevice(peer, &lidStub{}) + builder := session.NewBuilderFromSignal(companion, peer.SignalAddress(), pbSerializer) + if err := builder.ProcessBundle(context.Background(), signalTestBundle(t, peerDevice, uint32(peer.Device))); err != nil { + t.Fatal(err) + } + } + + message := &waE2E.Message{Conversation: proto.String("hello")} + plaintext, dsmPlaintext, err := marshalMessage(business, message) + if err != nil { + t.Fatal(err) + } + participantJIDs := func(node *waBinary.Node) []types.JID { + var out []types.JID + participants := node.GetChildByTag("participants") + for _, to := range participants.GetChildren() { + out = append(out, to.AttrGetter().JID("jid")) + } + return out + } + + node, devices, err := cli.prepareMessageNode(context.Background(), business, "3EB0DM", message, []types.JID{business}, plaintext, dsmPlaintext, &MessageDebugTimings{}, nodeExtraParams{}) + if err != nil { + t.Fatal(err) + } + if len(devices) != 2 || devices[1] != hosted { + t.Fatalf("1:1 devices = %v, want primary and %s", devices, hosted) + } + if got := participantJIDs(node); len(got) != 2 || got[1] != hosted { + t.Fatalf("1:1 participants = %v, want primary and %s", got, hosted) + } + + group := types.NewJID("120363000000000000", types.GroupServer) + node, devices, err = cli.prepareMessageNode(context.Background(), group, "3EB0GRP", message, []types.JID{business}, plaintext, nil, &MessageDebugTimings{}, nodeExtraParams{}) + if err != nil { + t.Fatal(err) + } + if len(devices) != 1 || devices[0] != primary { + t.Fatalf("group devices = %v, want only %s", devices, primary) + } + if got := participantJIDs(node); len(got) != 1 || got[0] != primary { + t.Fatalf("group participants = %v, want only %s", got, primary) + } +} From c843ad5b1a7d426dd680e879886e585da2e3995c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 28 Oct 2026 02:26:05 +0200 Subject: [PATCH 09/10] Note usync AEA limits and hosted Signal addressing --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97e5875e3..624c0a178 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,13 +12,14 @@ All notable HyperMeow changes are documented here. HyperMeow uses commit pseudo- ### Added - Persist hosted (coexistence) pairing as `store.Device.IsHosted` and report it on `events.PairSuccess.IsHosted`. SQL-backed stores upgrade to schema version 20, which adds `whatsmeow_device.is_hosted` (default `false`). -- Add `types.AEAAccountType` (`not_set`, `e2ee`, `hybrid_e2ee`, `non_e2ee`) on `BusinessProfile`, `UserInfo` and `IsOnWhatsAppResponse`, read from `` in business profile and usync business results. +- Add `types.AEAAccountType` (`not_set`, `e2ee`, `hybrid_e2ee`, `non_e2ee`) on `BusinessProfile`, `UserInfo` and `IsOnWhatsAppResponse`, read from `` in business profile and usync business results. `GetBusinessProfile` is the reliable source: `GetUserInfo` and `IsOnWhatsApp` do not request the business profile, so their field is set only when the server includes it. - Add `VerifiedName.Serial`, `Issuer`, `PrivacyMode`, `IsAPI` and `IsBusinessApp`, typed actual-actor and host-storage values, and `MessageInfo.BizPrivacyMode` from `` on business messages. - Add `UndecryptableMessage.IsHostedUnavailable` for ``. ### Fixed - `GetUserInfo` returns the parsed verified business name in `UserInfo.VerifiedName`. +- Use one Signal address for a hosted device 99 when decrypting its messages, sending to it and answering its retry receipts: `:99@hosted.lid` when the account's LID is known, otherwise `:99@hosted`. Hosted sessions no longer collide with a regular device 99, and sessions stored under the PN hosted address move to the LID hosted address. `MessageInfo.Sender` still reports hosted senders on the regular servers. - Stop automatic reconnect and emit `CATRefreshError` to generic and connection-scoped observers when a 413/414 failure cannot refresh its token, including when `RefreshCAT` is unset. The missing callback returns `ErrCATRefreshUnavailable` instead of panicking or retrying unchanged credentials. - Protect the upstream unified-session interval check during concurrent sends. - Parse newsletter live updates inside `messages` wrappers and retain forward counts, while preserving the legacy unwrapped form. From 36a1c1e0f8cf4c25a42cd185604f75e871c79969 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Fri, 30 Oct 2026 02:39:04 +0200 Subject: [PATCH 10/10] Migrate hosted LID sessions without wildcard key cleanup --- CHANGELOG.md | 1 + hosted_signal.go | 10 +++ hosted_signal_test.go | 88 +++++++++++++++++++ store/sqlstore/store.go | 32 +++---- store/sqlstore/store_test.go | 17 +++- .../testdata/hosted_migration_sqlite.py | 35 ++++++++ 6 files changed, 162 insertions(+), 21 deletions(-) create mode 100644 store/sqlstore/testdata/hosted_migration_sqlite.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 624c0a178..efd0cd0e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ All notable HyperMeow changes are documented here. HyperMeow uses commit pseudo- ### Fixed - `GetUserInfo` returns the parsed verified business name in `UserInfo.VerifiedName`. +- Migrate an established hosted PN session when the peer starts sending from its hosted LID. SQL-backed migration uses literal address-prefix ranges so hosted underscores cannot select or delete another account's keys. - Use one Signal address for a hosted device 99 when decrypting its messages, sending to it and answering its retry receipts: `:99@hosted.lid` when the account's LID is known, otherwise `:99@hosted`. Hosted sessions no longer collide with a regular device 99, and sessions stored under the PN hosted address move to the LID hosted address. `MessageInfo.Sender` still reports hosted senders on the regular servers. - Stop automatic reconnect and emit `CATRefreshError` to generic and connection-scoped observers when a 413/414 failure cannot refresh its token, including when `RefreshCAT` is unset. The missing callback returns `ErrCATRefreshUnavailable` instead of panicking or retrying unchanged credentials. - Protect the upstream unified-session interval check during concurrent sends. diff --git a/hosted_signal.go b/hosted_signal.go index 1a98ac5b7..67099aa8e 100644 --- a/hosted_signal.go +++ b/hosted_signal.go @@ -44,6 +44,16 @@ func (cli *Client) hostedSignalJID(ctx context.Context, jid types.JID) types.JID if jid.Device != HostedDeviceID { cli.Log.Warnf("Hosted JID %s does not use device %d", jid, HostedDeviceID) } + if jid.Server == types.HostedLIDServer { + pn, err := cli.Store.LIDs.GetPNForLID(ctx, types.JID{User: jid.User, Server: types.HiddenUserServer}) + if err != nil { + cli.Log.Warnf("Failed to get PN for hosted device %s: %v", jid, err) + } else if !pn.IsEmpty() { + hostedPN := types.JID{User: pn.User, Device: jid.Device, Server: types.HostedServer} + cli.migrateSessionStore(ctx, hostedPN, jid) + } + return jid + } if jid.Server != types.HostedServer { return jid } diff --git a/hosted_signal_test.go b/hosted_signal_test.go index ccb206abd..96909d576 100644 --- a/hosted_signal_test.go +++ b/hosted_signal_test.go @@ -7,6 +7,7 @@ package whatsmeow import ( "context" "slices" + "strings" "sync" "testing" @@ -46,6 +47,26 @@ func newMemSignalStore() *memSignalStore { } } +func (m *memSignalStore) MigratePNToLID(_ context.Context, pn, lid types.JID) error { + m.lock.Lock() + defer m.lock.Unlock() + prefix := pn.SignalAddressUser() + ":" + replacement := lid.SignalAddressUser() + ":" + for address, record := range m.sessions { + if strings.HasPrefix(address, prefix) { + m.sessions[replacement+strings.TrimPrefix(address, prefix)] = record + delete(m.sessions, address) + } + } + for address, identity := range m.identities { + if strings.HasPrefix(address, prefix) { + m.identities[replacement+strings.TrimPrefix(address, prefix)] = identity + delete(m.identities, address) + } + } + return nil +} + func (m *memSignalStore) PutIdentity(_ context.Context, address string, key [32]byte) error { m.lock.Lock() defer m.lock.Unlock() @@ -149,6 +170,13 @@ func (l *lidStub) GetLIDForPN(_ context.Context, pn types.JID) (types.JID, error return types.EmptyJID, nil } +func (l *lidStub) GetPNForLID(_ context.Context, lid types.JID) (types.JID, error) { + if lid.User == l.lid.User { + return types.JID{User: l.pn.User, Device: lid.Device, Server: types.DefaultUserServer}, nil + } + return types.EmptyJID, nil +} + func newSignalTestDevice(id types.JID, lids store.LIDStore) (*store.Device, *memSignalStore) { identityKey := keys.NewKeyPair() mem := newMemSignalStore() @@ -431,3 +459,63 @@ func TestRawHostedSender(t *testing.T) { } } } + +func TestHostedLIDTransitionMigratesEstablishedPNSession(t *testing.T) { + ctx := context.Background() + own := types.JID{User: "15550000000", Device: 7, Server: types.DefaultUserServer} + pn := types.JID{User: "15551234567", Device: HostedDeviceID, Server: types.HostedServer} + lid := types.JID{User: "123456789012345", Device: HostedDeviceID, Server: types.HostedLIDServer} + aliases := &lidStub{} + companion, mem := newSignalTestDevice(own, aliases) + cli := NewClient(companion, waLog.Noop) + cli.SynchronousAck = true + cli.testSendHook = (&sentNodes{}).hook + var received []string + cli.AddEventHandler(func(evt any) { + if event, ok := evt.(*events.Message); ok { + received = append(received, event.Message.GetConversation()) + } + }) + remote := newSignalTestPeer(t, companion, pn) + kind, ciphertext := remote.encrypt(t, "before alias") + deliverSignalTestMessage(t, cli, pn, "3EB0PN", kind, ciphertext) + // A reply acknowledges the initial prekey session so the next inbound + // ciphertext needs that established session, rather than a new prekey. + reply, err := proto.Marshal(&waE2E.Message{Conversation: proto.String("ack")}) + if err != nil { + t.Fatal(err) + } + nodes, _, err := cli.encryptMessageForDevices(ctx, []types.JID{pn}, "3EB0ACK", reply, nil, waBinary.Attrs{}) + if err != nil { + t.Fatal(err) + } + enc := nodes[0].GetChildByTag("enc") + signal, err := protocol.NewSignalMessageFromBytes(enc.Content.([]byte), pbSerializer.SignalMessage) + if err != nil { + t.Fatal(err) + } + if _, err = remote.cipher.Decrypt(ctx, signal); err != nil { + t.Fatal(err) + } + regular := types.JID{User: pn.User, Device: HostedDeviceID, Server: types.DefaultUserServer}.SignalAddress().String() + mem.sessions[regular] = []byte("regular session stays separate") + aliases.pn = types.NewJID(pn.User, types.DefaultUserServer) + aliases.lid = types.NewJID(lid.User, types.HiddenUserServer) + kind, ciphertext = remote.encrypt(t, "after alias") + if kind != "msg" { + t.Fatalf("transition ciphertext type = %s, want msg", kind) + } + deliverSignalTestMessage(t, cli, lid, "3EB0LID", kind, ciphertext) + if !slices.Equal(received, []string{"before alias", "after alias"}) { + t.Fatalf("decrypted = %q", received) + } + if found, _ := mem.HasSession(ctx, pn.SignalAddress().String()); found { + t.Fatal("old hosted PN session remains") + } + if found, _ := mem.HasSession(ctx, lid.SignalAddress().String()); !found { + t.Fatal("hosted LID session is missing") + } + if string(mem.sessions[regular]) != "regular session stays separate" { + t.Fatal("regular device99 session changed") + } +} diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 2a9039d2e..a7f211b66 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -383,31 +383,26 @@ const ( INSERT INTO whatsmeow_sessions (our_jid, their_id, session) SELECT our_jid, replace(their_id, $2, $3), session FROM whatsmeow_sessions - WHERE our_jid=$1 AND their_id LIKE $2 || ':%' + WHERE our_jid=$1 AND their_id >= $4 AND their_id < $5 ON CONFLICT (our_jid, their_id) DO UPDATE SET session=excluded.session ` - deleteAllIdentityKeysQuery = `DELETE FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id LIKE $2` + deleteAllIdentityKeysQuery = `DELETE FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id >= $2 AND their_id < $3` migratePNToLIDIdentityKeysQuery = ` INSERT INTO whatsmeow_identity_keys (our_jid, their_id, identity) SELECT our_jid, replace(their_id, $2, $3), identity FROM whatsmeow_identity_keys - WHERE our_jid=$1 AND their_id LIKE $2 || ':%' + WHERE our_jid=$1 AND their_id >= $4 AND their_id < $5 ON CONFLICT (our_jid, their_id) DO UPDATE SET identity=excluded.identity ` - deleteAllSenderKeysQuery = `DELETE FROM whatsmeow_sender_keys WHERE our_jid=$1 AND sender_id LIKE $2` + deleteAllSenderKeysQuery = `DELETE FROM whatsmeow_sender_keys WHERE our_jid=$1 AND sender_id >= $2 AND sender_id < $3` migratePNToLIDSenderKeysQuery = ` INSERT INTO whatsmeow_sender_keys (our_jid, chat_id, sender_id, sender_key) SELECT our_jid, chat_id, replace(sender_id, $2, $3), sender_key FROM whatsmeow_sender_keys - WHERE our_jid=$1 AND sender_id LIKE $2 || ':%' + WHERE our_jid=$1 AND sender_id >= $4 AND sender_id < $5 ON CONFLICT (our_jid, chat_id, sender_id) DO UPDATE SET sender_key=excluded.sender_key ` hasPNRowsToMigrateQuery = ` - SELECT EXISTS(SELECT 1 FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id LIKE $2) - OR EXISTS(SELECT 1 FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id LIKE $2) - OR EXISTS(SELECT 1 FROM whatsmeow_sender_keys WHERE our_jid=$1 AND sender_id LIKE $2) - ` - hasPNRowsToMigrateSQLiteQuery = ` SELECT EXISTS(SELECT 1 FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id >= $2 AND their_id < $3) OR EXISTS(SELECT 1 FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id >= $2 AND their_id < $3) OR EXISTS(SELECT 1 FROM whatsmeow_sender_keys WHERE our_jid=$1 AND sender_id >= $2 AND sender_id < $3) @@ -598,12 +593,12 @@ func signalAddressRange(user string) (string, string) { } func (s *SQLStore) deleteAllSenderKeys(ctx context.Context, phone string) error { - _, err := s.db.Exec(ctx, deleteAllSenderKeysQuery, s.JID, phone+":%") + _, err := s.db.Exec(ctx, deleteAllSenderKeysQuery, s.JID, phone+":", phone+";") return err } func (s *SQLStore) deleteAllIdentityKeys(ctx context.Context, phone string) error { - _, err := s.db.Exec(ctx, deleteAllIdentityKeysQuery, s.JID, phone+":%") + _, err := s.db.Exec(ctx, deleteAllIdentityKeysQuery, s.JID, phone+":", phone+";") return err } @@ -634,11 +629,8 @@ func (s *SQLStore) MigratePNToLID(ctx context.Context, pn, lid types.JID) error } var hasPNRows bool var err error - if s.db.Dialect == dbutil.SQLite { - err = s.db.QueryRow(ctx, hasPNRowsToMigrateSQLiteQuery, s.JID, pnSignal+":", pnSignal+";").Scan(&hasPNRows) - } else { - err = s.db.QueryRow(ctx, hasPNRowsToMigrateQuery, s.JID, pnSignal+":%").Scan(&hasPNRows) - } + lower, upper := signalAddressRange(pnSignal) + err = s.db.QueryRow(ctx, hasPNRowsToMigrateQuery, s.JID, lower, upper).Scan(&hasPNRows) if err != nil { s.log.Warnf("Failed to check for PN rows to migrate from %s: %v", pnSignal, err) } else if !hasPNRows { @@ -648,7 +640,7 @@ func (s *SQLStore) MigratePNToLID(ctx context.Context, pn, lid types.JID) error var sessionsUpdated, identityKeysUpdated, senderKeysUpdated int64 lidSignal := lid.SignalAddressUser() err = s.db.DoTxn(ctx, nil, func(ctx context.Context) error { - res, err := s.db.Exec(ctx, migratePNToLIDSessionsQuery, s.JID, pnSignal, lidSignal) + res, err := s.db.Exec(ctx, migratePNToLIDSessionsQuery, s.JID, pnSignal, lidSignal, lower, upper) if err != nil { return fmt.Errorf("failed to migrate sessions: %w", err) } @@ -661,7 +653,7 @@ func (s *SQLStore) MigratePNToLID(ctx context.Context, pn, lid types.JID) error return fmt.Errorf("failed to delete extra sessions: %w", err) } - res, err = s.db.Exec(ctx, migratePNToLIDIdentityKeysQuery, s.JID, pnSignal, lidSignal) + res, err = s.db.Exec(ctx, migratePNToLIDIdentityKeysQuery, s.JID, pnSignal, lidSignal, lower, upper) if err != nil { return fmt.Errorf("failed to migrate identity keys: %w", err) } @@ -674,7 +666,7 @@ func (s *SQLStore) MigratePNToLID(ctx context.Context, pn, lid types.JID) error return fmt.Errorf("failed to delete extra identity keys: %w", err) } - res, err = s.db.Exec(ctx, migratePNToLIDSenderKeysQuery, s.JID, pnSignal, lidSignal) + res, err = s.db.Exec(ctx, migratePNToLIDSenderKeysQuery, s.JID, pnSignal, lidSignal, lower, upper) if err != nil { return fmt.Errorf("failed to migrate sender keys: %w", err) } diff --git a/store/sqlstore/store_test.go b/store/sqlstore/store_test.go index 2fcddd1e2..6d408f9fd 100644 --- a/store/sqlstore/store_test.go +++ b/store/sqlstore/store_test.go @@ -409,7 +409,7 @@ func TestMigratePNToLIDCachesEmptyPreflightTemporarily(t *testing.T) { t.Fatalf("existence query did not cover %s", table) } } - wantArgs := []string{store.JID, "15551234567:%"} + wantArgs := []string{store.JID, "15551234567:", "15551234567;"} if len(state.args) != len(wantArgs) { t.Fatalf("unexpected existence query argument count %d", len(state.args)) } @@ -441,3 +441,18 @@ func TestMigratePNToLIDUsesSQLitePrefixRange(t *testing.T) { } } } + +func TestHostedMigrationQueriesUseLiteralPrefixRanges(t *testing.T) { + for name, query := range map[string]string{ + "sessions": migratePNToLIDSessionsQuery, + "identities": migratePNToLIDIdentityKeysQuery, + "sender keys": migratePNToLIDSenderKeysQuery, + "identity cleanup": deleteAllIdentityKeysQuery, + "sender cleanup": deleteAllSenderKeysQuery, + "preflight": hasPNRowsToMigrateQuery, + } { + if strings.Contains(query, "LIKE") { + t.Fatalf("%s treats hosted underscore as a wildcard: %s", name, query) + } + } +} diff --git a/store/sqlstore/testdata/hosted_migration_sqlite.py b/store/sqlstore/testdata/hosted_migration_sqlite.py new file mode 100644 index 000000000..99c95b566 --- /dev/null +++ b/store/sqlstore/testdata/hosted_migration_sqlite.py @@ -0,0 +1,35 @@ +# Copyright (c) 2026 Rajeh Taher +# Licensed under the MIT License. See LICENSE-MIT for details. + +"""Exercise the production SQL text using an isolated in-memory SQLite database.""" +import pathlib,re,sqlite3 +s=(pathlib.Path(__file__).resolve().parents[1]/'store.go').read_text() +def query(name):return re.search(r'\b'+re.escape(name)+r'\s*=\s*`([^`]+)`',s).group(1) +db=sqlite3.connect(':memory:') +db.executescript(''' +CREATE TABLE whatsmeow_sessions(our_jid TEXT, their_id TEXT, session BLOB, PRIMARY KEY(our_jid,their_id)); +CREATE TABLE whatsmeow_identity_keys(our_jid TEXT, their_id TEXT, identity BLOB, PRIMARY KEY(our_jid,their_id)); +CREATE TABLE whatsmeow_sender_keys(our_jid TEXT, chat_id TEXT, sender_id TEXT, sender_key BLOB, PRIMARY KEY(our_jid,chat_id,sender_id)); +''') +our='15550000000:7@s.whatsapp.net';pn='15551234567_128';lid='123456789012345_129' +# The underscore wildcard previously selected another real user's address. +other='155512345670128:99';regular='15551234567:99';hosted=pn+':99';target=lid+':99' +for table,idcol,data,migrate,cleanup in [ +('whatsmeow_sessions','their_id','session','migratePNToLIDSessionsQuery','deleteAllSessionsQuery'), +('whatsmeow_identity_keys','their_id','identity','migratePNToLIDIdentityKeysQuery','deleteAllIdentityKeysQuery'), +('whatsmeow_sender_keys','sender_id','sender_key','migratePNToLIDSenderKeysQuery','deleteAllSenderKeysQuery')]: + extra=',chat_id' if idcol=='sender_id' else '' + for address in [hosted,other,regular]: + columns='our_jid,'+idcol+','+data+extra + values=[our,address,address.encode()]+(['group@g.us'] if extra else []) + db.execute(f'INSERT INTO {table}({columns}) VALUES('+','.join('?' for _ in values)+')',values) + sql=query(migrate); args=[our,pn,lid]+([pn+':',pn+';'] if '$5' in sql else []) + db.execute(sql,{str(i+1):value for i,value in enumerate(args)}) + sql=query(cleanup); args=[our,pn+':%'] if 'LIKE' in sql else [our,pn+':',pn+';'] + db.execute(sql,{str(i+1):value for i,value in enumerate(args)}) + rows=dict(db.execute(f'SELECT {idcol},{data} FROM {table} WHERE our_jid=?',[our])) + assert hosted not in rows, (table,'hosted PN not removed') + assert rows.get(target)==hosted.encode(), (table,'hosted session not migrated') + assert rows.get(other)==other.encode(), (table,'unrelated PN deleted by wildcard') + assert rows.get(regular)==regular.encode(), (table,'regular device99 changed') + print(table+': hosted migration preserved unrelated PN and regular device99',flush=True)