tor-browser

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

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

Bug 1988748 - Have ProxyPass use token Time Data r=ip-protection-reviewers,baku

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

Diffstat:
Mbrowser/components/ipprotection/GuardianClient.sys.mjs | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mbrowser/components/ipprotection/tests/browser/head.js | 23+++++++++++++++++++----
Mbrowser/components/ipprotection/tests/xpcshell/head.js | 47+++++++++++++++++++++++++++++++++++++++++------
Mbrowser/components/ipprotection/tests/xpcshell/test_GuardianClient.js | 20+++-----------------
Mbrowser/components/ipprotection/tests/xpcshell/test_IPProtectionPanel.js | 7+------
Mbrowser/components/ipprotection/tests/xpcshell/test_IPProxyManager.js | 27+++++++++++----------------
6 files changed, 145 insertions(+), 80 deletions(-)

diff --git a/browser/components/ipprotection/GuardianClient.sys.mjs b/browser/components/ipprotection/GuardianClient.sys.mjs @@ -278,12 +278,16 @@ export class GuardianClient { * Immutable after creation. */ export class ProxyPass extends EventTarget { + #body = { + /** Not Before */ + nbf: 0, + /** Expiration */ + exp: 0, + }; /** * @param {string} token - The JWT to use for authentication. - * @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, from = Temporal.Now.instant()) { + constructor(token) { super(); if (typeof token !== "string") { throw new TypeError( @@ -291,16 +295,19 @@ export class ProxyPass extends EventTarget { ); } this.token = token; - this.from = from; - this.until = until; - const [header, body] = this.token.split("."); + // Contains [header.body.signature] + const parts = this.token.split("."); + if (parts.length !== 3) { + throw new TypeError("Invalid token format"); + } try { - const parses = [header, body].every(json => - JSON.parse(atob(json) != null) - ); - if (!parses) { - throw new TypeError("Invalid token format"); + const body = JSON.parse(atob(parts[1])); + if ( + !lazy.JsonSchemaValidator.validate(body, ProxyPass.bodySchema).valid + ) { + throw new TypeError("Token body does not match schema"); } + this.#body = body; } catch (error) { throw new TypeError("Invalid token format: " + error.message); } @@ -308,7 +315,10 @@ export class ProxyPass extends EventTarget { 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; + return ( + Temporal.Instant.compare(now, this.from) >= 0 && + Temporal.Instant.compare(now, this.until) < 0 + ); } shouldRotate(now = Temporal.Now.instant()) { @@ -318,6 +328,16 @@ export class ProxyPass extends EventTarget { return Temporal.Instant.compare(now, this.rotationTimePoint) >= 0; } + get from() { + // nbf is in seconds since epoch + return Temporal.Instant.fromEpochMilliseconds(this.#body.nbf * 1000); + } + + get until() { + // exp is in seconds since epoch + return Temporal.Instant.fromEpochMilliseconds(this.#body.exp * 1000); + } + /** * Parses a ProxyPass from a Response object. * @@ -334,23 +354,6 @@ export class ProxyPass extends EventTarget { } try { - // Get cache_control max-age value - const cache_control = response.headers - .get("cache-control") - ?.match(/max-age=(\d+)/)?.[1]; - - if (!cache_control) { - console.error("Missing or invalid Cache-Control header"); - return null; - } - - const max_age = parseInt(cache_control, 10); - if (isNaN(max_age)) { - console.error("Invalid max-age value in Cache-Control header"); - return null; - } - 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,8 +362,7 @@ export class ProxyPass extends EventTarget { console.error("Missing or invalid token in response"); return null; } - - return new ProxyPass(token, until, from); + return new ProxyPass(token); } catch (error) { console.error("Error parsing proxy pass response:", error); return null; @@ -378,6 +380,43 @@ export class ProxyPass extends EventTarget { } // Rotate 10 Minutes from the End Time static ROTATION_TIME = Temporal.Duration.from({ minutes: 10 }); + + static get bodySchema() { + return { + $schema: "http://json-schema.org/draft-07/schema#", + title: "JWT Claims", + type: "object", + properties: { + sub: { + type: "string", + description: "Subject identifier", + }, + aud: { + type: "string", + format: "uri", + description: "Audience for which the token is intended", + }, + iat: { + type: "integer", + description: "Issued-at time (seconds since Unix epoch)", + }, + nbf: { + type: "integer", + description: "Not-before time (seconds since Unix epoch)", + }, + exp: { + type: "integer", + description: "Expiration time (seconds since Unix epoch)", + }, + iss: { + type: "string", + description: "Issuer identifier", + }, + }, + required: ["sub", "aud", "iat", "nbf", "exp", "iss"], + additionalProperties: true, + }; + } } /** diff --git a/browser/components/ipprotection/tests/browser/head.js b/browser/components/ipprotection/tests/browser/head.js @@ -387,10 +387,25 @@ async function cleanupExperiment() { } /* exported cleanupExperiment */ -function makePass() { - const token = - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30"; - return new ProxyPass(token, Temporal.Now.instant().add({ hours: 24 })); +function makePass( + from = Temporal.Now.instant(), + until = from.add({ hours: 24 }) +) { + const header = { + alg: "HS256", + typ: "JWT", + }; + const body = { + iat: Math.floor(from.add({ seconds: 1 }).epochMilliseconds / 1000), + nbf: Math.floor(from.epochMilliseconds / 1000), + exp: Math.floor(until.epochMilliseconds / 1000), + sub: "proxy-pass-user-42", + aud: "guardian-proxy", + iss: "vpn.mozilla.org", + }; + const encode = obj => btoa(JSON.stringify(obj)); + const token = [encode(header), encode(body), "signature"].join("."); + return new ProxyPass(token); } /* exported makePass */ diff --git a/browser/components/ipprotection/tests/xpcshell/head.js b/browser/components/ipprotection/tests/xpcshell/head.js @@ -90,11 +90,46 @@ function setupStubs( sandbox.stub(IPProtectionService.guardian, "fetchProxyPass").resolves({ status: 200, error: undefined, - pass: { - isValid: () => options.validProxyPass, - shouldRotate: () => !options.validProxyPass, - asBearerToken: () => "Bearer helloworld", - rotationTimePoint: Temporal.Now.instant().add({ hours: 1 }), - }, + pass: new ProxyPass( + options.validProxyPass + ? createProxyPassToken() + : createExpiredProxyPassToken() + ), }); } + +/** + * Creates a Token that can be fed as a Network Response from Guardian + * to simulate a Proxy Pass. + * + * @param {Temporal.Instant} from + * @param {Temporal.Instant} until + * @returns {string} JWT Token + */ +function createProxyPassToken( + from = Temporal.Now.instant(), + until = from.add({ hours: 24 }) +) { + const header = { + alg: "HS256", + typ: "JWT", + }; + const body = { + iat: Math.floor(from.add({ seconds: 1 }).epochMilliseconds / 1000), + nbf: Math.floor(from.epochMilliseconds / 1000), + exp: Math.floor(until.epochMilliseconds / 1000), + sub: "proxy-pass-user-42", + aud: "guardian-proxy", + iss: "vpn.mozilla.org", + }; + const encode = obj => btoa(JSON.stringify(obj)); + return [encode(header), encode(body), "signature"].join("."); +} +/* exported createExpiredProxyPassToken */ +function createExpiredProxyPassToken() { + return createProxyPassToken( + Temporal.Now.instant().subtract({ hours: 2 }), + Temporal.Now.instant().subtract({ hours: 1 }) + ); +} +/* exported createExpiredProxyPassToken */ diff --git a/browser/components/ipprotection/tests/xpcshell/test_GuardianClient.js b/browser/components/ipprotection/tests/xpcshell/test_GuardianClient.js @@ -310,7 +310,7 @@ add_task(async function test_fetchProxyPass() { const testcases = [ { name: "It should parse a valid response", - sends: ok({ token: "header.payload.signature" }), + sends: ok({ token: createProxyPassToken() }), expects: { status: 200, error: null, @@ -336,17 +336,8 @@ add_task(async function test_fetchProxyPass() { }, }, { - name: "It should handle a missing Cache-Control header", - sends: ok({ token: "header.payload.signature" }, { "Cache-Control": "" }), - expects: { - status: 200, - error: "invalid_response", - validPass: false, - }, - }, - { name: "It should handle an invalid token format", - sends: ok({ token: "invalid-token-format" }), + sends: ok({ token: "header.body.signature" }), expects: { status: 200, error: "invalid_response", @@ -435,11 +426,6 @@ add_task(async function test_parseGuardianSuccessURL() { }); 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' @@ -471,7 +457,7 @@ add_task(async function test_proxyPassShouldRotate() { testcases.forEach(({ name, currentTime, expects }) => { info(`Running test case: ${name}`); - const proxyPass = new ProxyPass(testToken, until, from); + const proxyPass = new ProxyPass(createProxyPassToken(from, until)); const result = proxyPass.shouldRotate(currentTime); Assert.equal( result, diff --git a/browser/components/ipprotection/tests/xpcshell/test_IPProtectionPanel.js b/browser/components/ipprotection/tests/xpcshell/test_IPProtectionPanel.js @@ -240,12 +240,7 @@ add_task(async function test_IPProtectionPanel_started_stopped() { sandbox.stub(IPProtectionService.guardian, "fetchProxyPass").resolves({ status: 200, error: undefined, - pass: { - isValid: () => true, - shouldRotate: () => false, - asBearerToken: () => "Bearer helloworld", - rotationTimePoint: Temporal.Now.instant().add({ hours: 1 }), - }, + pass: new ProxyPass(createProxyPassToken()), }); IPProtectionService.updateState(); diff --git a/browser/components/ipprotection/tests/xpcshell/test_IPProxyManager.js b/browser/components/ipprotection/tests/xpcshell/test_IPProxyManager.js @@ -162,10 +162,7 @@ add_task(async function test_IPPProxyManager_reset() { sandbox.stub(IPProtectionService.guardian, "fetchProxyPass").returns({ status: 200, error: undefined, - pass: { - isValid: () => true, - asBearerToken: () => "Bearer hello world", - }, + pass: new ProxyPass(createProxyPassToken()), }); await IPPProxyManager.start(); @@ -254,12 +251,11 @@ add_task(async function test_IPPProxytates_active() { sandbox.stub(IPProtectionService.guardian, "fetchProxyPass").resolves({ status: 200, error: undefined, - pass: { - isValid: () => options.validProxyPass, - shouldRotate: () => !options.validProxyPass, - rotationTimePoint: Temporal.Now.instant().add({ hours: 1 }), - asBearerToken: () => "Bearer helloworld", - }, + pass: new ProxyPass( + options.validProxyPass + ? createProxyPassToken() + : createExpiredProxyPassToken() + ), }); const waitForReady = waitForEvent( @@ -329,12 +325,11 @@ add_task(async function test_IPPProxytates_start_stop() { sandbox.stub(IPProtectionService.guardian, "fetchProxyPass").resolves({ status: 200, error: undefined, - pass: { - isValid: () => options.validProxyPass, - shouldRotate: () => !options.validProxyPass, - rotationTimePoint: Temporal.Now.instant().add({ hours: 1 }), - asBearerToken: () => "Bearer helloworld", - }, + pass: new ProxyPass( + options.validProxyPass + ? createProxyPassToken() + : createExpiredProxyPassToken() + ), }); const waitForReady = waitForEvent(