Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ jobs:
- name: Test
run: go test -v ./...

# Pinned: golang.org/x/tools v0.50.0 and later require Go 1.26, which
# breaks the Go 1.25 job. v0.49.0 is the newest release on Go 1.25.
- name: Install goimports
run: |
go install golang.org/x/tools/cmd/goimports@latest
go install golang.org/x/tools/cmd/goimports@v0.49.0
export PATH="$HOME/go/bin:$PATH"

- name: Run pre-commit
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ All notable HyperMeow changes are documented here. HyperMeow uses commit pseudo-

## [Unreleased]

### Added

- Added remote Signal keys: `NewRemoteSignalClient` runs a linked device whose Signal private keys, sessions and message plaintext live in an external store reached through `RemoteSignal`. The client keeps its socket, receipts, retry receipts, prekey uploads and QR pairing, hands each received payload to the store, receives only delivery metadata (`events.RemoteMessage`), and transmits stanzas the store built with `SendPrebuiltStanza`. The store also handles identity changes (`ForgetIdentity`) and phone-number-to-LID session moves (`MigratePNToLID`). Pair-code pairing, local `SendMessage` and `SendFBMessage` return `ErrRemoteSignalUnsupported` for such clients, and `PreRetryCallback` receives a nil message.

### Documentation

- Added an evidence-backed comparison with upstream WhatsMeow.
Expand Down
18 changes: 18 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ type Client struct {
GetMessageForRetry func(requester, to types.JID, id types.MessageID) *waE2E.Message
// PreRetryCallback is called before a retry receipt is accepted.
// If it returns false, the accepting will be cancelled and the retry receipt will be ignored.
// On a client from NewRemoteSignalClient, msg is always nil: the remote
// Signal store keeps sent messages and this client never has their content.
PreRetryCallback func(receipt *events.Receipt, id types.MessageID, retryCount int, msg *waE2E.Message) bool
// Should whatsmeow store recently sent messages in the database so that retry receipts can be accepted
// even if the process is restarted? If false, only the in-memory cache and GetMessageForRetry will be used.
Expand Down Expand Up @@ -236,6 +238,15 @@ type Client struct {
// client (see [NewShadowClient]): it has no socket, Connect is guarded,
// and outbound nodes plus Signal/keying oracle ops are delegated here.
shadowRelay ShadowRelay
// remoteSignal holds this device's Signal keys when they live outside
// the client; see NewRemoteSignalClient.
remoteSignal RemoteSignal
// remoteMigratedPN records phone-number users whose Signal state the
// remote store has already moved to their LID.
remoteMigratedPN sync.Map
// testSendHook captures marshaled outbound nodes when there is no
// socket. Only tests set it.
testSendHook func(payload []byte) error

// DisabledFeatures controls which built-in processing paths are
// skipped. See [DisabledFeatures].
Expand Down Expand Up @@ -1064,6 +1075,13 @@ func (cli *Client) sendNodeAndGetData(ctx context.Context, node waBinary.Node) (
cli.sendLog.Debugf("%s", &node)
return payload, cli.shadowRelay.SendNode(ctx, payload)
}
if cli.testSendHook != nil {
payload, err := waBinary.Marshal(node)
if err != nil {
return nil, fmt.Errorf("failed to marshal node: %w", err)
}
return payload, cli.testSendHook(payload)
}
return nil, ErrNotConnected
}

Expand Down
8 changes: 8 additions & 0 deletions message.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,10 @@ func (cli *Client) handlePlaintextMessage(ctx context.Context, info *types.Messa
}

func (cli *Client) migrateSessionStore(ctx context.Context, pn, lid types.JID) {
if cli.isRemoteSignal() {
cli.migrateRemoteSessions(ctx, pn, lid)
return
}
err := cli.Store.Sessions.MigratePNToLID(ctx, pn, lid)
if err != nil {
cli.Log.Errorf("Failed to migrate signal store from %s to %s: %v", pn, lid, err)
Expand Down Expand Up @@ -381,6 +385,10 @@ func (cli *Client) decryptMessages(ctx context.Context, info *types.MessageInfo,
info.SenderAlt = lid
}
}
if cli.isRemoteSignal() {
cli.decryptMessagesRemote(ctx, info, node, senderEncryptionJID)
return
}
var recognizedStanza, protobufFailed bool
for _, child := range children {
if child.Tag != "enc" {
Expand Down
4 changes: 4 additions & 0 deletions notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ func (cli *Client) handleEncryptNotification(ctx context.Context, node *waBinary
}

func (cli *Client) deleteIdentityChangeState(ctx context.Context, from types.JID) {
if cli.isRemoteSignal() {
cli.forgetRemoteIdentity(ctx, from)
return
}
users := identityChangeSignalUsers(from)
if cli.Store.LIDs != nil {
var alternate types.JID
Expand Down
8 changes: 8 additions & 0 deletions pair-code.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ func generateCompanionEphemeralKey() (ephemeralKeyPair *keys.KeyPair, ephemeralK
//
// See https://faq.whatsapp.com/1324084875126592 for more info
func (cli *Client) PairPhone(ctx context.Context, phone string, showPushNotification bool, clientType PairClientType, clientDisplayName string) (string, error) {
if cli.isRemoteSignal() {
// Code pairing runs X25519 with the identity private key; the remote
// store does not expose that step yet.
return "", fmt.Errorf("pair-code pairing: %w", ErrRemoteSignalUnsupported)
}
if cli == nil {
return "", ErrClientIsNil
}
Expand Down Expand Up @@ -150,6 +155,9 @@ func (cli *Client) tryHandleCodePairNotification(ctx context.Context, parentNode
}

func (cli *Client) handleCodePairNotification(ctx context.Context, parentNode *waBinary.Node) error {
if cli.isRemoteSignal() {
return fmt.Errorf("pair-code pairing: %w", ErrRemoteSignalUnsupported)
}
node, ok := parentNode.GetOptionalChildByTag("link_code_companion_reg")
if !ok {
return &ElementMissingError{
Expand Down
18 changes: 17 additions & 1 deletion pair.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,16 @@ func (cli *Client) handlePair(ctx context.Context, deviceIdentityBytes []byte, r
return ErrPairInvalidDeviceSignature
}

deviceIdentity.DeviceSignature = generateDeviceSignature(&deviceIdentity, cli.Store.IdentityKey)[:]
if cli.isRemoteSignal() {
signature, err := cli.remoteDeviceSignature(ctx, deviceIdentityBytes, &deviceIdentity)
if err != nil {
cli.sendPairError(ctx, reqID, 500, "internal-error")
return err
}
deviceIdentity.DeviceSignature = signature
} else {
deviceIdentity.DeviceSignature = generateDeviceSignature(&deviceIdentity, cli.Store.IdentityKey)[:]
}

if cli.PrePairCallback != nil && !cli.PrePairCallback(jid, platform, businessName) {
cli.sendPairError(ctx, reqID, 500, "internal-error")
Expand Down Expand Up @@ -219,6 +228,13 @@ func (cli *Client) handlePair(ctx context.Context, deviceIdentityBytes []byte, r
return &PairDatabaseError{"failed to save device store", err}
}
cli.StoreLIDPNMapping(ctx, lid, jid)
if cli.isRemoteSignal() {
if err = cli.remoteSignal.SetOwnJIDs(ctx, jid, lid); err != nil {
_ = cli.Store.Delete(ctx)
cli.sendPairError(ctx, reqID, 500, "internal-error")
return fmt.Errorf("failed to give the remote Signal store its own JIDs: %w", err)
}
}
err = cli.Store.Identities.PutIdentity(ctx, mainDeviceLID.SignalAddress().String(), mainDeviceIdentity)
if err != nil {
_ = cli.Store.Delete(ctx)
Expand Down
Loading
Loading