pair: add support for passkeys
This commit is contained in:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user