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:
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",
},
});