tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 5ad12cdc1dc72f8fc8fcc17cf206984b8ca85e29
parent f73e57efddcb1a167d97f4cbf8ed3e48537853bf
Author: Sebastian Streich <sstreich@mozilla.com>
Date:   Mon, 15 Dec 2025 13:48:38 +0000

Bug 1988748 Preemtivlly renew the ProxyPass r=ip-protection-reviewers,baku

This patch adds Methods to ProxyPass to notify when a Pass is about
to expire. IPProtectionService will listen to those events and attempt a
pass rotation if that happens.

wtsxmtxw

Differential Revision: https://phabricator.services.mozilla.com/D264961

Diffstat:
Mbrowser/components/ipprotection/GuardianClient.sys.mjs | 51+++++++++++++++++++++++++++++----------------------
Mbrowser/components/ipprotection/IPPProxyManager.sys.mjs | 55++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mbrowser/components/ipprotection/tests/browser/head.js | 2+-
Mbrowser/components/ipprotection/tests/xpcshell/head.js | 2++
Mbrowser/components/ipprotection/tests/xpcshell/test_GuardianClient.js | 54++++++++++++++++++++++++++++++++++++++++++++++++------
Mbrowser/components/ipprotection/tests/xpcshell/test_IPProtectionPanel.js | 2++
Mbrowser/components/ipprotection/tests/xpcshell/test_IPProxyManager.js | 4++++
7 files changed, 140 insertions(+), 30 deletions(-)

