diff --git a/CHANGELOG.md b/CHANGELOG.md index 70f32f06c..efd0cd0e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,18 @@ 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. `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`. +- 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. - 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 diff --git a/business_identity_test.go b/business_identity_test.go new file mode 100644 index 000000000..01fe7d439 --- /dev/null +++ b/business_identity_test.go @@ -0,0 +1,359 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + +package whatsmeow + +import ( + "context" + "slices" + "strings" + "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" + "github.com/polymorfa/hypermeow/types/events" + waLog "github.com/polymorfa/hypermeow/util/log" +) + +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) + } +} + +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) + } +} + +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) + } + }) + } +} + +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) + } +} 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) + } +} diff --git a/hosted_signal.go b/hosted_signal.go new file mode 100644 index 000000000..67099aa8e --- /dev/null +++ b/hosted_signal.go @@ -0,0 +1,70 @@ +// 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.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 + } + 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..96909d576 --- /dev/null +++ b/hosted_signal_test.go @@ -0,0 +1,521 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + +package whatsmeow + +import ( + "context" + "slices" + "strings" + "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) 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() + 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 (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() + 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) + } + } +} + +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/message.go b/message.go index 53a93d9b1..e68497ee5 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 } @@ -351,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 } @@ -365,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/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..96dae2cc6 --- /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" + "github.com/polymorfa/hypermeow/util/keys" + waLog "github.com/polymorfa/hypermeow/util/log" +) + +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/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) 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/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) 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..41a566917 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. @@ -322,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 } 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 b5e8305ca..787a0adf3 100644 --- a/types/user.go +++ b/types/user.go @@ -15,26 +15,107 @@ 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 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. 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 +184,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 +356,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..0c688c229 100644 --- a/user.go +++ b/user.go @@ -12,7 +12,9 @@ import ( "errors" "fmt" "slices" + "strconv" "strings" + "time" "google.golang.org/protobuf/proto" @@ -214,6 +216,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 +304,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 +545,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 +598,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 +928,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 @@ -949,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" {