pair: add support for passkeys

This commit is contained in:
Tulir Asokan
2026-06-30 21:06:29 +03:00
parent 37913c82b7
commit b572e5bcb9
9 changed files with 534 additions and 40 deletions
+4 -1
View File
@@ -186,7 +186,10 @@ type Client struct {
BackgroundEventCtx context.Context
phoneLinkingCache *phoneLinkingCache
phoneLinkingCache atomic.Pointer[phoneLinkingCache]
passkeyLinkingCache atomic.Pointer[passkeyLinkingCache]
passkeyHandoffKey atomic.Pointer[passkeyHandoffKey]
passkeySkipHandoffUX atomic.Bool
uniqueID string
idCounter atomic.Uint64
Generated
+100 -32
View File
@@ -199,6 +199,18 @@ func (int *DangerousInternalClient) HandleConnectSuccess(ctx context.Context, no
int.c.handleConnectSuccess(ctx, node)
}
func (int *DangerousInternalClient) GenerateCsToken(ctx context.Context, jid types.JID) []byte {
return int.c.generateCsToken(ctx, jid)
}
func (int *DangerousInternalClient) StoreNCTSalt(ctx context.Context, salt []byte) error {
return int.c.storeNCTSalt(ctx, salt)
}
func (int *DangerousInternalClient) ClearNCTSalt(ctx context.Context) error {
return int.c.clearNCTSalt(ctx)
}
func (int *DangerousInternalClient) DownloadAndDecrypt(ctx context.Context, url string, mediaKey []byte, appInfo MediaType, fileEncSHA256, fileSHA256 []byte) (data []byte, err error) {
return int.c.downloadAndDecrypt(ctx, url, mediaKey, appInfo, fileEncSHA256, fileSHA256)
}
@@ -503,6 +515,26 @@ func (int *DangerousInternalClient) SendPairError(ctx context.Context, id string
int.c.sendPairError(ctx, id, code, text)
}
func (int *DangerousInternalClient) HandlePasskeyNotification(ctx context.Context, node *waBinary.Node) {
int.c.handlePasskeyNotification(ctx, node)
}
func (int *DangerousInternalClient) TryHandlePasskeyContinuationNotification(ctx context.Context, node *waBinary.Node) {
int.c.tryHandlePasskeyContinuationNotification(ctx, node)
}
func (int *DangerousInternalClient) HandlePasskeyContinuationNotification(ctx context.Context, node *waBinary.Node) error {
return int.c.handlePasskeyContinuationNotification(ctx, node)
}
func (int *DangerousInternalClient) GetCompanionRef(ctx context.Context) (string, error) {
return int.c.getCompanionRef(ctx)
}
func (int *DangerousInternalClient) GetPasskeyRequestOptions(ctx context.Context) (*types.WebAuthnPublicKey, error) {
return int.c.getPasskeyRequestOptions(ctx)
}
func (int *DangerousInternalClient) GetServerPreKeyCount(ctx context.Context) (int, error) {
return int.c.getServerPreKeyCount(ctx)
}
@@ -563,6 +595,14 @@ func (int *DangerousInternalClient) SendMessageReceipt(ctx context.Context, info
int.c.sendMessageReceipt(ctx, info, node)
}
func (int *DangerousInternalClient) ShouldIncludeReportingToken(message *waE2E.Message) bool {
return int.c.shouldIncludeReportingToken(message)
}
func (int *DangerousInternalClient) GetMessageReportingToken(msgProtobuf []byte, msg *waE2E.Message, senderJID, remoteJID types.JID, messageID types.MessageID) waBinary.Node {
return int.c.getMessageReportingToken(msgProtobuf, msg, senderJID, remoteJID, messageID)
}
func (int *DangerousInternalClient) GenerateRequestID() string {
return int.c.generateRequestID()
}
@@ -643,30 +683,6 @@ func (int *DangerousInternalClient) SendRetryReceipt(ctx context.Context, node *
int.c.sendRetryReceipt(ctx, node, info, forceIncludeIdentity)
}
func (int *DangerousInternalClient) SendGroupV3(ctx context.Context, to, ownID types.JID, id types.MessageID, messageApp []byte, msgAttrs messageAttrs, frankingTag []byte, timings *MessageDebugTimings) (string, []byte, error) {
return int.c.sendGroupV3(ctx, to, ownID, id, messageApp, msgAttrs, frankingTag, timings)
}
func (int *DangerousInternalClient) SendDMV3(ctx context.Context, to, ownID types.JID, id types.MessageID, messageApp []byte, msgAttrs messageAttrs, frankingTag []byte, timings *MessageDebugTimings) ([]byte, string, error) {
return int.c.sendDMV3(ctx, to, ownID, id, messageApp, msgAttrs, frankingTag, timings)
}
func (int *DangerousInternalClient) PrepareMessageNodeV3(ctx context.Context, to, ownID types.JID, id types.MessageID, payload *waMsgTransport.MessageTransport_Payload, skdm *waMsgTransport.MessageTransport_Protocol_Ancillary_SenderKeyDistributionMessage, msgAttrs messageAttrs, frankingTag []byte, participants []types.JID, timings *MessageDebugTimings) (*waBinary.Node, []types.JID, error) {
return int.c.prepareMessageNodeV3(ctx, to, ownID, id, payload, skdm, msgAttrs, frankingTag, participants, timings)
}
func (int *DangerousInternalClient) EncryptMessageForDevicesV3(ctx context.Context, allDevices []types.JID, ownID types.JID, id string, payload *waMsgTransport.MessageTransport_Payload, skdm *waMsgTransport.MessageTransport_Protocol_Ancillary_SenderKeyDistributionMessage, dsm *waMsgTransport.MessageTransport_Protocol_Integral_DeviceSentMessage, encAttrs waBinary.Attrs) ([]waBinary.Node, error) {
return int.c.encryptMessageForDevicesV3(ctx, allDevices, ownID, id, payload, skdm, dsm, encAttrs)
}
func (int *DangerousInternalClient) EncryptMessageForDeviceAndWrapV3(ctx context.Context, payload *waMsgTransport.MessageTransport_Payload, skdm *waMsgTransport.MessageTransport_Protocol_Ancillary_SenderKeyDistributionMessage, dsm *waMsgTransport.MessageTransport_Protocol_Integral_DeviceSentMessage, to types.JID, bundle *prekey.Bundle, encAttrs waBinary.Attrs) (*waBinary.Node, error) {
return int.c.encryptMessageForDeviceAndWrapV3(ctx, payload, skdm, dsm, to, bundle, encAttrs)
}
func (int *DangerousInternalClient) EncryptMessageForDeviceV3(ctx context.Context, payload *waMsgTransport.MessageTransport_Payload, skdm *waMsgTransport.MessageTransport_Protocol_Ancillary_SenderKeyDistributionMessage, dsm *waMsgTransport.MessageTransport_Protocol_Integral_DeviceSentMessage, to types.JID, bundle *prekey.Bundle, extraAttrs waBinary.Attrs) (*waBinary.Node, error) {
return int.c.encryptMessageForDeviceV3(ctx, payload, skdm, dsm, to, bundle, extraAttrs)
}
func (int *DangerousInternalClient) SendNewsletter(ctx context.Context, to types.JID, id types.MessageID, message *waE2E.Message, mediaID string, timings *MessageDebugTimings) ([]byte, error) {
return int.c.sendNewsletter(ctx, to, id, message, mediaID, timings)
}
@@ -711,6 +727,66 @@ func (int *DangerousInternalClient) EncryptMessageForDevice(ctx context.Context,
return int.c.encryptMessageForDevice(ctx, plaintext, to, bundle, extraAttrs, existingSessions)
}
func (int *DangerousInternalClient) SendGroupV3(ctx context.Context, to, ownID types.JID, id types.MessageID, messageApp []byte, msgAttrs messageAttrs, frankingTag []byte, timings *MessageDebugTimings) (string, []byte, error) {
return int.c.sendGroupV3(ctx, to, ownID, id, messageApp, msgAttrs, frankingTag, timings)
}
func (int *DangerousInternalClient) SendDMV3(ctx context.Context, to, ownID types.JID, id types.MessageID, messageApp []byte, msgAttrs messageAttrs, frankingTag []byte, timings *MessageDebugTimings) ([]byte, string, error) {
return int.c.sendDMV3(ctx, to, ownID, id, messageApp, msgAttrs, frankingTag, timings)
}
func (int *DangerousInternalClient) PrepareMessageNodeV3(ctx context.Context, to, ownID types.JID, id types.MessageID, payload *waMsgTransport.MessageTransport_Payload, skdm *waMsgTransport.MessageTransport_Protocol_Ancillary_SenderKeyDistributionMessage, msgAttrs messageAttrs, frankingTag []byte, participants []types.JID, timings *MessageDebugTimings) (*waBinary.Node, []types.JID, error) {
return int.c.prepareMessageNodeV3(ctx, to, ownID, id, payload, skdm, msgAttrs, frankingTag, participants, timings)
}
func (int *DangerousInternalClient) EncryptMessageForDevicesV3(ctx context.Context, allDevices []types.JID, ownID types.JID, id string, payload *waMsgTransport.MessageTransport_Payload, skdm *waMsgTransport.MessageTransport_Protocol_Ancillary_SenderKeyDistributionMessage, dsm *waMsgTransport.MessageTransport_Protocol_Integral_DeviceSentMessage, encAttrs waBinary.Attrs) ([]waBinary.Node, error) {
return int.c.encryptMessageForDevicesV3(ctx, allDevices, ownID, id, payload, skdm, dsm, encAttrs)
}
func (int *DangerousInternalClient) EncryptMessageForDeviceAndWrapV3(ctx context.Context, payload *waMsgTransport.MessageTransport_Payload, skdm *waMsgTransport.MessageTransport_Protocol_Ancillary_SenderKeyDistributionMessage, dsm *waMsgTransport.MessageTransport_Protocol_Integral_DeviceSentMessage, to types.JID, bundle *prekey.Bundle, encAttrs waBinary.Attrs) (*waBinary.Node, error) {
return int.c.encryptMessageForDeviceAndWrapV3(ctx, payload, skdm, dsm, to, bundle, encAttrs)
}
func (int *DangerousInternalClient) EncryptMessageForDeviceV3(ctx context.Context, payload *waMsgTransport.MessageTransport_Payload, skdm *waMsgTransport.MessageTransport_Protocol_Ancillary_SenderKeyDistributionMessage, dsm *waMsgTransport.MessageTransport_Protocol_Integral_DeviceSentMessage, to types.JID, bundle *prekey.Bundle, extraAttrs waBinary.Attrs) (*waBinary.Node, error) {
return int.c.encryptMessageForDeviceV3(ctx, payload, skdm, dsm, to, bundle, extraAttrs)
}
func (int *DangerousInternalClient) ResolveTCTokenStorageLID(ctx context.Context, jid types.JID) types.JID {
return int.c.resolveTCTokenStorageLID(ctx, jid)
}
func (int *DangerousInternalClient) GetTCTokenSenderTS(jid types.JID) time.Time {
return int.c.getTCTokenSenderTS(jid)
}
func (int *DangerousInternalClient) ValidateAndSetTCTokenSenderTS(jid types.JID, storedSenderTimestamp time.Time) bool {
return int.c.validateAndSetTCTokenSenderTS(jid, storedSenderTimestamp)
}
func (int *DangerousInternalClient) SetTCTokenSenderTS(jid types.JID, ts time.Time) {
int.c.setTCTokenSenderTS(jid, ts)
}
func (int *DangerousInternalClient) UnlockedCleanupTCTokenSenderTSMap() {
int.c.unlockedCleanupTCTokenSenderTSMap()
}
func (int *DangerousInternalClient) EnsureTCToken(ctx context.Context, jid types.JID) (token []byte, err error) {
return int.c.ensureTCToken(ctx, jid)
}
func (int *DangerousInternalClient) DeleteExpiredPrivacyTokens() {
int.c.deleteExpiredPrivacyTokens()
}
func (int *DangerousInternalClient) IssuePrivacyTokenAndSave(jid types.JID, senderTimestamp time.Time) {
int.c.issuePrivacyTokenAndSave(jid, senderTimestamp)
}
func (int *DangerousInternalClient) IssuePrivacyToken(ctx context.Context, jid types.JID, timestamp time.Time) (*waBinary.Node, error) {
return int.c.issuePrivacyToken(ctx, jid, timestamp)
}
func (int *DangerousInternalClient) RawUpload(ctx context.Context, dataToUpload io.Reader, uploadSize uint64, fileHash []byte, appInfo MediaType, newsletter bool, resp *UploadResponse) error {
return int.c.rawUpload(ctx, dataToUpload, uploadSize, fileHash, appInfo, newsletter, resp)
}
@@ -746,11 +822,3 @@ func (int *DangerousInternalClient) Usync(ctx context.Context, jids []types.JID,
func (int *DangerousInternalClient) ParseBlocklist(node *waBinary.Node) *types.Blocklist {
return int.c.parseBlocklist(node)
}
func (int *DangerousInternalClient) ShouldIncludeReportingToken(message *waE2E.Message) bool {
return int.c.shouldIncludeReportingToken(message)
}
func (int *DangerousInternalClient) GetMessageReportingToken(msgProtobuf []byte, msg *waE2E.Message, senderJID, remoteJID types.JID, messageID types.MessageID) waBinary.Node {
return int.c.getMessageReportingToken(msgProtobuf, msg, senderJID, remoteJID, messageID)
}
+4 -4
View File
@@ -101,11 +101,11 @@ func main() {
fset := token.NewFileSet()
fileNames := []string{
"appstate.go", "armadillomessage.go", "broadcast.go", "call.go", "client.go",
"connectionevents.go", "download.go", "download-to-file.go", "group.go", "handshake.go",
"connectionevents.go", "cstoken.go", "download.go", "download-to-file.go", "group.go", "handshake.go",
"keepalive.go", "mediaconn.go", "mediaretry.go", "message.go", "msgsecret.go",
"newsletter.go", "notification.go", "pair-code.go", "pair.go", "prekeys.go",
"presence.go", "privacysettings.go", "push.go", "qrchan.go", "receipt.go", "request.go",
"retry.go", "sendfb.go", "send.go", "upload.go", "user.go", "reportingtoken.go",
"newsletter.go", "notification.go", "pair-code.go", "pair.go", "pair-passkey.go", "prekeys.go",
"presence.go", "privacysettings.go", "push.go", "qrchan.go", "receipt.go", "reportingtoken.go",
"request.go", "retry.go", "send.go", "sendfb.go", "tctoken.go", "upload.go", "user.go",
}
files := make([]*ast.File, len(fileNames))
for i, name := range fileNames {
+4
View File
@@ -504,6 +504,10 @@ func (cli *Client) handleNotification(ctx context.Context, node *waBinary.Node)
cli.handleMexNotification(ctx, node)
case "status":
cli.handleStatusNotification(ctx, node)
case "passkey_prologue_request":
cli.handlePasskeyNotification(ctx, node)
case "crsc_continuation":
go cli.tryHandlePasskeyContinuationNotification(ctx, node)
// Other types: business, disappearing_mode, server, status, pay, psa
default:
cli.Log.Debugf("Unhandled notification with type %s", notifType)
+3 -3
View File
@@ -133,12 +133,12 @@ func (cli *Client) PairPhone(ctx context.Context, phone string, showPushNotifica
if !ok {
return "", fmt.Errorf("unexpected type %T in content of link_code_pairing_ref tag", pairingRefNode.Content)
}
cli.phoneLinkingCache = &phoneLinkingCache{
cli.phoneLinkingCache.Store(&phoneLinkingCache{
jid: jid,
keyPair: ephemeralKeyPair,
linkingCode: encodedLinkingCode,
pairingRef: string(pairingRef),
}
})
return encodedLinkingCode[0:4] + "-" + encodedLinkingCode[4:], nil
}
@@ -157,7 +157,7 @@ func (cli *Client) handleCodePairNotification(ctx context.Context, parentNode *w
In: "notification",
}
}
linkCache := cli.phoneLinkingCache
linkCache := cli.phoneLinkingCache.Load()
if linkCache == nil {
return fmt.Errorf("received code pair notification without a pending pairing")
}
+324
View File
@@ -0,0 +1,324 @@
// Copyright (c) 2026 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package whatsmeow
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/json"
"fmt"
"time"
"go.mau.fi/util/random"
"golang.org/x/crypto/curve25519"
"google.golang.org/protobuf/proto"
waBinary "go.mau.fi/whatsmeow/binary"
"go.mau.fi/whatsmeow/proto/waCompanionReg"
"go.mau.fi/whatsmeow/store"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
"go.mau.fi/whatsmeow/util/gcmutil"
"go.mau.fi/whatsmeow/util/hkdfutil"
"go.mau.fi/whatsmeow/util/keys"
)
type passkeyLinkingCache struct {
keyPair *keys.KeyPair
companionNonce []byte
pairingRef string
deviceType waCompanionReg.DeviceProps_PlatformType
encryptionKey []byte
}
type passkeyHandoffKey struct {
hmac []byte
ts time.Time
}
func (k *passkeyHandoffKey) Valid() bool {
return k != nil && time.Since(k.ts) < 5*time.Minute
}
func (cli *Client) handlePasskeyNotification(ctx context.Context, node *waBinary.Node) {
if fromJID := node.AttrGetter().JID("from"); fromJID != types.ServerJID {
cli.Log.Warnf("Ignoring passkey notification from non-server JID %s", fromJID)
return
}
pubKey, err := parsePasskeyNotification(node)
if err != nil {
cli.Log.Warnf("Failed to parse passkey notification: %v", err)
var secondErr error
pubKey, secondErr = cli.getPasskeyRequestOptions(ctx)
if secondErr != nil {
cli.Log.Warnf("Failed to fetch passkey options: %v", secondErr)
cli.dispatchEvent(&events.PairPasskeyError{
Error: fmt.Errorf("failed to parse passkey notification: %w (fetching key also failed: %w)", err, secondErr),
Continuation: false,
})
return
}
cli.Log.Debugf("Successfully fetched passkey options after failing to parse notification")
}
cli.passkeyHandoffKey.Store(&passkeyHandoffKey{
hmac: hkdfutil.SHA256(cli.Store.AdvSecretKey, nil, []byte("shortcake-passkey-handoff-v1"), 32),
ts: time.Now(),
})
cli.Store.AdvSecretKey = random.Bytes(32)
cli.dispatchEvent(&events.PairPasskeyRequest{PublicKey: pubKey})
}
// SendPasskeyResponse sends a WebAuthn response from the authenticator to the server.
// This should be called after receiving an [*events.PairPasskeyRequest] and asking the authenticator for a response.
func (cli *Client) SendPasskeyResponse(ctx context.Context, passkeyResp *types.WebAuthnResponse) error {
marshaledResp, err := json.Marshal(passkeyResp)
if err != nil {
return fmt.Errorf("failed to marshal WebAuthnResponse: %w", err)
}
companionRef, err := cli.getCompanionRef(ctx)
if err != nil {
return fmt.Errorf("failed to get companion ref: %w", err)
}
companionEphemeralKeyPair := keys.NewKeyPair()
companionNonce := random.Bytes(32)
deviceType := store.DeviceProps.GetPlatformType()
ident, err := proto.Marshal(&waCompanionReg.CompanionEphemeralIdentity{
PublicKey: companionEphemeralKeyPair.Pub[:],
DeviceType: &deviceType,
Ref: &companionRef,
})
if err != nil {
return fmt.Errorf("failed to marshal CompanionEphemeralIdentity: %w", err)
}
commitment := sha256.Sum256(append(ident, companionNonce...))
prologuePayload, err := proto.Marshal(&waCompanionReg.ProloguePayload{
CompanionEphemeralIdentity: ident,
Commitment: &waCompanionReg.CompanionCommitment{
Hash: commitment[:],
},
})
if err != nil {
return fmt.Errorf("failed to marshal ProloguePayload: %w", err)
}
cli.passkeyLinkingCache.Store(&passkeyLinkingCache{
keyPair: companionEphemeralKeyPair,
companionNonce: companionNonce,
pairingRef: companionRef,
deviceType: deviceType,
})
prologueContent := []waBinary.Node{
{Tag: "credential_id", Content: []byte(passkeyResp.RawID)},
{Tag: "webauthn_assertion", Content: marshaledResp},
{Tag: "prologue_payload", Content: prologuePayload},
}
if handoffKey := cli.passkeyHandoffKey.Load(); handoffKey.Valid() {
h := hmac.New(sha256.New, handoffKey.hmac)
h.Write(prologuePayload)
pairingHandoffProof := h.Sum(nil)
prologueContent = append(prologueContent, waBinary.Node{
Tag: "pairing_handoff_proof",
Content: pairingHandoffProof,
})
cli.passkeySkipHandoffUX.Store(true)
} else {
cli.passkeySkipHandoffUX.Store(false)
}
_, err = cli.sendIQ(ctx, infoQuery{
Namespace: "md",
Type: iqSet,
To: types.ServerJID,
Content: []waBinary.Node{{
Tag: "passkey_prologue",
Content: prologueContent,
}},
})
if err != nil {
return fmt.Errorf("failed to send passkey response: %w", err)
}
cli.passkeyHandoffKey.Store(nil)
return nil
}
func (cli *Client) tryHandlePasskeyContinuationNotification(ctx context.Context, node *waBinary.Node) {
if fromJID := node.AttrGetter().JID("from"); fromJID != types.ServerJID {
cli.Log.Warnf("Ignoring passkey continuation notification from non-server JID %s", fromJID)
return
}
err := cli.handlePasskeyContinuationNotification(ctx, node)
if err != nil {
cli.Log.Warnf("Failed to handle passkey continuation notification: %v", err)
cli.dispatchEvent(&events.PairPasskeyError{
Error: err,
Continuation: true,
})
}
}
func (cli *Client) handlePasskeyContinuationNotification(ctx context.Context, node *waBinary.Node) error {
cache := cli.passkeyLinkingCache.Load()
if cache == nil {
return fmt.Errorf("received passkey continuation notification without a linking cache")
}
primaryEphemeralIdentity, err := parsePasskeyContinuationNotification(node)
if err != nil {
return fmt.Errorf("failed to parse passkey continuation notification: %w", err)
}
sharedSecret, err := curve25519.X25519(cache.keyPair.Priv[:], primaryEphemeralIdentity.PublicKey)
if err != nil {
return fmt.Errorf("failed to compute shared secret: %w", err)
}
_, err = cli.sendIQ(ctx, infoQuery{
Namespace: "md",
Type: iqSet,
To: types.ServerJID,
Content: []waBinary.Node{{
Tag: "companion_nonce",
Content: cache.companionNonce,
}},
})
if err != nil {
return fmt.Errorf("failed to send companion nonce: %w", err)
}
const info = "Pairing Information Encryption Key"
salt := fmt.Sprintf("Companion Pairing %d with ref %s", cache.deviceType, cache.pairingRef)
cache.encryptionKey = hkdfutil.SHA256(sharedSecret, []byte(salt), []byte(info), 32)
digest := sha256.Sum256(append(cache.companionNonce, primaryEphemeralIdentity.PublicKey...))
codeBytes := make([]byte, 5)
for i := range codeBytes {
codeBytes[i] = primaryEphemeralIdentity.Nonce[i] ^ digest[i]
}
encodedCode := linkingBase32.EncodeToString(codeBytes)
cli.dispatchEvent(&events.PairPasskeyConfirmation{
Code: encodedCode[0:4] + "-" + encodedCode[4:],
SkipHandoffUX: cli.passkeySkipHandoffUX.Load(),
})
return nil
}
// SendPasskeyConfirmation sends a confirmation to the server that the pairing code in [*events.PairPasskeyConfirmation]
// was shown to the user and they confirmed it. If the event has the SkipHandoffUX flag, showing the code to the user
// can be skipped.
func (cli *Client) SendPasskeyConfirmation(ctx context.Context) error {
cache := cli.passkeyLinkingCache.Load()
if cache == nil {
return fmt.Errorf("no passkey linking cache available")
} else if cache.encryptionKey == nil {
return fmt.Errorf("passkey linking cache does not have an encryption key yet")
}
req, err := proto.Marshal(&waCompanionReg.PairingRequest{
CompanionPublicKey: cli.Store.NoiseKey.Pub[:],
CompanionIdentityKey: cli.Store.IdentityKey.Pub[:],
AdvSecret: cli.Store.AdvSecretKey,
})
if err != nil {
return fmt.Errorf("failed to marshal PairingRequest: %w", err)
}
iv := random.Bytes(12)
encryptedReq, err := gcmutil.Encrypt(cache.encryptionKey, iv, req, nil)
if err != nil {
return fmt.Errorf("failed to encrypt PairingRequest: %w", err)
}
wrappedReq, err := proto.Marshal(&waCompanionReg.EncryptedPairingRequest{
EncryptedPayload: encryptedReq,
IV: iv,
})
if err != nil {
return fmt.Errorf("failed to marshal EncryptedPairingRequest: %w", err)
}
_, err = cli.sendIQ(ctx, infoQuery{
Namespace: "md",
Type: iqSet,
To: types.ServerJID,
Content: []waBinary.Node{{
Tag: "encrypted_pairing_request",
Content: wrappedReq,
}},
})
if err != nil {
return err
}
cli.passkeyLinkingCache.Store(nil)
return nil
}
func (cli *Client) getCompanionRef(ctx context.Context) (string, error) {
resp, err := cli.sendIQ(ctx, infoQuery{
Namespace: "md",
Type: iqGet,
To: types.ServerJID,
Content: []waBinary.Node{{Tag: "ref"}},
})
if err != nil {
return "", err
}
ref, ok := resp.GetOptionalChildByTag("ref")
if !ok {
return "", &ElementMissingError{Tag: "ref", In: "get ref response"}
}
contentBytes, ok := ref.Content.([]byte)
if !ok {
return "", fmt.Errorf("unexpected content type %T for <ref> node", ref.Content)
}
return string(contentBytes), nil
}
func (cli *Client) getPasskeyRequestOptions(ctx context.Context) (*types.WebAuthnPublicKey, error) {
resp, err := cli.sendIQ(ctx, infoQuery{
Namespace: "md",
Type: iqGet,
To: types.ServerJID,
Content: []waBinary.Node{{Tag: "passkey_request_options"}},
})
if err != nil {
return nil, err
}
return parsePasskeyNotification(resp)
}
func parsePasskeyNotification(node *waBinary.Node) (*types.WebAuthnPublicKey, error) {
opts, ok := node.GetOptionalChildByTag("passkey_request_options")
if !ok {
return nil, &ElementMissingError{Tag: "passkey_request_options", In: "passkey notification"}
}
content, ok := opts.Content.([]byte)
if !ok {
return nil, fmt.Errorf("unexpected content type %T for <passkey_request_options> node", opts.Content)
}
var pubKey types.WebAuthnPublicKey
err := json.Unmarshal(content, &pubKey)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal <passkey_request_options> content: %w", err)
}
return &pubKey, nil
}
func parsePasskeyContinuationNotification(node *waBinary.Node) (*waCompanionReg.PrimaryEphemeralIdentity, error) {
opts, ok := node.GetOptionalChildByTag("primary_ephemeral_identity")
if !ok {
return nil, &ElementMissingError{Tag: "primary_ephemeral_identity", In: "passkey continuation notification"}
}
content, ok := opts.Content.([]byte)
if !ok {
return nil, fmt.Errorf("unexpected content type %T for <primary_ephemeral_identity> node", opts.Content)
}
var buf waCompanionReg.PrimaryEphemeralIdentity
err := proto.Unmarshal(content, &buf)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal primary ephemeral identity: %w", err)
} else if len(buf.PublicKey) != 32 {
return nil, fmt.Errorf("unexpected public key length %d primary ephemeral identity", len(buf.PublicKey))
} else if len(buf.Nonce) != 32 {
return nil, fmt.Errorf("unexpected nonce length %d primary ephemeral identity", len(buf.Nonce))
}
return &buf, err
}
+35
View File
@@ -8,6 +8,7 @@ package whatsmeow
import (
"context"
"fmt"
"slices"
"sync"
"sync/atomic"
@@ -27,10 +28,15 @@ type QRChannelItem struct {
Code string
// The timeout after which the next code will be sent down the channel.
Timeout time.Duration
PasskeyRequest *events.PairPasskeyRequest
PasskeyConfirmation *events.PairPasskeyConfirmation
}
const QRChannelEventCode = "code"
const QRChannelEventError = "error"
const QRChannelEventPasskeyRequest = "passkey-request"
const QRChannelEventPasskeyResponse = "passkey-confirmation"
// Possible final items in the QR channel. In addition to these, an `error` event may be emitted,
// in which case the Error field will have the error that occurred during pairing.
@@ -132,6 +138,35 @@ func (qrc *qrChannel) handleEvent(rawEvt any) {
qrc.log.Debugf("QR code scanned without multidevice enabled")
qrc.output <- QRChannelScannedWithoutMultidevice
return
case *events.PairPasskeyRequest:
qrc.output <- QRChannelItem{
Event: QRChannelEventPasskeyRequest,
PasskeyRequest: evt,
}
return
case *events.PairPasskeyConfirmation:
if evt.SkipHandoffUX {
qrc.log.Debugf("Sending automatic passkey confirmation")
err := qrc.cli.SendPasskeyConfirmation(qrc.ctx)
if err != nil {
qrc.output <- QRChannelItem{
Event: QRChannelEventError,
Error: fmt.Errorf("failed to send passkey confirmation automatically: %w", err),
}
}
} else {
qrc.output <- QRChannelItem{
Event: QRChannelEventPasskeyResponse,
PasskeyConfirmation: evt,
}
}
return
case *events.PairPasskeyError:
qrc.output <- QRChannelItem{
Event: QRChannelEventError,
Error: evt.Error,
}
return
case *events.ClientOutdated:
outputType = QRChannelClientOutdated
case *events.PairSuccess:
+20
View File
@@ -59,6 +59,26 @@ type PairError struct {
Error error
}
// PairPasskeyRequest is emitted when the pairing requires a passkey.
// The client should generate a response and send it using Client.SendPasskeyResponse.
type PairPasskeyRequest struct {
PublicKey *types.WebAuthnPublicKey
}
// PairPasskeyError is emitted if handling a passkey notification fails.
type PairPasskeyError struct {
Error error
Continuation bool // Whether this was from a continuation notification rather than the initial one
}
// PairPasskeyConfirmation is emitted after a successful SendPasskeyResponse call.
// If SkipHandoffUX is false, the user should be shown the code and asked to verify that it matches the one on their phone.
// After verification if needed, the client should call Client.SendPasskeyConfirmation to finish the pairing process.
type PairPasskeyConfirmation struct {
Code string
SkipHandoffUX bool
}
// QRScannedWithoutMultidevice is emitted when the pairing QR code is scanned, but the phone didn't have multidevice enabled.
// The same QR code can still be scanned after this event, which means the user can just be told to enable multidevice and re-scan the code.
type QRScannedWithoutMultidevice struct{}
+40
View File
@@ -0,0 +1,40 @@
// Copyright (c) 2026 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package types
import (
"go.mau.fi/util/jsonbytes"
)
type WebAuthnPublicKey struct {
Challenge jsonbytes.UnpaddedURLBytes `json:"challenge"`
Timeout int `json:"timeout"`
RelyingPartID string `json:"rpId"`
AllowCredentials []AllowedCredential `json:"allowCredentials"`
UserVerification string `json:"userVerification"`
Extensions map[string]any `json:"extensions"`
}
type AllowedCredential struct {
ID jsonbytes.UnpaddedURLBytes `json:"id"`
Type string `json:"type"`
Transports []string `json:"transports"`
}
type WebAuthnResponse struct {
ID string `json:"id"`
RawID jsonbytes.UnpaddedURLBytes `json:"rawId"`
Type string `json:"type"`
Response WebAuthnResponseData `json:"response"`
}
type WebAuthnResponseData struct {
ClientDataJSON jsonbytes.UnpaddedURLBytes `json:"clientDataJSON"`
AuthenticatorData jsonbytes.UnpaddedURLBytes `json:"authenticatorData"`
Signature jsonbytes.UnpaddedURLBytes `json:"signature"`
UserHandle *jsonbytes.UnpaddedURLBytes `json:"userHandle"`
}