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