OnionAliasStore.sys.mjs (16127B)
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 http://mozilla.org/MPL/2.0/. */ 4 5 import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 JSONFile: "resource://gre/modules/JSONFile.sys.mjs", 11 TorConnect: "resource://gre/modules/TorConnect.sys.mjs", 12 TorConnectTopics: "resource://gre/modules/TorConnect.sys.mjs", 13 TorRequestWatch: 14 "moz-src:///browser/components/onionservices/TorRequestWatch.sys.mjs", 15 }); 16 17 /* OnionAliasStore observer topics */ 18 export const OnionAliasStoreTopics = Object.freeze({ 19 ChannelsChanged: "onionaliasstore:channels-changed", 20 }); 21 22 const SECURE_DROP = { 23 name: "SecureDropTorOnion2021", 24 pathPrefix: "https://securedrop.org/https-everywhere-2021/", 25 jwk: { 26 kty: "RSA", 27 e: "AQAB", 28 n: "vsC7BNafkRe8Uh1DUgCkv6RbPQMdJgAKKnWdSqQd7tQzU1mXfmo_k1Py_2MYMZXOWmqSZ9iwIYkykZYywJ2VyMGve4byj1sLn6YQoOkG8g5Z3V4y0S2RpEfmYumNjTzfq8nxtLnwjaYd4sCUd5wa0SzeLrpRQuXo2bF3QuUF2xcbLJloxX1MmlsMMCdBc-qGNonLJ7bpn_JuyXlDWy1Fkeyw1qgjiOdiRIbMC1x302zgzX6dSrBrNB8Cpsh-vCE0ZjUo8M9caEv06F6QbYmdGJHM0ZZY34OHMSNdf-_qUKIV_SuxuSuFE99tkAeWnbWpyI1V-xhVo1sc7NzChP8ci2TdPvI3_0JyAuCvL6zIFqJUJkZibEUghhg6F09-oNJKpy7rhUJq7zZyLXJsvuXnn0gnIxfjRvMcDfZAKUVMZKRdw7fwWzwQril4Ib0MQOVda9vb_4JMk7Gup-TUI4sfuS4NKwsnKoODIO-2U5QpJWdtp1F4AQ1pBv8ajFl1WTrVGvkRGK0woPWaO6pWyJ4kRnhnxrV2FyNNt3JSR-0JEjhFWws47kjBvpr0VRiVRFppKA-plKs4LPlaaCff39TleYmY3mETe3w1GIGc2Lliad32Jpbx496IgDe1K3FMBEoKFZfhmtlRSXft8NKgSzPt2zkatM9bFKfaCYRaSy7akbk", 29 }, 30 scope: /^https?:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.securedrop\.tor\.onion\//, 31 enabled: true, 32 mappings: [], 33 currentTimestamp: 0, 34 }; 35 36 const kPrefOnionAliasEnabled = "browser.urlbar.onionRewrites.enabled"; 37 38 const log = console.createInstance({ 39 maxLogLevelPref: "browser.onionalias.log_level", 40 prefix: "OnionAlias", 41 }); 42 43 // Inspired by aboutMemory.js and PingCentre.jsm 44 function gunzip(buffer) { 45 return new Promise(resolve => { 46 const listener = Cc["@mozilla.org/network/stream-loader;1"].createInstance( 47 Ci.nsIStreamLoader 48 ); 49 listener.init({ 50 onStreamComplete(loader, context, status, length, result) { 51 resolve(String.fromCharCode(...result)); 52 }, 53 }); 54 const scs = Cc["@mozilla.org/streamConverters;1"].getService( 55 Ci.nsIStreamConverterService 56 ); 57 const converter = scs.asyncConvertData( 58 "gzip", 59 "uncompressed", 60 listener, 61 null 62 ); 63 const stream = Cc[ 64 "@mozilla.org/io/arraybuffer-input-stream;1" 65 ].createInstance(Ci.nsIArrayBufferInputStream); 66 stream.setData(buffer, 0, buffer.byteLength); 67 converter.onStartRequest(null, null); 68 converter.onDataAvailable(null, stream, 0, buffer.byteLength); 69 converter.onStopRequest(null, null, null); 70 }); 71 } 72 73 /** 74 * A channel that distributes Onion aliases. 75 * 76 * Each channel needs: 77 * - a name 78 * - a key used to sign the rules 79 * - a path prefix that will be used to build the URLs used to fetch updates 80 * - a scope (the apex domain for all aliases, and it must be a subdomain of 81 * .tor.onion). 82 */ 83 class Channel { 84 static get SIGN_ALGORITHM() { 85 return { 86 name: "RSA-PSS", 87 saltLength: 32, 88 hash: { name: "SHA-256" }, 89 }; 90 } 91 92 #enabled; 93 94 constructor(name, pathPrefix, jwk, scope, enabled) { 95 this.name = name; 96 this.pathPrefix = pathPrefix; 97 this.jwk = jwk; 98 this.scope = scope; 99 this.#enabled = enabled; 100 101 this.mappings = []; 102 this.currentTimestamp = 0; 103 this.latestTimestamp = 0; 104 } 105 106 async updateLatestTimestamp() { 107 const timestampUrl = this.pathPrefix + "/latest-rulesets-timestamp"; 108 log.debug(`Updating ${this.name} timestamp from ${timestampUrl}`); 109 const response = await fetch(timestampUrl); 110 if (!response.ok) { 111 throw Error(`Could not fetch timestamp for ${this.name}`, { 112 cause: response.status, 113 }); 114 } 115 const timestampStr = await response.text(); 116 const timestamp = parseInt(timestampStr); 117 // Avoid hijacking, sanitize the timestamp 118 if (isNaN(timestamp)) { 119 throw Error("Latest timestamp is not a number"); 120 } 121 log.debug(`Updated ${this.name} timestamp: ${timestamp}`); 122 this.latestTimestamp = timestamp; 123 } 124 125 async makeKey() { 126 return crypto.subtle.importKey( 127 "jwk", 128 this.jwk, 129 Channel.SIGN_ALGORITHM, 130 false, 131 ["verify"] 132 ); 133 } 134 135 async downloadVerifiedRules() { 136 log.debug(`Downloading and verifying ruleset for ${this.name}`); 137 138 const key = await this.makeKey(); 139 const signatureUrl = 140 this.pathPrefix + `/rulesets-signature.${this.latestTimestamp}.sha256`; 141 const signatureResponse = await fetch(signatureUrl); 142 if (!signatureResponse.ok) { 143 throw Error("Could not fetch the rules signature"); 144 } 145 const signature = await signatureResponse.arrayBuffer(); 146 147 const rulesUrl = 148 this.pathPrefix + `/default.rulesets.${this.latestTimestamp}.gz`; 149 const rulesResponse = await fetch(rulesUrl); 150 if (!rulesResponse.ok) { 151 throw Error("Could not fetch rules"); 152 } 153 const rulesGz = await rulesResponse.arrayBuffer(); 154 155 if ( 156 !(await crypto.subtle.verify( 157 Channel.SIGN_ALGORITHM, 158 key, 159 signature, 160 rulesGz 161 )) 162 ) { 163 throw Error("Could not verify rules signature"); 164 } 165 log.debug( 166 `Downloaded and verified rules for ${this.name}, now uncompressing` 167 ); 168 this.#makeMappings(JSON.parse(await gunzip(rulesGz))); 169 } 170 171 #makeMappings(rules) { 172 const toTest = /^https?:\/\/[a-zA-Z0-9\.]{56}\.onion$/; 173 const mappings = []; 174 rules.rulesets.forEach(rule => { 175 if (rule.rule.length != 1) { 176 log.warn(`Unsupported rule lenght: ${rule.rule.length}`); 177 return; 178 } 179 if (!toTest.test(rule.rule[0].to)) { 180 log.warn( 181 `Ignoring rule, because of a malformed to: ${rule.rule[0].to}` 182 ); 183 return; 184 } 185 const toHostname = URL.parse(rule.rule[0].to)?.hostname; 186 if (!toHostname) { 187 log.error( 188 "Unable to parse the URL and the hostname from the to rule", 189 rule.rule[0].to 190 ); 191 return; 192 } 193 194 let fromRe; 195 try { 196 fromRe = new RegExp(rule.rule[0].from); 197 } catch (err) { 198 log.error("Malformed from field", rule.rule[0].from, err); 199 return; 200 } 201 for (const target of rule.target) { 202 if ( 203 target.endsWith(".tor.onion") && 204 this.scope.test(`http://${target}/`) && 205 fromRe.test(`http://${target}/`) 206 ) { 207 mappings.push([target, toHostname]); 208 } else { 209 log.warn("Ignoring malformed rule", rule); 210 } 211 } 212 }); 213 this.mappings = mappings; 214 this.currentTimestamp = rules.timestamp; 215 log.debug(`Updated mappings for ${this.name}`, mappings); 216 } 217 218 async updateMappings(force) { 219 force = force === undefined ? false : !!force; 220 if (!this.#enabled && !force) { 221 return; 222 } 223 await this.updateLatestTimestamp(); 224 if (this.latestTimestamp <= this.currentTimestamp && !force) { 225 log.debug( 226 `Rules for ${this.name} are already up to date, skipping update` 227 ); 228 return; 229 } 230 await this.downloadVerifiedRules(); 231 } 232 233 get enabled() { 234 return this.#enabled; 235 } 236 set enabled(enabled) { 237 this.#enabled = enabled; 238 if (!enabled) { 239 this.mappings = []; 240 this.currentTimestamp = 0; 241 this.latestTimestamp = 0; 242 } 243 } 244 245 toJSON() { 246 let scope = this.scope.toString(); 247 scope = scope.substr(1, scope.length - 2); 248 return { 249 name: this.name, 250 pathPrefix: this.pathPrefix, 251 jwk: this.jwk, 252 scope, 253 enabled: this.#enabled, 254 mappings: this.mappings, 255 currentTimestamp: this.currentTimestamp, 256 }; 257 } 258 259 static fromJSON(obj) { 260 let channel = new Channel( 261 obj.name, 262 obj.pathPrefix, 263 obj.jwk, 264 new RegExp(obj.scope), 265 obj.enabled 266 ); 267 if (obj.enabled) { 268 channel.mappings = obj.mappings; 269 channel.currentTimestamp = obj.currentTimestamp; 270 } 271 return channel; 272 } 273 } 274 275 /** 276 * The manager of onion aliases. 277 * It allows creating, reading, updating and deleting channels and it keeps them 278 * updated. 279 * 280 * This class is a singleton which should be accessed with OnionAliasStore. 281 */ 282 class _OnionAliasStore { 283 static get RULESET_CHECK_INTERVAL() { 284 return 86400 * 1000; // 1 day, like HTTPS-Everywhere 285 } 286 287 #channels = new Map(); 288 #rulesetTimeout = null; 289 #lastCheck = 0; 290 #storage = null; 291 292 async init() { 293 lazy.TorRequestWatch.start(); 294 await this.#loadSettings(); 295 if (this.enabled && !lazy.TorConnect.shouldShowTorConnect) { 296 await this.#startUpdates(); 297 } else { 298 Services.obs.addObserver(this, lazy.TorConnectTopics.BootstrapComplete); 299 } 300 Services.prefs.addObserver(kPrefOnionAliasEnabled, this); 301 } 302 303 uninit() { 304 this.#clear(); 305 if (this.#rulesetTimeout) { 306 clearTimeout(this.#rulesetTimeout); 307 } 308 this.#rulesetTimeout = null; 309 310 Services.obs.removeObserver(this, lazy.TorConnectTopics.BootstrapComplete); 311 Services.prefs.removeObserver(kPrefOnionAliasEnabled, this); 312 313 lazy.TorRequestWatch.stop(); 314 } 315 316 async getChannels() { 317 if (this.#storage === null) { 318 await this.#loadSettings(); 319 } 320 return Array.from(this.#channels.values(), ch => ch.toJSON()); 321 } 322 323 async setChannel(chanData) { 324 const name = chanData.name?.trim(); 325 if (!name) { 326 throw Error("Name cannot be empty"); 327 } 328 329 // This will throw if the URL is invalid. 330 new URL(chanData.pathPrefix); 331 const scope = new RegExp(chanData.scope); 332 const ch = new Channel( 333 name, 334 chanData.pathPrefix, 335 chanData.jwk, 336 scope, 337 !!chanData.enabled 338 ); 339 // Call makeKey to make it throw if the key is invalid 340 await ch.makeKey(); 341 this.#channels.set(name, ch); 342 this.#applyMappings(); 343 this.#saveSettings(); 344 setTimeout(this.#notifyChanges.bind(this), 1); 345 return ch; 346 } 347 348 enableChannel(name, enabled) { 349 const channel = this.#channels.get(name); 350 if (channel !== null) { 351 channel.enabled = enabled; 352 this.#applyMappings(); 353 this.#saveSettings(); 354 this.#notifyChanges(); 355 if (this.enabled && enabled && !channel.currentTimestamp) { 356 this.updateChannel(name); 357 } 358 } 359 } 360 361 async updateChannel(name) { 362 if (!this.enabled) { 363 throw Error("Onion Aliases are disabled"); 364 } 365 const channel = this.#channels.get(name); 366 if (channel === null) { 367 throw Error("Channel not found"); 368 } 369 await channel.updateMappings(true); 370 this.#saveSettings(); 371 this.#applyMappings(); 372 setTimeout(this.#notifyChanges.bind(this), 1); 373 return channel; 374 } 375 376 deleteChannel(name) { 377 if (this.#channels.delete(name)) { 378 this.#saveSettings(); 379 this.#applyMappings(); 380 this.#notifyChanges(); 381 } 382 } 383 384 async #loadSettings() { 385 if (this.#storage !== null) { 386 return; 387 } 388 this.#channels = new Map(); 389 this.#storage = new lazy.JSONFile({ 390 path: PathUtils.join( 391 Services.dirsvc.get("ProfD", Ci.nsIFile).path, 392 "onion-aliases.json" 393 ), 394 dataPostProcessor: this.#settingsProcessor.bind(this), 395 }); 396 await this.#storage.load(); 397 log.debug("Loaded settings", this.#storage.data, this.#storage.path); 398 this.#applyMappings(); 399 this.#notifyChanges(); 400 } 401 402 #settingsProcessor(data) { 403 if ("lastCheck" in data) { 404 this.#lastCheck = data.lastCheck; 405 } else { 406 data.lastCheck = 0; 407 } 408 if (!("channels" in data) || !Array.isArray(data.channels)) { 409 data.channels = [SECURE_DROP]; 410 // Force updating 411 data.lastCheck = 0; 412 } 413 const channels = new Map(); 414 data.channels = data.channels.filter(ch => { 415 try { 416 channels.set(ch.name, Channel.fromJSON(ch)); 417 } catch (err) { 418 log.error("Could not load a channel", err, ch); 419 return false; 420 } 421 return true; 422 }); 423 this.#channels = channels; 424 return data; 425 } 426 427 #saveSettings() { 428 if (this.#storage === null) { 429 throw Error("Settings have not been loaded"); 430 } 431 this.#storage.data.lastCheck = this.#lastCheck; 432 this.#storage.data.channels = Array.from(this.#channels.values(), ch => 433 ch.toJSON() 434 ); 435 this.#storage.saveSoon(); 436 } 437 438 #addMapping(shortOnionHost, longOnionHost) { 439 const service = Cc["@torproject.org/onion-alias-service;1"].getService( 440 Ci.IOnionAliasService 441 ); 442 service.addOnionAlias(shortOnionHost, longOnionHost); 443 } 444 445 #clear() { 446 const service = Cc["@torproject.org/onion-alias-service;1"].getService( 447 Ci.IOnionAliasService 448 ); 449 service.clearOnionAliases(); 450 } 451 452 #applyMappings() { 453 this.#clear(); 454 for (const ch of this.#channels.values()) { 455 if (!ch.enabled) { 456 continue; 457 } 458 for (const [short, long] of ch.mappings) { 459 this.#addMapping(short, long); 460 } 461 } 462 } 463 464 async #periodicRulesetCheck() { 465 if (!this.enabled) { 466 log.debug("Onion Aliases are disabled, not updating rulesets."); 467 return; 468 } 469 log.debug("Begin scheduled ruleset update"); 470 this.#lastCheck = Date.now(); 471 let anyUpdated = false; 472 for (const ch of this.#channels.values()) { 473 if (!ch.enabled) { 474 log.debug(`Not updating ${ch.name} because not enabled`); 475 continue; 476 } 477 log.debug(`Updating ${ch.name}`); 478 try { 479 await ch.updateMappings(); 480 anyUpdated = true; 481 } catch (err) { 482 log.error(`Could not update mappings for channel ${ch.name}`, err); 483 } 484 } 485 if (anyUpdated) { 486 this.#saveSettings(); 487 this.#applyMappings(); 488 this.#notifyChanges(); 489 } else { 490 log.debug("No channel has been updated, avoid saving"); 491 } 492 this.#scheduleCheck(_OnionAliasStore.RULESET_CHECK_INTERVAL); 493 } 494 495 async #startUpdates() { 496 // This is a private function, so we expect the callers to verify whether 497 // onion aliases are enabled. 498 // Callees will also do, so we avoid an additional check here. 499 const dt = Date.now() - this.#lastCheck; 500 let force = false; 501 for (const ch of this.#channels.values()) { 502 if (ch.enabled && !ch.currentTimestamp) { 503 // Edited while being offline or some other error happened 504 force = true; 505 break; 506 } 507 } 508 if (dt > _OnionAliasStore.RULESET_CHECK_INTERVAL || force) { 509 log.debug( 510 `Mappings are stale (${dt}), or force check requested (${force}), checking them immediately` 511 ); 512 await this.#periodicRulesetCheck(); 513 } else { 514 this.#scheduleCheck(_OnionAliasStore.RULESET_CHECK_INTERVAL - dt); 515 } 516 } 517 518 #scheduleCheck(dt) { 519 if (this.#rulesetTimeout) { 520 log.warn("The previous update timeout was not null"); 521 clearTimeout(this.#rulesetTimeout); 522 } 523 if (!this.enabled) { 524 log.warn( 525 "Ignoring the scheduling of a new check because the Onion Alias feature is currently disabled." 526 ); 527 this.#rulesetTimeout = null; 528 return; 529 } 530 log.debug(`Scheduling ruleset update in ${dt}`); 531 this.#rulesetTimeout = setTimeout(() => { 532 this.#rulesetTimeout = null; 533 this.#periodicRulesetCheck(); 534 }, dt); 535 } 536 537 #notifyChanges() { 538 Services.obs.notifyObservers( 539 Array.from(this.#channels.values(), ch => ch.toJSON()), 540 OnionAliasStoreTopics.ChannelsChanged 541 ); 542 } 543 544 get enabled() { 545 return Services.prefs.getBoolPref(kPrefOnionAliasEnabled, true); 546 } 547 548 observe(aSubject, aTopic) { 549 if (aTopic === "nsPref:changed") { 550 if (this.enabled) { 551 this.#startUpdates(); 552 } else if (this.#rulesetTimeout) { 553 clearTimeout(this.#rulesetTimeout); 554 this.#rulesetTimeout = null; 555 } 556 } else if ( 557 aTopic === lazy.TorConnectTopics.BootstrapComplete && 558 this.enabled 559 ) { 560 this.#startUpdates(); 561 } 562 } 563 } 564 565 export const OnionAliasStore = new _OnionAliasStore();