qr.go 12 KB

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