Step 1 — Copy and run the code in your browser console
+Open https://web.whatsapp.com, press F12, paste in the console and press Enter.
+Loading...+ +
diff --git a/go.mod b/go.mod index 02830db2..91aa38dc 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/patrickmn/go-cache v2.1.0+incompatible github.com/rs/zerolog v1.35.1 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e - go.mau.fi/whatsmeow v0.0.0-20260516102357-8d3700152a69 + go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b google.golang.org/protobuf v1.36.11 ) @@ -24,7 +24,7 @@ require ( github.com/rabbitmq/amqp091-go v1.10.0 github.com/vincent-petithory/dataurl v1.0.0 golang.org/x/image v0.32.0 - golang.org/x/sync v0.20.0 + golang.org/x/sync v0.21.0 modernc.org/sqlite v1.37.1 ) @@ -40,7 +40,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect github.com/aws/smithy-go v1.22.3 // indirect github.com/beeper/argo-go v1.1.2 // indirect - github.com/coder/websocket v1.8.14 // indirect + github.com/coder/websocket v1.8.15 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect @@ -48,9 +48,9 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rs/xid v1.6.0 // indirect github.com/vektah/gqlparser/v2 v2.5.33 // indirect - golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect - golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect modernc.org/libc v1.65.8 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect @@ -63,10 +63,10 @@ require ( github.com/joho/godotenv v1.5.1 github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.22 // indirect - go.mau.fi/libsignal v0.2.1 // indirect - go.mau.fi/util v0.9.9 // indirect - golang.org/x/crypto v0.51.0 // indirect - golang.org/x/net v0.54.0 - golang.org/x/sys v0.44.0 // indirect + go.mau.fi/libsignal v0.2.2 // indirect + go.mau.fi/util v0.9.10 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 + golang.org/x/sys v0.46.0 // indirect rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 20121606..dfa7689d 100644 --- a/go.sum +++ b/go.sum @@ -37,8 +37,8 @@ github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k= github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -71,8 +71,8 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= -github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk= +github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= @@ -104,12 +104,12 @@ github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6O github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI= github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0= -go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU= -go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE= -go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY= -go.mau.fi/whatsmeow v0.0.0-20260516102357-8d3700152a69 h1:rRcG2I1LVQ11kH2+KSYxNeFNZXp1oKA5Yxmagsf5bks= -go.mau.fi/whatsmeow v0.0.0-20260516102357-8d3700152a69/go.mod h1:SY+3c678dbtckGZF16M+sfEW8ZxTb9xkaKwhXueF5yE= +go.mau.fi/libsignal v0.2.2 h1:QV+XdzQkm3x3aSG7FcqfGSZuFXz83pRZPBFaPygHbOU= +go.mau.fi/libsignal v0.2.2/go.mod h1:CRlIQg2J8uYTfDFvNoO8/KcZjs5cey0vbc6oj/bssY0= +go.mau.fi/util v0.9.10 h1:wzvz5iDHyqDXB8vgisD4d3SzucLXNM3iNY+1O1RoHtg= +go.mau.fi/util v0.9.10/go.mod h1:YQOxySn+ZE3qSYqNxvyX7Yi3suA8YK17PS6QqBREW7A= +go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b h1:ZUk1ErarDNpnbosXR/MeOz2gkqA4S1bh8zjaSRj7N+Y= +go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b/go.mod h1:9dmNTYZ/1pHjPw/bz+azBsGjAkcrZbqzMrKcvG5bJ8U= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -118,10 +118,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ= golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -129,8 +129,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -140,8 +140,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -149,8 +149,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -162,8 +162,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -173,8 +173,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -184,8 +184,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -194,8 +194,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= +golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/handlers.go b/handlers.go index ef64235b..9038464e 100644 --- a/handlers.go +++ b/handlers.go @@ -632,7 +632,17 @@ func (s *server) GetQR() http.HandlerFunc { } log.Info().Str("instance", txtid).Str("qrcode", code).Msg("Get QR successful") - response := map[string]interface{}{"QRCode": fmt.Sprintf("%s", code)} + response := map[string]interface{}{ + "QRCode": fmt.Sprintf("%s", code), + "passkeyPending": false, + "publicKey": nil, + } + + if pk := peekPendingPasskey(txtid); pk != nil && pk.Request != nil { + response["passkeyPending"] = true + response["publicKey"] = pk.Request.PublicKey + } + responseJson, err := json.Marshal(response) if err != nil { s.Respond(w, r, http.StatusInternalServerError, err) @@ -744,6 +754,112 @@ func (s *server) PairPhone() http.HandlerFunc { } } +// PasskeyResponse receives a WebAuthn response from the frontend and sends it to WhatsApp +func (s *server) PasskeyResponse() http.HandlerFunc { + type passkeyResponseStruct struct { + Response *types.WebAuthnResponse `json:"response"` + } + + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + state := getAndConsumePendingPasskey(txtid) + if state == nil || state.Request == nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("no pending passkey request")) + return + } + + decoder := json.NewDecoder(r.Body) + var t passkeyResponseStruct + err := decoder.Decode(&t) + if err != nil { + storePendingPasskey(txtid, state) // put it back so user can retry + s.Respond(w, r, http.StatusBadRequest, errors.New("could not decode Payload")) + return + } + + if t.Response == nil { + storePendingPasskey(txtid, state) // put it back + s.Respond(w, r, http.StatusBadRequest, errors.New("missing response in Payload")) + return + } + + if state.Client == nil { + storePendingPasskey(txtid, state) // put it back + s.Respond(w, r, http.StatusInternalServerError, errors.New("passkey client unavailable")) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err = state.Client.SendPasskeyResponse(ctx, t.Response) + if err != nil { + log.Error().Err(err).Str("userID", txtid).Msg("Failed to send passkey response") + s.Respond(w, r, http.StatusInternalServerError, err) + return + } + + log.Info().Str("userID", txtid).Msg("Passkey response sent successfully") + s.Respond(w, r, http.StatusOK, map[string]interface{}{"status": "passkey_response_sent"}) + } +} + +// PasskeyConfirm confirms the passkey pairing code was shown to the user. +// Unlike PasskeyResponse, this uses peek (non-consuming read) because the +// confirmation step does not require exclusive ownership of the state — the +// state is only removed after a successful SendPasskeyConfirmation call. +func (s *server) PasskeyConfirm() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + state := peekPendingPasskey(txtid) + if state == nil || state.Client == nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("no pending passkey confirmation")) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err := state.Client.SendPasskeyConfirmation(ctx) + if err != nil { + log.Error().Err(err).Str("userID", txtid).Msg("Failed to send passkey confirmation") + s.Respond(w, r, http.StatusInternalServerError, err) + return + } + + // Now consume the state — the confirmation was sent successfully. + deletePendingPasskey(txtid) + + log.Info().Str("userID", txtid).Msg("Passkey confirmation sent successfully") + s.Respond(w, r, http.StatusOK, map[string]interface{}{"status": "passkey_confirmed"}) + } +} + +// GetPasskeyStatus returns whether there is a pending passkey request for this session +func (s *server) GetPasskeyStatus() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + pk := peekPendingPasskey(txtid) + response := map[string]interface{}{ + "passkeyPending": pk != nil && pk.Request != nil, + "publicKey": nil, + } + if pk != nil && pk.Request != nil { + response["publicKey"] = pk.Request.PublicKey + } + + responseJson, err := json.Marshal(response) + if err != nil { + s.Respond(w, r, http.StatusInternalServerError, err) + } else { + s.Respond(w, r, http.StatusOK, string(responseJson)) + } + } +} + // Gets Connected and LoggedIn Status func (s *server) GetStatus() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -821,6 +937,13 @@ func (s *server) GetStatus() http.HandlerFunc { } proxyConfig := proxyConfigResponse(proxyURL, webhookUseProxy) + passkeyPending := false + var publicKey interface{} = nil + if pk := peekPendingPasskey(txtid); pk != nil && pk.Request != nil { + passkeyPending = true + publicKey = pk.Request.PublicKey + } + response := map[string]interface{}{ "id": txtid, "name": userInfo.Get("Name"), @@ -832,6 +955,8 @@ func (s *server) GetStatus() http.HandlerFunc { "events": userInfo.Get("Events"), "proxy_url": userInfo.Get("Proxy"), "qrcode": userInfo.Get("Qrcode"), + "passkeyPending": passkeyPending, + "publicKey": publicKey, "history": userInfo.Get("History"), "proxy_config": proxyConfig, "s3_config": s3Config, diff --git a/main.go b/main.go index 0b7711cc..84a5780f 100644 --- a/main.go +++ b/main.go @@ -492,6 +492,9 @@ func main() { s.connectOnStartup() + // Start background cleanup of stale passkey pairing states (needed for both modes) + startPasskeyCleanup() + if serverMode == Stdio { startStdioMode(s) } else { @@ -557,6 +560,7 @@ func startHTTPMode(s *server) { } }() log.Info().Str("address", *address).Str("port", *port).Msg("Server started. Waiting for connections...") + select {} } diff --git a/passkey_test.go b/passkey_test.go new file mode 100644 index 00000000..4ab4bd75 --- /dev/null +++ b/passkey_test.go @@ -0,0 +1,489 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/mux" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" +) + +// ===================== Test helpers ===================== + +// resetPasskeyMap clears the global pending passkey map and registers a +// cleanup so it is always cleared after each test finishes. +func resetPasskeyMap(t *testing.T) { + t.Helper() + pendingPasskeyMu.Lock() + pendingPasskeyRequests = make(map[string]*PendingPasskeyState) + pendingPasskeyMu.Unlock() + t.Cleanup(func() { + pendingPasskeyMu.Lock() + pendingPasskeyRequests = make(map[string]*PendingPasskeyState) + pendingPasskeyMu.Unlock() + }) +} + +// addTestUserForPasskey inserts a minimal user row and returns its ID. +func addTestUserForPasskey(t *testing.T, s *server, token string) string { + t.Helper() + userID := "test-user-" + token + _, err := s.db.Exec( + "INSERT INTO users (id, name, token) VALUES (?, ?, ?)", + userID, "Test User", token, + ) + if err != nil { + t.Fatalf("insert test user: %v", err) + } + return userID +} + +// injectUserInfo simulates the authalice middleware by placing a Values +// context value on the request. +func injectUserInfo(r *http.Request, userID string) *http.Request { + v := Values{map[string]string{ + "Id": userID, + "Token": "test-token", + }} + return r.WithContext(context.WithValue(r.Context(), "userinfo", v)) +} + +// parseResponse unmarshals the recorder body and returns the map. +func parseResponse(t *testing.T, w *httptest.ResponseRecorder) map[string]interface{} { + t.Helper() + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON response: %v\nbody: %s", err, w.Body.String()) + } + return resp +} + +// assertErrorBody checks that the response is a JSON-RPC-style error +// envelope (as returned by s.Respond with an error) and that the error +// message contains the expected substring. +func assertErrorBody(t *testing.T, w *httptest.ResponseRecorder, expectedStatus int, substr string) { + t.Helper() + if w.Code != expectedStatus { + t.Fatalf("expected status %d, got %d", expectedStatus, w.Code) + } + resp := parseResponse(t, w) + if resp["success"] != false { + t.Fatalf("expected success=false, got %v", resp["success"]) + } + errMsg, _ := resp["error"].(string) + if !strings.Contains(errMsg, substr) { + t.Fatalf("expected error to contain %q, got %q", substr, errMsg) + } +} + +// ===================== Memory / lifecycle tests ===================== + +func TestPendingPasskeyState_Lifecycle(t *testing.T) { + resetPasskeyMap(t) + + t.Run("empty_map_returns_nil", func(t *testing.T) { + if pk := peekPendingPasskey("u1"); pk != nil { + t.Fatalf("peek expected nil, got %+v", pk) + } + if pk := getAndConsumePendingPasskey("u1"); pk != nil { + t.Fatalf("consume expected nil, got %+v", pk) + } + }) + + t.Run("peek_is_non_destructive", func(t *testing.T) { + state := &PendingPasskeyState{Request: &events.PairPasskeyRequest{}} + storePendingPasskey("u2", state) + + if pk := peekPendingPasskey("u2"); pk == nil { + t.Fatal("first peek: expected state") + } + if pk := peekPendingPasskey("u2"); pk == nil { + t.Fatal("second peek: expected state again") + } + }) + + t.Run("consume_removes_state", func(t *testing.T) { + state := &PendingPasskeyState{Request: &events.PairPasskeyRequest{}} + storePendingPasskey("u3", state) + + if pk := getAndConsumePendingPasskey("u3"); pk == nil { + t.Fatal("consume: expected state") + } + if pk := peekPendingPasskey("u3"); pk != nil { + t.Fatal("after consume: expected nil") + } + }) + + t.Run("double_consume_returns_nil", func(t *testing.T) { + state := &PendingPasskeyState{Request: &events.PairPasskeyRequest{}} + storePendingPasskey("u4", state) + + _ = getAndConsumePendingPasskey("u4") + if pk := getAndConsumePendingPasskey("u4"); pk != nil { + t.Fatal("second consume should return nil") + } + }) +} + +func TestPendingPasskeyState_DeleteExplicitly(t *testing.T) { + resetPasskeyMap(t) + + storePendingPasskey("d1", &PendingPasskeyState{Request: &events.PairPasskeyRequest{}}) + if peekPendingPasskey("d1") == nil { + t.Fatal("expected state after store") + } + + deletePendingPasskey("d1") + if peekPendingPasskey("d1") != nil { + t.Fatal("expected nil after explicit delete") + } +} + +func TestPendingPasskeyState_IsolationBetweenUsers(t *testing.T) { + resetPasskeyMap(t) + + storePendingPasskey("iso-a", &PendingPasskeyState{Request: &events.PairPasskeyRequest{}}) + storePendingPasskey("iso-b", &PendingPasskeyState{Request: &events.PairPasskeyRequest{}}) + + getAndConsumePendingPasskey("iso-a") + + if peekPendingPasskey("iso-a") != nil { + t.Fatal("user-a should be consumed") + } + if peekPendingPasskey("iso-b") == nil { + t.Fatal("user-b should still be present") + } +} + +func TestPendingPasskeyState_Overwrite(t *testing.T) { + resetPasskeyMap(t) + + state1 := &PendingPasskeyState{ + Request: &events.PairPasskeyRequest{}, + CreatedAt: time.Now().Add(-10 * time.Minute), + } + state2 := &PendingPasskeyState{ + Request: &events.PairPasskeyRequest{}, + CreatedAt: time.Now(), + } + + storePendingPasskey("ow", state1) + storePendingPasskey("ow", state2) // overwrite + + pk := peekPendingPasskey("ow") + if pk == nil { + t.Fatal("expected state after overwrite") + } + if pk.CreatedAt.Before(state2.CreatedAt.Add(-time.Second)) { + t.Fatal("second store should have overwritten the first") + } +} + +func TestPendingPasskeyState_TTLExpiry(t *testing.T) { + resetPasskeyMap(t) + + // Inject an old state directly (bypass store to set CreatedAt in the past) + pendingPasskeyMu.Lock() + pendingPasskeyRequests["old-user"] = &PendingPasskeyState{ + Request: &events.PairPasskeyRequest{}, + CreatedAt: time.Now().Add(-20 * time.Minute), + } + pendingPasskeyMu.Unlock() + + // Simulate cleanup (mirrors the goroutine logic) + now := time.Now() + pendingPasskeyMu.Lock() + for uid, st := range pendingPasskeyRequests { + if now.Sub(st.CreatedAt) > passkeyStateTTL { + delete(pendingPasskeyRequests, uid) + } + } + pendingPasskeyMu.Unlock() + + if peekPendingPasskey("old-user") != nil { + t.Fatal("expired state should have been cleaned up") + } +} + +func TestPendingPasskeyState_FreshStateSurvivesCleanup(t *testing.T) { + resetPasskeyMap(t) + + storePendingPasskey("fresh", &PendingPasskeyState{ + Request: &events.PairPasskeyRequest{}, + CreatedAt: time.Now(), + }) + + now := time.Now() + pendingPasskeyMu.Lock() + for uid, st := range pendingPasskeyRequests { + if now.Sub(st.CreatedAt) > passkeyStateTTL { + delete(pendingPasskeyRequests, uid) + } + } + pendingPasskeyMu.Unlock() + + if peekPendingPasskey("fresh") == nil { + t.Fatal("fresh state should survive cleanup") + } +} + +// getData extracts the "data" field from the standard s.Respond envelope. +func getData(t *testing.T, w *httptest.ResponseRecorder) map[string]interface{} { + t.Helper() + resp := parseResponse(t, w) + data, _ := resp["data"].(map[string]interface{}) + if data == nil { + t.Fatalf("expected data field in response, got: %v", resp) + } + return data +} + +// ===================== HTTP handler: GetPasskeyStatus ===================== + +func TestGetPasskeyStatus_NoPending(t *testing.T) { + resetPasskeyMap(t) + s := makeTestServer(t) + userID := addTestUserForPasskey(t, s, "ps-np") + + req := httptest.NewRequest("GET", "/session/passkey-status", nil) + req = mux.SetURLVars(req, map[string]string{"id": userID}) + req = injectUserInfo(req, userID) + w := httptest.NewRecorder() + s.GetPasskeyStatus()(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + data := getData(t, w) + if data["passkeyPending"] != false { + t.Fatalf("expected passkeyPending=false, got %v", data["passkeyPending"]) + } + if data["publicKey"] != nil { + t.Fatalf("expected publicKey=nil, got %v", data["publicKey"]) + } +} + +func TestGetPasskeyStatus_WithPending(t *testing.T) { + resetPasskeyMap(t) + s := makeTestServer(t) + userID := addTestUserForPasskey(t, s, "ps-wp") + + fakePK := &types.WebAuthnPublicKey{RelyingPartID: "whatsapp.com", Timeout: 600000} + storePendingPasskey(userID, &PendingPasskeyState{ + Request: &events.PairPasskeyRequest{PublicKey: fakePK}, + CreatedAt: time.Now(), + }) + + req := httptest.NewRequest("GET", "/session/passkey-status", nil) + req = mux.SetURLVars(req, map[string]string{"id": userID}) + req = injectUserInfo(req, userID) + w := httptest.NewRecorder() + s.GetPasskeyStatus()(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + data := getData(t, w) + if data["passkeyPending"] != true { + t.Fatalf("expected passkeyPending=true, got %v", data["passkeyPending"]) + } + if data["publicKey"] == nil { + t.Fatal("expected publicKey to be non-nil") + } +} + +func TestGetPasskeyStatus_StateWithNilRequest(t *testing.T) { + resetPasskeyMap(t) + s := makeTestServer(t) + userID := addTestUserForPasskey(t, s, "ps-nr") + + // Store a state where Request is nil + pendingPasskeyMu.Lock() + pendingPasskeyRequests[userID] = &PendingPasskeyState{ + Request: nil, + CreatedAt: time.Now(), + } + pendingPasskeyMu.Unlock() + + req := httptest.NewRequest("GET", "/session/passkey-status", nil) + req = mux.SetURLVars(req, map[string]string{"id": userID}) + req = injectUserInfo(req, userID) + w := httptest.NewRecorder() + s.GetPasskeyStatus()(w, req) + + data := getData(t, w) + // Request is nil → passkeyPending should be false + if data["passkeyPending"] != false { + t.Fatalf("expected passkeyPending=false when Request=nil, got %v", data["passkeyPending"]) + } +} + +// ===================== HTTP handler: GetQR ===================== + +// TestGetQR_NoSession_ReturnsNoSession verifies that GetQR returns a 500 +// with "no session" when the clientManager has no client for this user. +// This is the realistic path since we cannot spin up a real whatsmeow +// client in a unit test. +func TestGetQR_NoSession_ReturnsNoSession(t *testing.T) { + resetPasskeyMap(t) + s := makeTestServer(t) + userID := addTestUserForPasskey(t, s, "qr-ns") + + req := httptest.NewRequest("GET", "/session/qr", nil) + req = mux.SetURLVars(req, map[string]string{"id": userID}) + req = injectUserInfo(req, userID) + w := httptest.NewRecorder() + s.GetQR()(w, req) + + // GetQR checks clientManager first → no client = "no session" error + assertErrorBody(t, w, http.StatusInternalServerError, "no session") +} + +// TestGetQR_NoSession_DoesNotLeakPasskey verifies that even when a +// passkey is pending, the "no session" check happens first. The passkey +// state should remain unconsumed. +func TestGetQR_NoSession_DoesNotLeakPasskey(t *testing.T) { + resetPasskeyMap(t) + s := makeTestServer(t) + userID := addTestUserForPasskey(t, s, "qr-leak") + + fakePK := &types.WebAuthnPublicKey{RelyingPartID: "whatsapp.com"} + storePendingPasskey(userID, &PendingPasskeyState{ + Request: &events.PairPasskeyRequest{PublicKey: fakePK}, + CreatedAt: time.Now(), + }) + + req := httptest.NewRequest("GET", "/session/qr", nil) + req = mux.SetURLVars(req, map[string]string{"id": userID}) + req = injectUserInfo(req, userID) + w := httptest.NewRecorder() + s.GetQR()(w, req) + + // Should fail with "no session" + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", w.Code) + } + + // Passkey state should NOT have been consumed + if pk := peekPendingPasskey(userID); pk == nil { + t.Fatal("passkey state should still exist after GetQR 'no session' error") + } +} + +// ===================== HTTP handler: PasskeyResponse ===================== + +func TestPasskeyResponse_NoPendingReturns400(t *testing.T) { + resetPasskeyMap(t) + s := makeTestServer(t) + userID := addTestUserForPasskey(t, s, "pr-400") + + body := `{"response":{"id":"x","rawId":"x","type":"public-key","response":{"clientDataJSON":"x","authenticatorData":"x","signature":"x","userHandle":null}}}` + req := httptest.NewRequest("POST", "/session/passkey-response", strings.NewReader(body)) + req = mux.SetURLVars(req, map[string]string{"id": userID}) + req = injectUserInfo(req, userID) + w := httptest.NewRecorder() + s.PasskeyResponse()(w, req) + + assertErrorBody(t, w, http.StatusBadRequest, "no pending passkey") +} + +func TestPasskeyResponse_InvalidJSON_Returns400AndRestoresState(t *testing.T) { + resetPasskeyMap(t) + s := makeTestServer(t) + userID := addTestUserForPasskey(t, s, "pr-badjson") + + fakePK := &types.WebAuthnPublicKey{RelyingPartID: "whatsapp.com"} + storePendingPasskey(userID, &PendingPasskeyState{ + Request: &events.PairPasskeyRequest{PublicKey: fakePK}, + CreatedAt: time.Now(), + }) + + req := httptest.NewRequest("POST", "/session/passkey-response", strings.NewReader("NOT_JSON")) + req = mux.SetURLVars(req, map[string]string{"id": userID}) + req = injectUserInfo(req, userID) + w := httptest.NewRecorder() + s.PasskeyResponse()(w, req) + + assertErrorBody(t, w, http.StatusBadRequest, "could not decode") + + // State should be restored so the user can retry + if pk := peekPendingPasskey(userID); pk == nil { + t.Fatal("state should be restored after decode error") + } +} + +func TestPasskeyResponse_MissingResponseField_Returns400AndRestoresState(t *testing.T) { + resetPasskeyMap(t) + s := makeTestServer(t) + userID := addTestUserForPasskey(t, s, "pr-noresp") + + fakePK := &types.WebAuthnPublicKey{RelyingPartID: "whatsapp.com"} + storePendingPasskey(userID, &PendingPasskeyState{ + Request: &events.PairPasskeyRequest{PublicKey: fakePK}, + CreatedAt: time.Now(), + }) + + // Valid JSON but missing the "response" field + req := httptest.NewRequest("POST", "/session/passkey-response", strings.NewReader("{}")) + req = mux.SetURLVars(req, map[string]string{"id": userID}) + req = injectUserInfo(req, userID) + w := httptest.NewRecorder() + s.PasskeyResponse()(w, req) + + assertErrorBody(t, w, http.StatusBadRequest, "missing response") + + // State should be restored + if pk := peekPendingPasskey(userID); pk == nil { + t.Fatal("state should be restored after missing response field") + } +} + +// ===================== HTTP handler: PasskeyConfirm ===================== + +func TestPasskeyConfirm_NoPendingReturns400(t *testing.T) { + resetPasskeyMap(t) + s := makeTestServer(t) + userID := addTestUserForPasskey(t, s, "pc-400") + + req := httptest.NewRequest("POST", "/session/passkey-confirm", nil) + req = mux.SetURLVars(req, map[string]string{"id": userID}) + req = injectUserInfo(req, userID) + w := httptest.NewRecorder() + s.PasskeyConfirm()(w, req) + + assertErrorBody(t, w, http.StatusBadRequest, "no pending passkey") +} + +func TestPasskeyConfirm_StateWithNilClientReturns400(t *testing.T) { + resetPasskeyMap(t) + s := makeTestServer(t) + userID := addTestUserForPasskey(t, s, "pc-nil") + + // State exists but Client is nil + pendingPasskeyMu.Lock() + pendingPasskeyRequests[userID] = &PendingPasskeyState{ + Request: &events.PairPasskeyRequest{}, + Client: nil, + CreatedAt: time.Now(), + } + pendingPasskeyMu.Unlock() + + req := httptest.NewRequest("POST", "/session/passkey-confirm", nil) + req = mux.SetURLVars(req, map[string]string{"id": userID}) + req = injectUserInfo(req, userID) + w := httptest.NewRecorder() + s.PasskeyConfirm()(w, req) + + assertErrorBody(t, w, http.StatusBadRequest, "no pending passkey") + + // State should still exist (peek was used, not consume) + if pk := peekPendingPasskey(userID); pk == nil { + t.Fatal("state should still exist after nil-client check") + } +} diff --git a/routes.go b/routes.go index ddb53655..d818e7d6 100644 --- a/routes.go +++ b/routes.go @@ -84,6 +84,9 @@ func (s *server) routes() { s.router.Handle("/session/status", c.Then(s.GetStatus())).Methods("GET") s.router.Handle("/session/qr", c.Then(s.GetQR())).Methods("GET") s.router.Handle("/session/pairphone", c.Then(s.PairPhone())).Methods("POST") + s.router.Handle("/session/passkey-response", c.Then(s.PasskeyResponse())).Methods("POST") + s.router.Handle("/session/passkey-confirm", c.Then(s.PasskeyConfirm())).Methods("POST") + s.router.Handle("/session/passkey-status", c.Then(s.GetPasskeyStatus())).Methods("GET") s.router.Handle("/session/history", c.Then(s.RequestHistorySync())).Methods("GET") s.router.Handle("/webhook", c.Then(s.SetWebhook())).Methods("POST") diff --git a/static/api/spec.yml b/static/api/spec.yml index d6d3a5ec..db45d4a5 100644 --- a/static/api/spec.yml +++ b/static/api/spec.yml @@ -338,7 +338,7 @@ paths: tags: - Session summary: connects to WhatsApp servers - description: "Initiates connection to WhatsApp servers.\n\nIf there is no previous session created, it will generate a QR code that can be retrieved via the [qr](#/Session/get_session_qr) API call.\n\nIf the optional Subscribe is supplied it will limit webhooks to the specified event types: Message,ReadReceipt,Presence,HistorySync,ChatPresence.\n\nIf no Subscribe is supplied it will subscribe to All events.\n\nIf Immediate is set to false, the action will wait for 10 seconds to retrieve actual connection status from whatsapp, otherwise it will return immediatly.\n\nWhen setting Immediate to true you should check for actual connection status after a few seconds via the [status](#/Session/get_session_status) API call as your connection might fail if the session was closed from another device." + description: "Initiates connection to WhatsApp servers.\n\nIf there is no previous session created, it will generate a QR code that can be retrieved via the [qr](#/Session/get_session_qr) API call. Alternatively, the device may initiate passkey pairing instead of QR — in that case, a PasskeyRequest webhook will be sent with the challenge data.\n\nIf the optional Subscribe is supplied it will limit webhooks to the specified event types: Message,ReadReceipt,Presence,HistorySync,ChatPresence.\n\nIf no Subscribe is supplied it will subscribe to All events.\n\nIf Immediate is set to false, the action will wait for 10 seconds to retrieve actual connection status from whatsapp, otherwise it will return immediatly.\n\nWhen setting Immediate to true you should check for actual connection status after a few seconds via the [status](#/Session/get_session_status) API call as your connection might fail if the session was closed from another device." security: - ApiKeyAuth: [] requestBody: @@ -435,7 +435,7 @@ paths: tags: - Session summary: Gets QR code for scanning - description: Gets QR code if the user is connected but not logged in. If the user is already logged in, QRCode will be empty. + description: Gets QR code if the user is connected but not logged in and the device is pairing via QR. If the user is already logged in, QRCode will be empty. If the device initiated passkey pairing instead of QR, QRCode will also be empty — in that case the pairing data is delivered via PasskeyRequest webhook. security: - ApiKeyAuth: [] responses: @@ -444,7 +444,70 @@ paths: content: application/json: schema: - example: { "code": 200, "data": { "QRCode": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEAAQMAAABmvDolAAAABlBMVEX///8AAABVwtN+AAAEw0lEQVR42uyZPa7zqhaGX0ThLmsCkZlGCktMKaU76FxmSkgUmQZWJkA6CuT3avlLvrNvvRMX9x6KXWQ/UhCsn2cR/Lv+v5YhudQ6njEs1bBjqGYDwlJJpoOAArtUbK4Pi5jN3qPAlCkstcAeBazMUaoj78RpxGW4yWYzWVfmzwFLlLX4O+VkkucN5tFDOxiIAvfoA/X4uVQ4sgUcCBTYCG7AEGGKvbdrBabQ8OOyvg3ovm4ynqfLXJ9rvi+303ie5vm/gvZXgK6BLC7fo5hiG4KwW7b6I/2+DJi1+ybVFQyx6o6bbKPVDCyjTwcBZB9uevBtAEafhiosCFH/4kNA8i1gg02B3KxezGbzEjUCDgIwYppR3SNdgtY3H0M1j8xFzCscvg/8uQvZAB9piidv1RXfZhbHdAwAlzsCNCaJDdMF4WQeeSGACZ8BMNl4FZYJA7j2YalPPhhngetHAaZPcyBg2wyYdAk0fKQ5yPja5PcBzTZW4uxJ2bTGwmxnu/BH4vwSgEsYItcCH+VZJt/AYhmHatbXdX8d2JvaTVzxCVW2aVhqheXSqvnR9b4L6AoUx3zX+jZd5rDB5jbLuv0txd8GRs+liuv+TsKloQWujxxRYf5s8gOA7fMVK9PQuDtMNCx2ibIdCMCy1s0yQU6Od9bqim1BuzoOAgzTHOiKv0d5Mt+XClN8DBxN/wxg2G2DbDYNJExCqE+Ne8poXoLxdUA/w5VrnxBQ9fjlqaJMwWgPAzLjtfKRW4A21ojnStX0dX2d5PeB0fawu2pChcuM4bk+tLmbMn0GMJslb5ptDXySbb5W1+0SyVcJOgRIQxSc7X0RUSvGs2DSeaz4gwCMNi/7XNACZc0KbPBtruv2KQA+DVFladBvt4xywhmh1Xd2fx8wzGTUltqCWrHWgqL7Jg8E0hSiFJfbUJ/Fpx3L1OHsVR8+APgoZMclUKvcft2+zTBrwjHArosim4ZcfW4Y4lVWnYXg2A8C9C5aEFXDoEJzmXFyfZoH/p0Wvw7oXoZbNQ823ase1wk2DQ3u7XK/BkzOqovwpM68Ko+jUyPFu6F8H4DvqsAuaUMZJ6+azjTPdS32KMBkLnpQ3VPnbsZgiktALW91/wDQEV5V7gT4JT6L62GRzeV0EDDC7rVFax2ZW6Aa6V5h/FEAgBlSbLrMVScU1s09+jxwG/9q87cB/Yxw3acBsk2Yw+nPf9Y1p88ARlNPtvPkF3LlPQYp8MtSx/FtpF8H4DNrZd8fOtTOxJSzXdo/c/fXAbN2DLeKs1dxHeEZZVWaju/3h18CcDk3qePZpllglDZ89MCq8nIQoDPAVaPi3iAFFwS1xjjr+HcYwD+hri216vBZzQbbZsE44RhAp+sQxfTpApGCoV1NOfsl4pX+nwC65a1uLnkK9TSuVTOhaQ4cBOzvtDcZXU5Bdl28SrF9HqrZJhwD7O/VsZpi7xSz7pXW6ahQ1/dB/RrYf2QhLBmr1lNINVRZfw9BBwArc4SszGlWWd2fxB9cFvJQYKnUUWAgV22y5v1e/ffHpiOAqMLCiOpymwNGtxvk9s8mfwcU2CiydqvJbdKuSX0K8a/KHQDsMQkyeVbtISFif8mRcfwRtF8F/l3/O+s/AQAA///lM0dZSaTeTQAAAABJRU5ErkJggg==" }, "success": true } + example: { "code": 200, "data": { "QRCode": "data:image/png;base64,...", "passkeyPending": true, "publicKey": { "allowCredentials": [], "challenge": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "extensions": { "uvm": true }, "rpId": "whatsapp.com", "timeout": 600000, "userVerification": "required" } }, "success": true } + + In addition to QRCode, the response also includes passkey pairing state: + - passkeyPending (boolean): true if the device initiated passkey pairing instead of QR + - publicKey (object|null): the WebAuthn challenge data when passkeyPending is true + /session/passkey-status: + get: + tags: + - Session + summary: Get passkey pairing status + description: "Checks if there is a pending passkey pairing request for this session.\n\nReturns passkeyPending=true with the publicKey challenge data if the device initiated passkey pairing instead of QR." + security: + - ApiKeyAuth: [] + responses: + 200: + description: Response + content: + application/json: + schema: + example: { "code": 200, "data": { "passkeyPending": true, "publicKey": { "allowCredentials": [], "challenge": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "extensions": { "uvm": true }, "rpId": "whatsapp.com", "timeout": 600000, "userVerification": "required" } }, "success": true } + /session/passkey-response: + post: + tags: + - Session + summary: Send passkey WebAuthn response + description: "Sends the WebAuthn response from the authenticator to complete passkey pairing.\n\nThis endpoint must be called after receiving a webhook with the PasskeyRequest event containing the challenge data." + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/PasskeyResponse' + responses: + 200: + description: Response + content: + application/json: + schema: + example: { "code": 200, "data": { "status": "passkey_response_sent" }, "success": true } + 400: + description: No pending passkey request + 500: + description: Failed to send passkey response + /session/passkey-confirm: + post: + tags: + - Session + summary: Confirm passkey pairing code + description: "Confirms that the 8-character pairing code was displayed to the user and matches the code on the phone.\n\nThis endpoint must be called after receiving a webhook with the PasskeyConfirmation event." + security: + - ApiKeyAuth: [] + responses: + 200: + description: Response + content: + application/json: + schema: + example: { "code": 200, "data": { "status": "passkey_confirmed" }, "success": true } + 400: + description: No pending passkey confirmation + 500: + description: Failed to send passkey confirmation /session/proxy: post: tags: @@ -3278,6 +3341,49 @@ definitions: description: Number of days to retain files (0 for no expiration) example: 30 + PasskeyResponse: + type: object + required: + - response + properties: + response: + type: object + description: WebAuthn response object from navigator.credentials.get() + properties: + id: + type: string + description: Base64url-encoded credential identifier + example: "ABCD1234..." + rawId: + type: string + description: Base64url-encoded raw credential identifier + example: "ABCD1234..." + type: + type: string + description: Credential type + example: "public-key" + response: + type: object + description: Authenticator response data + properties: + clientDataJSON: + type: string + description: Base64url-encoded client data + example: "eyJ0eXBlIjoi..." + authenticatorData: + type: string + description: Base64url-encoded authenticator data + example: "SZYN5YgO..." + signature: + type: string + description: Base64url-encoded signature + example: "MEUCIQD..." + userHandle: + type: string + nullable: true + description: Base64url-encoded user handle (can be null) + example: null + components: securitySchemes: ApiKeyAuth: diff --git a/static/dashboard/index.html b/static/dashboard/index.html index e31c8edb..b0a269b2 100644 --- a/static/dashboard/index.html +++ b/static/dashboard/index.html @@ -1309,6 +1309,44 @@
Open https://web.whatsapp.com, press F12, paste in the console and press Enter.
+Loading...+ +
+ WhatsApp requested passkey verification. +
+ +