diff --git a/browser/components/ipprotection/GuardianClient.sys.mjs b/browser/components/ipprotection/GuardianClient.sys.mjs @@ -173,6 +173,7 @@ export class GuardianClient { const response = await this.withToken(async token => { return await fetch(this.#tokenURL, { method: "GET", + cache: "no-cache", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", @@ -276,18 +277,22 @@ export class GuardianClient { * * Immutable after creation. */ -export class ProxyPass { +export class ProxyPass extends EventTarget { /** * @param {string} token - The JWT to use for authentication. - * @param {number} until - The timestamp until which the token is valid. + * @param {Temporal.Instant} until -The Point in time when the token becomes invalid. + * @param {Temporal.Instant} from - The Point in time when the token becomes valid. */ - constructor(token, until) { - if (typeof token !== "string" || typeof until !== "number") { - throw new TypeError("Invalid arguments for ProxyPass constructor"); + constructor(token, until, from = Temporal.Now.instant()) { + super(); + if (typeof token !== "string") { + throw new TypeError( + "Invalid arguments for ProxyPass constructor, token is not a string" + ); } this.token = token; + this.from = from; this.until = until; - this.from = Date.now(); const [header, body] = this.token.split("."); try { const parses = [header, body].every(json => @@ -299,21 +304,18 @@ export class ProxyPass { } catch (error) { throw new TypeError("Invalid token format: " + error.message); } - Object.freeze(this); } - isValid() { - const now = Date.now(); - return this.until > now; + + isValid(now = Temporal.Now.instant()) { + // If the remaining duration is zero or positive, the pass is still valid. + return Temporal.Instant.compare(now, this.until) < 0; } - shouldRotate() { - if (!this.isValid) { + shouldRotate(now = Temporal.Now.instant()) { + if (!this.isValid(now)) { return true; } - const totalLifespan = this.until - this.from; - const rotationPoint = - this.from + totalLifespan * (ProxyPass.ROTATION_PERCENTAGE / 100); - return Date.now() > rotationPoint; + return Temporal.Instant.compare(now, this.rotationTimePoint) >= 0; } /** @@ -347,9 +349,8 @@ export class ProxyPass { console.error("Invalid max-age value in Cache-Control header"); return null; } - - const until = Date.now() + max_age * 1000; - + const from = Temporal.Now.instant(); + const until = from.add(Temporal.Duration.from({ seconds: max_age })); // Parse JSON response const responseData = await response.json(); const token = responseData?.token; @@ -359,18 +360,24 @@ export class ProxyPass { return null; } - return new ProxyPass(token, until); + return new ProxyPass(token, until, from); } catch (error) { console.error("Error parsing proxy pass response:", error); return null; } } + /** + * @type {Temporal.Instant} - The Point in time when the token should be rotated. + */ + get rotationTimePoint() { + return this.until.subtract(ProxyPass.ROTATION_TIME); + } asBearerToken() { return `Bearer ${this.token}`; } - - static ROTATION_PERCENTAGE = 90; // 0-100 % - how long in the duration until the pass should be rotated. + // Rotate 10 Minutes from the End Time + static ROTATION_TIME = Temporal.Duration.from({ minutes: 10 }); } /** diff --git a/browser/components/ipprotection/IPPProxyManager.sys.mjs b/browser/components/ipprotection/IPPProxyManager.sys.mjs @@ -20,6 +20,21 @@ ChromeUtils.defineESModuleGetters(lazy, { "resource:///modules/ipprotection/IPProtectionService.sys.mjs", }); +ChromeUtils.defineLazyGetter( + lazy, + "setTimeout", + () => + ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs") + .setTimeout +); +ChromeUtils.defineLazyGetter( + lazy, + "clearTimeout", + () => + ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs") + .clearTimeout +); + import { ERRORS } from "chrome://browser/content/ipprotection/ipprotection-constants.mjs"; const LOG_PREF = "browser.ipProtection.log"; @@ -72,6 +87,8 @@ class IPPProxyManagerSingleton extends EventTarget { #rotateProxyPassPromise = null; #activatedAt = false; + #rotationTimer = 0; + errors = []; constructor() { @@ -251,9 +268,10 @@ class IPPProxyManagerSingleton extends EventTarget { // If the current proxy pass is valid, no need to re-authenticate. // Throws an error if the proxy pass is not available. - if (!this.#pass?.isValid()) { + if (this.#pass == null || this.#pass.shouldRotate()) { this.#pass = await this.#getProxyPass(); } + this.#schedulePassRotation(this.#pass); const location = lazy.IPProtectionServerlist.getDefaultLocation(); const server = lazy.IPProtectionServerlist.selectServer(location?.city); @@ -304,6 +322,9 @@ class IPPProxyManagerSingleton extends EventTarget { this.cancelChannelFilter(); + lazy.clearTimeout(this.#rotationTimer); + this.#rotationTimer = 0; + this.networkErrorObserver.stop(); lazy.logConsole.info("Stopped"); @@ -373,6 +394,37 @@ class IPPProxyManagerSingleton extends EventTarget { } /** + * Given a ProxyPass, sets a timer and triggers a rotation when it's about to expire. + * + * @param {*} pass + */ + #schedulePassRotation(pass) { + if (this.#rotationTimer) { + lazy.clearTimeout(this.#rotationTimer); + this.#rotationTimer = 0; + } + + const now = Temporal.Now.instant(); + const rotationTimePoint = pass.rotationTimePoint; + let msUntilRotation = now.until(rotationTimePoint).total("milliseconds"); + if (msUntilRotation <= 0) { + msUntilRotation = 0; + } + + lazy.logConsole.debug( + `ProxyPass will rotate in ${now.until(rotationTimePoint).total("minutes")} minutes` + ); + this.#rotationTimer = lazy.setTimeout(async () => { + this.#rotationTimer = 0; + if (!this.#connection?.active) { + return; + } + lazy.logConsole.debug(`Statrting scheduled ProxyPass rotation`); + await this.#rotateProxyPass(); + }, msUntilRotation); + } + + /** * Starts a flow to get a new ProxyPass and replace the current one. * * @returns {Promise<void>} - Returns a promise that resolves when the rotation is complete or failed. @@ -396,6 +448,7 @@ class IPPProxyManagerSingleton extends EventTarget { } lazy.logConsole.debug("Successfully rotated token!"); this.#pass = pass; + this.#schedulePassRotation(pass); return null; } diff --git a/browser/components/ipprotection/tests/browser/head.js b/browser/components/ipprotection/tests/browser/head.js @@ -390,7 +390,7 @@ async function cleanupExperiment() { function makePass() { const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30"; - return new ProxyPass(token, Date.now() + 31536000 * 1000); + return new ProxyPass(token, Temporal.Now.instant().add({ hours: 24 })); } /* exported makePass */ diff --git a/browser/components/ipprotection/tests/xpcshell/head.js b/browser/components/ipprotection/tests/xpcshell/head.js @@ -92,7 +92,9 @@ function setupStubs( error: undefined, pass: { isValid: () => options.validProxyPass, + shouldRotate: () => !options.validProxyPass, asBearerToken: () => "Bearer helloworld", + rotationTimePoint: Temporal.Now.instant().add({ hours: 1 }), }, }); } diff --git a/browser/components/ipprotection/tests/xpcshell/test_GuardianClient.js b/browser/components/ipprotection/tests/xpcshell/test_GuardianClient.js @@ -384,13 +384,8 @@ add_task(async function test_fetchProxyPass() { "string", `${name}: pass.token should be a string` ); - Assert.strictEqual( - typeof pass.until, - "number", - `${name}: pass.until should be a number` - ); Assert.greater( - pass.until, + pass.until.epochMilliseconds, Date.now(), `${name}: pass.until should be in the future` ); @@ -438,3 +433,50 @@ add_task(async function test_parseGuardianSuccessURL() { Assert.equal(result.error, expects.error, `${name}: error should match`); }); }); + +add_task(async function test_proxyPassShouldRotate() { + // Create a static synthetic JWT-like token for testing + const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const payload = btoa(JSON.stringify({ sub: "test", exp: 1702032000 })); + const testToken = `${header}.${payload}.signature`; + + const oneHour = Temporal.Duration.from({ hours: 1 }); + const from = Temporal.Instant.from("2025-12-08T12:00:00Z"); // Static point in time + // The pass is valid for 1 hour from 'from' + const until = from.add(oneHour); + const rotationTime = ProxyPass.ROTATION_TIME; + + const testcases = [ + { + name: "Should not rotate when before rotation time", + currentTime: until.subtract(rotationTime).subtract({ seconds: 1 }), + expects: { shouldRotate: false }, + }, + { + name: "Should rotate when at rotation time", + currentTime: until.subtract(rotationTime), + expects: { shouldRotate: true }, + }, + { + name: "Should rotate when after rotation time", + currentTime: until.subtract(rotationTime).add({ seconds: 1 }), + expects: { shouldRotate: true }, + }, + { + name: "Should rotate when pass is expired", + currentTime: until.add({ seconds: 1 }), + expects: { shouldRotate: true }, + }, + ]; + + testcases.forEach(({ name, currentTime, expects }) => { + info(`Running test case: ${name}`); + const proxyPass = new ProxyPass(testToken, until, from); + const result = proxyPass.shouldRotate(currentTime); + Assert.equal( + result, + expects.shouldRotate, + `${name}: shouldRotate should match` + ); + }); +}); diff --git a/browser/components/ipprotection/tests/xpcshell/test_IPProtectionPanel.js b/browser/components/ipprotection/tests/xpcshell/test_IPProtectionPanel.js @@ -242,7 +242,9 @@ add_task(async function test_IPProtectionPanel_started_stopped() { error: undefined, pass: { isValid: () => true, + shouldRotate: () => false, asBearerToken: () => "Bearer helloworld", + rotationTimePoint: Temporal.Now.instant().add({ hours: 1 }), }, }); diff --git a/browser/components/ipprotection/tests/xpcshell/test_IPProxyManager.js b/browser/components/ipprotection/tests/xpcshell/test_IPProxyManager.js @@ -256,6 +256,8 @@ add_task(async function test_IPPProxytates_active() { error: undefined, pass: { isValid: () => options.validProxyPass, + shouldRotate: () => !options.validProxyPass, + rotationTimePoint: Temporal.Now.instant().add({ hours: 1 }), asBearerToken: () => "Bearer helloworld", }, }); @@ -329,6 +331,8 @@ add_task(async function test_IPPProxytates_start_stop() { error: undefined, pass: { isValid: () => options.validProxyPass, + shouldRotate: () => !options.validProxyPass, + rotationTimePoint: Temporal.Now.instant().add({ hours: 1 }), asBearerToken: () => "Bearer helloworld", }, });