1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5"use strict";
6
7/**
8 *  Lookup keys by email addresses using WKD. A an email address is lookep up at most
9 *  once a day. (see https://tools.ietf.org/html/draft-koch-openpgp-webkey-service)
10 */
11
12var EXPORTED_SYMBOLS = ["EnigmailWkdLookup"];
13
14const { XPCOMUtils } = ChromeUtils.import(
15  "resource://gre/modules/XPCOMUtils.jsm"
16);
17
18XPCOMUtils.defineLazyModuleGetters(this, {
19  DNS: "resource:///modules/DNS.jsm",
20  EnigmailData: "chrome://openpgp/content/modules/data.jsm",
21  EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm",
22  EnigmailLog: "chrome://openpgp/content/modules/log.jsm",
23  EnigmailSqliteDb: "chrome://openpgp/content/modules/sqliteDb.jsm",
24  EnigmailZBase32: "chrome://openpgp/content/modules/zbase32.jsm",
25});
26
27Cu.importGlobalProperties(["fetch"]);
28
29// Those domains are not expected to have WKD:
30var BLACKLIST_DOMAINS = [
31  /* Default domains included */
32  "aol.com",
33  "att.net",
34  "comcast.net",
35  "facebook.com",
36  "gmail.com",
37  "gmx.com",
38  "googlemail.com",
39  "google.com",
40  "hotmail.com",
41  "hotmail.co.uk",
42  "mac.com",
43  "me.com",
44  "mail.com",
45  "msn.com",
46  "live.com",
47  "sbcglobal.net",
48  "verizon.net",
49  "yahoo.com",
50  "yahoo.co.uk",
51
52  /* Other global domains */
53  "email.com",
54  "games.com" /* AOL */,
55  "gmx.net",
56  "icloud.com",
57  "iname.com",
58  "inbox.com",
59  "lavabit.com",
60  "love.com" /* AOL */,
61  "outlook.com",
62  "pobox.com",
63  "tutanota.de",
64  "tutanota.com",
65  "tutamail.com",
66  "tuta.io",
67  "keemail.me",
68  "rocketmail.com" /* Yahoo */,
69  "safe-mail.net",
70  "wow.com" /* AOL */,
71  "ygm.com" /* AOL */,
72  "ymail.com" /* Yahoo */,
73  "zoho.com",
74  "yandex.com",
75
76  /* United States ISP domains */
77  "bellsouth.net",
78  "charter.net",
79  "cox.net",
80  "earthlink.net",
81  "juno.com",
82
83  /* British ISP domains */
84  "btinternet.com",
85  "virginmedia.com",
86  "blueyonder.co.uk",
87  "freeserve.co.uk",
88  "live.co.uk",
89  "ntlworld.com",
90  "o2.co.uk",
91  "orange.net",
92  "sky.com",
93  "talktalk.co.uk",
94  "tiscali.co.uk",
95  "virgin.net",
96  "wanadoo.co.uk",
97  "bt.com",
98
99  /* Domains used in Asia */
100  "sina.com",
101  "sina.cn",
102  "qq.com",
103  "naver.com",
104  "hanmail.net",
105  "daum.net",
106  "nate.com",
107  "yahoo.co.jp",
108  "yahoo.co.kr",
109  "yahoo.co.id",
110  "yahoo.co.in",
111  "yahoo.com.sg",
112  "yahoo.com.ph",
113  "163.com",
114  "yeah.net",
115  "126.com",
116  "21cn.com",
117  "aliyun.com",
118  "foxmail.com",
119
120  /* French ISP domains */
121  "hotmail.fr",
122  "live.fr",
123  "laposte.net",
124  "yahoo.fr",
125  "wanadoo.fr",
126  "orange.fr",
127  "gmx.fr",
128  "sfr.fr",
129  "neuf.fr",
130  "free.fr",
131
132  /* German ISP domains */
133  "gmx.de",
134  "hotmail.de",
135  "live.de",
136  "online.de",
137  "t-online.de" /* T-Mobile */,
138  "web.de",
139  "yahoo.de",
140
141  /* Italian ISP domains */
142  "libero.it",
143  "virgilio.it",
144  "hotmail.it",
145  "aol.it",
146  "tiscali.it",
147  "alice.it",
148  "live.it",
149  "yahoo.it",
150  "email.it",
151  "tin.it",
152  "poste.it",
153  "teletu.it",
154
155  /* Russian ISP domains */
156  "mail.ru",
157  "rambler.ru",
158  "yandex.ru",
159  "ya.ru",
160  "list.ru",
161
162  /* Belgian ISP domains */
163  "hotmail.be",
164  "live.be",
165  "skynet.be",
166  "voo.be",
167  "tvcablenet.be",
168  "telenet.be",
169
170  /* Argentinian ISP domains */
171  "hotmail.com.ar",
172  "live.com.ar",
173  "yahoo.com.ar",
174  "fibertel.com.ar",
175  "speedy.com.ar",
176  "arnet.com.ar",
177
178  /* Domains used in Mexico */
179  "yahoo.com.mx",
180  "live.com.mx",
181  "hotmail.es",
182  "hotmail.com.mx",
183  "prodigy.net.mx",
184
185  /* Domains used in Canada */
186  "yahoo.ca",
187  "hotmail.ca",
188  "bell.net",
189  "shaw.ca",
190  "sympatico.ca",
191  "rogers.com",
192
193  /* Domains used in Brazil */
194  "yahoo.com.br",
195  "hotmail.com.br",
196  "outlook.com.br",
197  "uol.com.br",
198  "bol.com.br",
199  "terra.com.br",
200  "ig.com.br",
201  "itelefonica.com.br",
202  "r7.com",
203  "zipmail.com.br",
204  "globo.com",
205  "globomail.com",
206  "oi.com.br",
207];
208
209var EnigmailWkdLookup = {
210  /**
211   * Try to import keys using WKD. Found keys are automatically imported
212   *
213   * @param {Array of String} emailList: email addresses (in lowercase)
214   *
215   * @return {Promise<Boolean>}: true - new keys found
216   */
217  findKeys(emails) {
218    return new Promise((resolve, reject) => {
219      EnigmailLog.DEBUG("wkdLookup.jsm: findKeys(" + emails.join(",") + ")\n");
220
221      if (emails.length === 0) {
222        resolve(false);
223        return;
224      }
225
226      let self = this;
227
228      // do a little sanity test such that we don't do the lookup for nothing too often
229      for (let e of emails) {
230        if (e.search(/.@.+\...+$/) < 0) {
231          resolve(false);
232          return;
233        }
234      }
235
236      Promise.all(
237        emails.map(function(mailAddr) {
238          return self.determineLastAttempt(mailAddr.trim().toLowerCase());
239        })
240      )
241        .then(function(checks) {
242          let toCheck = [];
243
244          EnigmailLog.DEBUG(
245            "wkdLookup.jsm: findKeys: checks " + checks.length + "\n"
246          );
247
248          for (let i = 0; i < checks.length; i++) {
249            if (checks[i]) {
250              EnigmailLog.DEBUG(
251                "wkdLookup.jsm: findKeys: recheck " + emails[i] + "\n"
252              );
253              toCheck.push(emails[i]);
254            } else {
255              EnigmailLog.DEBUG(
256                "wkdLookup.jsm: findKeys: skip check " + emails[i] + "\n"
257              );
258            }
259          }
260
261          if (toCheck.length > 0) {
262            Promise.all(
263              toCheck.map(email => {
264                return self.downloadKey(email);
265              })
266            ).then(dataArr => {
267              if (dataArr) {
268                let gotKeys = [];
269                for (let i = 0; i < dataArr.length; i++) {
270                  if (dataArr[i] !== null) {
271                    gotKeys.push(dataArr[i]);
272                  }
273                }
274
275                if (gotKeys.length > 0) {
276                  for (let k in gotKeys) {
277                    if (gotKeys[k]) {
278                      let isBinary =
279                        gotKeys[k].keyData.search(
280                          /^-----BEGIN PGP PUBLIC KEY BLOCK-----/
281                        ) < 0;
282                      EnigmailKeyRing.importKey(
283                        null,
284                        true,
285                        gotKeys[k].keyData,
286                        isBinary,
287                        "",
288                        {},
289                        {},
290                        false
291                      );
292                    }
293                  }
294                  resolve(true);
295                } else {
296                  resolve(false);
297                }
298              }
299            });
300          } else {
301            resolve(false);
302          }
303        })
304        .catch(() => {
305          resolve(false);
306        });
307    });
308  },
309
310  /**
311   * Determine for an email address when we last attempted to
312   * obtain a key via wkd
313   *
314   * @param {String} email: email address
315   *
316   * @return {Promise<Boolean>}: true if new WKD lookup required
317   */
318  async determineLastAttempt(email) {
319    EnigmailLog.DEBUG("wkdLookup.jsm: determineLastAttempt(" + email + ")\n");
320
321    let conn;
322    try {
323      conn = await EnigmailSqliteDb.openDatabase();
324      let val = await timeForRecheck(conn, email);
325      conn.close();
326      return val;
327    } catch (x) {
328      EnigmailLog.DEBUG(
329        "wkdLookup.jsm: determineLastAttempt: could not open database\n"
330      );
331      if (conn) {
332        EnigmailLog.DEBUG(
333          "wkdLookup.jsm: error - closing connection: " + x + "\n"
334        );
335        conn.close();
336      }
337    }
338    // in case something goes wrong we recheck anyway
339    return true;
340  },
341
342  /**
343   * get the download URL for an email address for WKD or domain-specific locations
344   *
345   * @param {String} email: email address
346   *
347   * @return {Promise<String>}: URL (or null if not possible)
348   */
349
350  async getDownloadUrlFromEmail(email, advancedMethod) {
351    email = email.toLowerCase().trim();
352
353    let url = await getSiteSpecificUrl(email);
354    if (url) {
355      return url;
356    }
357
358    let at = email.indexOf("@");
359
360    let domain = email.substr(at + 1);
361    let user = email.substr(0, at);
362
363    var converter = Cc[
364      "@mozilla.org/intl/scriptableunicodeconverter"
365    ].createInstance(Ci.nsIScriptableUnicodeConverter);
366    converter.charset = "UTF-8";
367    var data = converter.convertToByteArray(user, {});
368
369    var ch = Cc["@mozilla.org/security/hash;1"].createInstance(
370      Ci.nsICryptoHash
371    );
372    ch.init(ch.SHA1);
373    ch.update(data, data.length);
374    let gotHash = ch.finish(false);
375    let encodedHash = EnigmailZBase32.encode(gotHash);
376
377    if (advancedMethod) {
378      url =
379        "https://openpgpkey." +
380        domain +
381        "/.well-known/openpgpkey/" +
382        domain +
383        "/hu/" +
384        encodedHash +
385        "?l=" +
386        escape(user);
387    } else {
388      url =
389        "https://" +
390        domain +
391        "/.well-known/openpgpkey/hu/" +
392        encodedHash +
393        "?l=" +
394        escape(user);
395    }
396
397    return url;
398  },
399
400  /**
401   * Download a key for an email address
402   *
403   * @param {String} email: email address
404   *
405   * @return {Promise<String>}: Key data (or null if not possible)
406   */
407  async downloadKey(email) {
408    EnigmailLog.DEBUG("wkdLookup.jsm: downloadKey(" + email + ")\n");
409
410    if (!this.isWkdAvailable(email)) {
411      EnigmailLog.DEBUG("wkdLookup.jsm: downloadKey: no WKD for the domain\n");
412      return null;
413    }
414
415    let keyData = await this.doWkdKeyDownload(email, true);
416
417    if (!keyData) {
418      keyData = await this.doWkdKeyDownload(email, false);
419    }
420
421    return keyData;
422  },
423
424  async doWkdKeyDownload(email, advancedMethod) {
425    EnigmailLog.DEBUG(
426      `wkdLookup.jsm: doWkdKeyDownload(${email}, ${advancedMethod})\n`
427    );
428
429    let url = await EnigmailWkdLookup.getDownloadUrlFromEmail(
430      email,
431      advancedMethod
432    );
433
434    let padLen = (url.length % 512) + 1;
435    let hdrs = new Headers({
436      Authorization: "Basic " + btoa("no-user:"),
437    });
438    hdrs.append("Content-Type", "application/octet-stream");
439    hdrs.append("X-Enigmail-Padding", "x".padEnd(padLen, "x"));
440
441    let myRequest = new Request(url, {
442      method: "GET",
443      headers: hdrs,
444      mode: "cors",
445      //redirect: 'error',
446      redirect: "follow",
447      cache: "default",
448    });
449
450    let response;
451    try {
452      EnigmailLog.DEBUG(
453        "wkdLookup.jsm: doWkdKeyDownload: requesting " + url + "\n"
454      );
455      response = await fetch(myRequest);
456      if (!response.ok) {
457        return null;
458      }
459    } catch (ex) {
460      EnigmailLog.DEBUG(
461        "wkdLookup.jsm: doWkdKeyDownload: error " + ex.toString() + "\n"
462      );
463      return null;
464    }
465
466    try {
467      if (
468        response.headers.has("content-type") &&
469        response.headers.get("content-type").search(/^text\/html/i) === 0
470      ) {
471        // if we get HTML output, we return nothing (for example redirects to error catching pages)
472        return null;
473      }
474      let keyData = EnigmailData.arrayBufferToString(
475        Cu.cloneInto(await response.arrayBuffer(), this)
476      );
477      EnigmailLog.DEBUG(
478        `wkdLookup.jsm: doWkdKeyDownload: got data for ${email}\n`
479      );
480      return {
481        email,
482        keyData,
483      };
484    } catch (ex) {
485      EnigmailLog.DEBUG(
486        "wkdLookup.jsm: doWkdKeyDownload: error " + ex.toString() + "\n"
487      );
488      return null;
489    }
490  },
491
492  isWkdAvailable(email) {
493    let domain = email.toLowerCase().replace(/^.*@/, "");
494
495    return !BLACKLIST_DOMAINS.includes(domain);
496  },
497};
498
499/**
500 * Check if enough time has passed since we looked-up the key for "email".
501 *
502 * @param connection: Object - SQLite connection
503 * @param email:      String - Email address to search (in lowercase)
504 *
505 * @return Promise (true if new lookup required)
506 */
507function timeForRecheck(connection, email) {
508  EnigmailLog.DEBUG("wkdLookup.jsm: timeForRecheck\n");
509
510  let obj = {
511    email,
512    now: Date.now(),
513  };
514
515  return connection
516    .execute(
517      "select count(*) from wkd_lookup_timestamp where email = :email and :now - last_seen < 60*60*24",
518      obj
519    )
520    .then(function(val) {
521      return connection
522        .execute(
523          "insert or replace into wkd_lookup_timestamp values (:email, :now)",
524          obj
525        )
526        .then(function() {
527          return Promise.resolve(val);
528        });
529    })
530    .then(
531      function(rows) {
532        EnigmailLog.DEBUG(
533          "wkdLookup.jsm: timeForRecheck: " + rows.length + "\n"
534        );
535
536        return rows.length === 1 && rows[0].getResultByIndex(0) === 0;
537      },
538      function(error) {
539        EnigmailLog.DEBUG(
540          "wkdLookup.jsm: timeForRecheck - error" + error + "\n"
541        );
542        Promise.reject(error);
543      }
544    );
545}
546
547/**
548 * Get special URLs for specific sites that don't use WKD, but still provide
549 * public keys of their users in
550 *
551 * @param {String}: emailAddr: email address in lowercase
552 *
553 * @return {Promise<String>}: URL or null of no URL relevant
554 */
555async function getSiteSpecificUrl(emailAddr) {
556  let domain = emailAddr.replace(/^.+@/, "");
557  let url = null;
558
559  switch (domain) {
560    case "protonmail.ch":
561    case "protonmail.com":
562    case "pm.me":
563      url =
564        "https://api.protonmail.ch/pks/lookup?op=get&options=mr&search=" +
565        escape(emailAddr);
566      break;
567  }
568  if (!url) {
569    let records = await DNS.mx(domain);
570    const mxHosts = records.filter(record => record.host);
571    console.debug(mxHosts);
572
573    if (
574      mxHosts &&
575      (mxHosts.includes("mail.protonmail.ch") ||
576        mxHosts.includes("mailsec.protonmail.ch"))
577    ) {
578      url =
579        "https://api.protonmail.ch/pks/lookup?op=get&options=mr&search=" +
580        escape(emailAddr);
581    }
582  }
583  return url;
584}
585