qr.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. package login
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/rand"
  6. "crypto/rsa"
  7. "crypto/sha256"
  8. "crypto/x509"
  9. "encoding/base64"
  10. "encoding/json"
  11. "errors"
  12. "fmt"
  13. "io"
  14. "log/slog"
  15. stdhttp "net/http"
  16. "strings"
  17. "time"
  18. "github.com/ayn2op/discordo/internal/config"
  19. apphttp "github.com/ayn2op/discordo/internal/http"
  20. "github.com/ayn2op/discordo/internal/ui"
  21. "github.com/ayn2op/tview"
  22. "github.com/diamondburned/arikawa/v3/api"
  23. "github.com/gdamore/tcell/v3"
  24. "github.com/gorilla/websocket"
  25. "github.com/skip2/go-qrcode"
  26. )
  27. const gatewayURL = "wss://remote-auth-gateway.discord.gg/?v=2"
  28. var endpointRemoteAuthLogin = api.EndpointMe + "/remote-auth/login"
  29. type qrLogin struct {
  30. *tview.TextView
  31. app *tview.Application
  32. cfg *config.Config
  33. done func(token string, err error)
  34. conn *websocket.Conn
  35. privKey *rsa.PrivateKey
  36. cancel context.CancelFunc
  37. fingerprint string
  38. }
  39. func newQRLogin(app *tview.Application, cfg *config.Config, done func(token string, err error)) *qrLogin {
  40. q := &qrLogin{
  41. TextView: tview.NewTextView(),
  42. app: app,
  43. cfg: cfg,
  44. done: done,
  45. }
  46. q.Box = ui.ConfigureBox(q.Box, &cfg.Theme)
  47. q.
  48. SetDynamicColors(true).
  49. SetScrollable(true).
  50. SetWrap(false).
  51. SetTextAlign(tview.AlignmentCenter).
  52. SetChangedFunc(func() {
  53. q.app.QueueUpdateDraw(func() {})
  54. }).
  55. SetTitle("Login with QR").
  56. SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
  57. if ev.Key() == tcell.KeyEsc {
  58. q.stop()
  59. if q.done != nil {
  60. q.done("", nil)
  61. }
  62. return nil
  63. }
  64. return ev
  65. })
  66. return q
  67. }
  68. func (q *qrLogin) centerText(s string) string {
  69. _, _, _, height := q.GetInnerRect()
  70. if height == 0 {
  71. height = 40
  72. }
  73. lines := strings.Count(s, "\n") + 1
  74. padding := (height - lines) / 2
  75. if padding < 0 {
  76. padding = 0
  77. } else if padding < 1 && height > lines {
  78. padding = 1
  79. }
  80. return strings.Repeat("\n", padding) + s
  81. }
  82. func (q *qrLogin) start() {
  83. ctx, cancel := context.WithCancel(context.Background())
  84. q.cancel = cancel
  85. go q.run(ctx)
  86. }
  87. func (q *qrLogin) stop() {
  88. if q.cancel != nil {
  89. q.cancel()
  90. }
  91. if q.conn != nil {
  92. q.conn.Close()
  93. }
  94. }
  95. func (q *qrLogin) setText(s string) {
  96. q.app.QueueUpdateDraw(func() {
  97. q.SetText(q.centerText(s))
  98. })
  99. }
  100. func (q *qrLogin) writeJSON(data any) error {
  101. return q.conn.WriteJSON(data)
  102. }
  103. type raHello struct {
  104. TimeoutMs int `json:"timeout_ms"`
  105. HeartbeatInterval int `json:"heartbeat_interval"`
  106. }
  107. type raNonceProof struct {
  108. EncryptedNonce string `json:"encrypted_nonce"`
  109. }
  110. type raPendingInit struct {
  111. Fingerprint string `json:"fingerprint"`
  112. }
  113. type raPendingLogin struct {
  114. Ticket string `json:"ticket"`
  115. }
  116. type raPendingTicket struct {
  117. EncryptedUserPayload string `json:"encrypted_user_payload"`
  118. }
  119. func (q *qrLogin) run(ctx context.Context) {
  120. defer q.stop()
  121. setText := func(s string) {
  122. q.app.QueueUpdateDraw(func() {
  123. q.SetText(q.centerText(s))
  124. })
  125. }
  126. setText("Preparing QR code...\n\n[::d]Press Esc to cancel[-]")
  127. privKey, err := rsa.GenerateKey(rand.Reader, 2048)
  128. if err != nil {
  129. q.fail(err)
  130. return
  131. }
  132. q.privKey = privKey
  133. pubDER, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey)
  134. if err != nil {
  135. q.fail(err)
  136. return
  137. }
  138. encodedPublicKey := base64.StdEncoding.EncodeToString(pubDER)
  139. headers := stdhttp.Header{}
  140. headers.Set("User-Agent", apphttp.BrowserUserAgent)
  141. headers.Set("Origin", "https://discord.com")
  142. q.setText("Connecting to Remote Auth Gateway...\n\n[::d]Press Esc to cancel[-]")
  143. dialer := websocket.Dialer{
  144. Proxy: stdhttp.ProxyFromEnvironment,
  145. HandshakeTimeout: 15 * time.Second,
  146. EnableCompression: true,
  147. }
  148. conn, resp, err := dialer.DialContext(ctx, gatewayURL, headers)
  149. if err != nil {
  150. var body []byte
  151. if resp != nil && resp.Body != nil {
  152. body, _ = io.ReadAll(resp.Body)
  153. }
  154. status := ""
  155. if resp != nil {
  156. status = resp.Status
  157. }
  158. q.fail(fmt.Errorf("websocket dial failed: %w, status=%s, body=%s", err, status, string(body)))
  159. return
  160. }
  161. q.conn = conn
  162. readCh := make(chan []byte, 1)
  163. readErr := make(chan error, 1)
  164. go func() {
  165. for {
  166. _, data, err := conn.ReadMessage()
  167. if err != nil {
  168. readErr <- err
  169. return
  170. }
  171. readCh <- data
  172. }
  173. }()
  174. var heartbeatTicker *time.Ticker
  175. defer func() {
  176. if heartbeatTicker != nil {
  177. heartbeatTicker.Stop()
  178. }
  179. }()
  180. for {
  181. select {
  182. case <-ctx.Done():
  183. return
  184. case err := <-readErr:
  185. if mapped := mapWSCloseError(err); mapped == nil {
  186. return
  187. } else {
  188. q.fail(mapped)
  189. }
  190. return
  191. case data := <-readCh:
  192. var opOnly struct {
  193. Op string `json:"op"`
  194. }
  195. if err := json.Unmarshal(data, &opOnly); err != nil {
  196. q.setText("[red]Bad JSON:[-] " + err.Error())
  197. q.fail(err)
  198. return
  199. }
  200. switch opOnly.Op {
  201. case "hello":
  202. var h raHello
  203. if err := json.Unmarshal(data, &h); err != nil {
  204. q.setText("[red]Hello decode failed:[-] " + err.Error())
  205. q.fail(err)
  206. return
  207. }
  208. if h.HeartbeatInterval > 0 {
  209. heartbeatTicker = time.NewTicker(time.Duration(h.HeartbeatInterval) * time.Millisecond)
  210. go func() {
  211. for {
  212. select {
  213. case <-ctx.Done():
  214. return
  215. case <-heartbeatTicker.C:
  216. q.writeJSON(map[string]any{"op": "heartbeat"})
  217. }
  218. }
  219. }()
  220. }
  221. q.setText("Connected. Handshaking...\n\n[::d]Press Esc to cancel[-]")
  222. if err := q.writeJSON(map[string]any{
  223. "op": "init",
  224. "encoded_public_key": encodedPublicKey,
  225. }); err != nil {
  226. q.setText("[red]Init send failed:[-] " + err.Error())
  227. q.fail(err)
  228. return
  229. }
  230. case "nonce_proof":
  231. var n raNonceProof
  232. if err := json.Unmarshal(data, &n); err != nil {
  233. q.setText("[red]Nonce decode failed:[-] " + err.Error())
  234. q.fail(err)
  235. return
  236. }
  237. enc, err := base64.StdEncoding.DecodeString(n.EncryptedNonce)
  238. if err != nil {
  239. q.setText("[red]Nonce b64 decode failed:[-] " + err.Error())
  240. q.fail(err)
  241. return
  242. }
  243. pt, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, q.privKey, enc, nil)
  244. if err != nil {
  245. q.setText("[red]Nonce decrypt failed:[-] " + err.Error())
  246. q.fail(err)
  247. return
  248. }
  249. nonce := base64.RawURLEncoding.EncodeToString(pt)
  250. if err := q.writeJSON(map[string]any{"op": "nonce_proof", "nonce": nonce}); err != nil {
  251. q.setText("[red]Nonce send failed:[-] " + err.Error())
  252. q.fail(err)
  253. return
  254. }
  255. case "pending_remote_init":
  256. var p raPendingInit
  257. if err := json.Unmarshal(data, &p); err != nil {
  258. q.setText("[red]Init decode failed:[-] " + err.Error())
  259. q.fail(err)
  260. return
  261. }
  262. q.fingerprint = p.Fingerprint
  263. content := "https://discord.com/ra/" + p.Fingerprint
  264. ascii, err := renderQR(content)
  265. if err != nil {
  266. q.setText("[red]QR render failed:[-] " + err.Error())
  267. q.fail(err)
  268. return
  269. }
  270. q.setText(ascii + "\n\n[::b]Scan with Discord mobile app[::-]\n\n[::d]Press Esc to cancel[-]")
  271. case "heartbeat_ack":
  272. case "pending_ticket":
  273. var t raPendingTicket
  274. if err := json.Unmarshal(data, &t); err != nil {
  275. q.setText("[red]Ticket decode failed:[-] " + err.Error())
  276. q.fail(err)
  277. return
  278. }
  279. payload, err := base64.StdEncoding.DecodeString(t.EncryptedUserPayload)
  280. if err != nil {
  281. q.setText("[red]Ticket payload b64 failed:[-] " + err.Error())
  282. q.fail(err)
  283. return
  284. }
  285. pt, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, q.privKey, payload, nil)
  286. if err != nil {
  287. q.setText("[red]Ticket payload decrypt failed:[-] " + err.Error())
  288. q.fail(err)
  289. return
  290. }
  291. parts := strings.SplitN(string(pt), ":", 4)
  292. var discriminator, username string
  293. if len(parts) == 4 {
  294. discriminator = parts[1]
  295. username = parts[3]
  296. }
  297. if discriminator == "" && username == "" {
  298. q.setText("Scan received.\n\nWaiting for approval on mobile...\n\n[::d]Press Esc to cancel[-]")
  299. } else {
  300. q.setText("Logging in as [::b]" + username + "[#]" + discriminator + "[::-]\n\nConfirm on mobile...\n\n[::d]Press Esc to cancel[-]")
  301. }
  302. case "pending_login":
  303. var p raPendingLogin
  304. if err := json.Unmarshal(data, &p); err != nil {
  305. q.setText("[red]Login decode failed:[-] " + err.Error())
  306. q.fail(err)
  307. return
  308. }
  309. q.setText("Authenticating...\n\n[::d]Please wait[-]")
  310. token, err := exchangeTicket(ctx, p.Ticket, q.fingerprint, q.privKey)
  311. if err != nil {
  312. q.setText("[red]Ticket exchange failed:[-] " + err.Error())
  313. q.fail(err)
  314. return
  315. }
  316. q.success(token)
  317. return
  318. case "cancel":
  319. q.setText("Login canceled on mobile")
  320. if q.done != nil {
  321. q.done("", nil)
  322. }
  323. return
  324. default:
  325. }
  326. }
  327. }
  328. }
  329. func renderQR(content string) (string, error) {
  330. code, err := qrcode.New(content, qrcode.Low)
  331. if err != nil {
  332. return "", err
  333. }
  334. bm := code.Bitmap()
  335. var b strings.Builder
  336. for y := 0; y < len(bm); y += 2 {
  337. for x := range bm[y] {
  338. top := bm[y][x]
  339. bottom := false
  340. if y+1 < len(bm) {
  341. bottom = bm[y+1][x]
  342. }
  343. if top && bottom {
  344. b.WriteString("█")
  345. } else if top && !bottom {
  346. b.WriteString("▀")
  347. } else if !top && bottom {
  348. b.WriteString("▄")
  349. } else {
  350. b.WriteString(" ")
  351. }
  352. }
  353. b.WriteByte('\n')
  354. }
  355. return b.String(), nil
  356. }
  357. func exchangeTicket(ctx context.Context, ticket string, fingerprint string, priv *rsa.PrivateKey) (string, error) {
  358. if ticket == "" {
  359. return "", errors.New("empty ticket")
  360. }
  361. body := map[string]string{"ticket": ticket}
  362. raw, err := json.Marshal(body)
  363. if err != nil {
  364. return "", err
  365. }
  366. req, err := stdhttp.NewRequestWithContext(ctx, stdhttp.MethodPost, endpointRemoteAuthLogin, bytes.NewReader(raw))
  367. if err != nil {
  368. return "", err
  369. }
  370. req.Header = apphttp.Headers()
  371. req.Header.Set("Content-Type", "application/json")
  372. req.Header.Set("User-Agent", apphttp.BrowserUserAgent)
  373. if fingerprint != "" {
  374. req.Header.Set("X-Fingerprint", fingerprint)
  375. req.Header.Set("Referer", "https://discord.com/ra/"+fingerprint)
  376. }
  377. client := &stdhttp.Client{Transport: apphttp.NewTransport(), Timeout: 20 * time.Second}
  378. resp, err := client.Do(req)
  379. if err != nil {
  380. return "", err
  381. }
  382. defer resp.Body.Close()
  383. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  384. b, _ := io.ReadAll(resp.Body)
  385. return "", fmt.Errorf("remote-auth login failed: %s: %s", resp.Status, string(b))
  386. }
  387. decoder := json.NewDecoder(resp.Body)
  388. var out struct {
  389. EncryptedToken string `json:"encrypted_token"`
  390. }
  391. if err := decoder.Decode(&out); err != nil {
  392. return "", err
  393. }
  394. if out.EncryptedToken == "" {
  395. return "", fmt.Errorf("no encrypted_token in response")
  396. }
  397. enc, err := base64.StdEncoding.DecodeString(out.EncryptedToken)
  398. if err != nil {
  399. return "", err
  400. }
  401. pt, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, priv, enc, nil)
  402. if err != nil {
  403. return "", err
  404. }
  405. return string(pt), nil
  406. }
  407. func (q *qrLogin) success(token string) {
  408. if q.done != nil {
  409. q.done(token, nil)
  410. }
  411. }
  412. func (q *qrLogin) fail(err error) {
  413. slog.Error("qr login failed", "err", err)
  414. if q.done != nil {
  415. q.done("", err)
  416. }
  417. }
  418. func mapWSCloseError(err error) error {
  419. var cerr *websocket.CloseError
  420. if errors.As(err, &cerr) {
  421. switch cerr.Code {
  422. case 1000:
  423. return errors.New("session closed")
  424. case 4000:
  425. return errors.New("remote auth: invalid version")
  426. case 4001:
  427. return errors.New("remote auth: decode error")
  428. case 4002:
  429. return errors.New("remote auth: handshake failure")
  430. case 4003:
  431. return errors.New("remote auth: session timed out")
  432. }
  433. }
  434. return err
  435. }