Repository: cloudflare/pp-browser-extension Branch: main Commit: 94fc4ce9938c Files: 47 Total size: 65.8 KB Directory structure: gitextract_k1shepog/ ├── .eslintrc.json ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── feature_request.md │ │ ├── new_bug.md │ │ └── ui_ux_feedback.md │ └── workflows/ │ ├── action.yml │ └── semgrep.yml ├── .gitignore ├── .npmrc ├── .prettierrc.json ├── CONTRIBUTING.md ├── DEVELOPER.md ├── LICENSE ├── README.md ├── devel.md ├── devel_pat.md ├── jest.config.mjs ├── jest.setup.mjs ├── package.json ├── platform/ │ ├── chromium/ │ │ ├── index.ts │ │ ├── manifest.json │ │ └── tsconfig.json │ ├── firefox/ │ │ ├── global.d.ts │ │ ├── index.ts │ │ ├── manifest.json │ │ └── tsconfig.json │ └── mv3/ │ └── chromium/ │ ├── index.ts │ ├── manifest.json │ └── tsconfig.json ├── src/ │ ├── background/ │ │ ├── attesters.ts │ │ ├── const.ts │ │ ├── index.ts │ │ ├── pubVerifToken.ts │ │ ├── replay.ts │ │ ├── rules.ts │ │ ├── tsconfig.json │ │ ├── util.test.ts │ │ └── util.ts │ ├── common/ │ │ ├── const.ts │ │ ├── index.ts │ │ ├── logger.ts │ │ └── settings.ts │ └── options/ │ ├── global.d.ts │ ├── index.css │ ├── index.html │ ├── index.ts │ └── tsconfig.json └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc.json ================================================ { "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": 2020, "sourceType": "module", "ecmaFeatures": { "jsx": true } }, "plugins": [ "@typescript-eslint", "security", "prettier" ], "extends": [ "eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended", "plugin:security/recommended-legacy", "plugin:prettier/recommended", "prettier" ], "ignorePatterns": [ "**/*.d.ts", "**/*.js", "coverage/*", "lib/*" ], "rules": { "@typescript-eslint/member-delimiter-style": 0, "@typescript-eslint/no-namespace": [ "warn" ], "@typescript-eslint/no-unused-vars": [ "error", { "argsIgnorePattern": "^_" } ], "no-case-declarations": 0, "no-console": [ "error", { "allow": [ "warn", "error" ] } ] } } ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: feature-request assignees: '' --- **Is your feature request related to a problem?** If so, make sure your problem hasn't been listed before. **Describe the solution you'd like** Comment about what can be improved, or what would you like to happen in response to some action. **Additional context** Add any other context about the feature request here. ================================================ FILE: .github/ISSUE_TEMPLATE/new_bug.md ================================================ --- name: Bug Report about: Create a report for a bug in Privacy Pass browser extension title: '' labels: triage assignees: '' --- Before reporting a new bug, verify if your request is already being tracked by another issue: https://github.com/cloudflare/pp-browser-extension/issues. --- If you believe that this is a new bug, please proceed to create an issue. The issue will be investigated after you have filled in the following information. **Describe the bug** A clear and concise description of the bug. **How to reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A description of what is expected to happen. **System (please complete the following information):** - OS: [e.g. iOS/Windows] - Attesters configuration from extension options - Browser [e.g. Chrome, Firefox] - Browser Version [e.g. 79, 80, ] - Silk Version [e.g. 4.0.0, 4.0.1 ] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/ui_ux_feedback.md ================================================ --- name: UI/UX Feedback about: We welcome feedback regarding user experience title: '' labels: enhancement assignees: '' --- **Describe** how your experience can be improved. **Note** that this report does not consider errors or bugs in the extension. Get some inspiration from these questions: - *What do you expect to see when you perform some action?* - *There exist some troubles on rendering?* - *Would you like browsers have builtin support for Privacy Pass?* ================================================ FILE: .github/workflows/action.yml ================================================ --- name: Silk Privacy Pass Client Extension on: push: branches: [main] pull_request: branches: [main] jobs: testing: name: Running on Node v${{ matrix.node-version }} runs-on: ubuntu-latest strategy: matrix: node-version: [20, 18] steps: - name: Checking out uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4 - name: Setup Node v${{ matrix.node-version }} uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 with: node-version: ${{ matrix.node-version }} - name: Installing run: npm ci - name: Linting run: npm run lint - name: Building run: npm run build - name: Testing run: npm test analyze: name: Analyze CodeQL runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write steps: - name: Checkout repository uses: actions/checkout@v4 - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: languages: javascript-typescript - name: Autobuild uses: github/codeql-action/autobuild@v3 - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 ================================================ FILE: .github/workflows/semgrep.yml ================================================ on: pull_request: {} workflow_dispatch: {} push: branches: - main - master schedule: - cron: '0 0 * * *' name: Semgrep config jobs: semgrep: name: semgrep/ci runs-on: ubuntu-latest env: SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} SEMGREP_URL: https://cloudflare.semgrep.dev SEMGREP_APP_URL: https://cloudflare.semgrep.dev SEMGREP_VERSION_CHECK_URL: https://cloudflare.semgrep.dev/api/check-version container: image: semgrep/semgrep steps: - uses: actions/checkout@v4 - run: semgrep ci ================================================ FILE: .gitignore ================================================ *.swp /node_modules /dist /lib /coverage ================================================ FILE: .npmrc ================================================ engine-strict=true ================================================ FILE: .prettierrc.json ================================================ { "semi": true, "singleQuote": true, "tabWidth": 4, "trailingComma": "all", "printWidth": 100 } ================================================ FILE: CONTRIBUTING.md ================================================ ## Contribution Guidelines ### Code Style Code is written in TypeScript and is automatically formatted with prettier. ```bash npm run format ``` ### Naming convention It is recommended to follow style guide for [TypeScript](https://google.github.io/styleguide/tsguide.html). ================================================ FILE: DEVELOPER.md ================================================ ## Directory Structure ``` pp-browser-extension ├──📂 platform: Contains platform specific assets, such as manifest for browsers, or brackground and service worker script used by each platform. ├──📂 public: Contains all the assets which are neither the business logic files nor the style sheets. └──📂 src: Contains all the business logic files and the style sheets. └──📂 background: The business logic of the extension service worker. └──📂 common: The shared logic between the extension background script and its options context. └──📂 options: The web app defining option page in the browser settings. ``` ================================================ FILE: LICENSE ================================================ Copyright (c) 2017-2022, Privacy Pass Team, Cloudflare, Inc., and other contributors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: README.md ================================================ [![github release](https://img.shields.io/github/release/cloudflare/pp-browser-extension.svg)](https://github.com/cloudflare/pp-browser-extension/releases/) [![Privacy Pass](https://github.com/cloudflare/pp-browser-extension/actions/workflows/action.yml/badge.svg)](https://github.com/cloudflare/pp-browser-extension/actions) [![License](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) # Silk - Privacy Pass Client for the browser ![Privacy Pass logo](./public/icons/128/gold.png) This browser extension implements the client-side of the Privacy Pass protocol providing unlinkable cryptographic tokens. **Specification:* Compliant with IETF [draft-ietf-privacypass-protocol v11](https://datatracker.ietf.org/doc/draft-ietf-privacypass-protocol/). **Support:** * ✅ Public-Verifiable tokens (Blind-RSA) * 🚧 Private-Verifiable tokens (VOPRF) * 🚧 Batched tokens * 🚧 Rate limited tokens ## Installation | **[Chrome][chrome-store]** | **[Firefox][firefox-store]** | | -- | -- | | [![chrome logo](./public/icons/browser/chrome.png)][chrome-store] | [![firefox logo](./public/icons/browser/firefox.png)][firefox-store] | ## How it works? **Privacy Pass Attesters:** 🟩 [Cloudflare Research with Turnstile][cf-url] [cf-url]: https://pp-attester-turnstile.research.cloudflare.com/ [chrome-store]: https://chrome.google.com/webstore/detail/privacy-pass/ajhmfdgkijocedmfjonnpjfojldioehi/ [firefox-store]: https://addons.mozilla.org/firefox/addon/privacy-pass/ **Get tokens** - If a website requests a Privacy Pass token, the extension is automatically going to request you to perform the associated challenge. - One page will open with a challenge to be solved. - Solve successfully the challenge and the extension will get **one** token. See [FAQs](#faqs) and [Known Issues](#known-issues) section: if something is not working as expected. --- ## Installing from Sources We recommend to install the extension using the official browser stores listed in [Installation](#Installation) section above. If you want to compile the sources or your browser is not supported, you can install the extension as follows. ### Building ```sh git clone https://github.com/cloudflare/pp-browser-extension nvm use 20 npm ci npm run build ``` Once these steps complete, the `dist` folder will contain all files required to load the extension. ### Running Tests ```sh nvm use 20 npm ci npm test ``` ### Manually Loading Extension #### Firefox 1. Open Firefox and navigate to [about:debugging#/runtime/this-firefox/](about:debugging#/runtime/this-firefox/) 1. Click on 'Load Temporary Add-on' button. 1. Select `manifest.json` from the `dist` folder. 1. Check extension logo appears in the top-right corner of the browser. #### Chrome 1. Open Chrome and navigate to [chrome://extensions/](chrome://extensions/) 1. Turn on the 'Developer mode' on the top-right corner. 1. Click on 'Load unpacked' button. 1. Select the `dist` folder. 1. Check extension logo appears in the top-right corner of the browser. 1. If you cannot see the extension logo, it's likely not pinned to the toolbar. #### Edge - Open Edge and navigate to [edge://extensions/](edge://extensions/) - Turn on the 'Developer mode' on the left bar. - Click on 'Load unpacked' button in the main panel. - Select the `dist` folder. - The extension will appear listed in the main panel. - To see the extension in the bar, click in the puzzle icon and enable it, so it gets pinned to the toolbar. --- ### Highlights **2023** -- The extension updates to Privacy Pass Protocol draft 16, with the cryptographic part in a dedicated library [cloudflare/privacypass-ts](https://github.com/cloudflare/privacypass-ts). Introducing the notion of Attesters and Issuers. **2022** -- The Privacy Pass protocol can also use RSA blind signatures. **2021** -- In this [blog post](https://blog.cloudflare.com/privacy-pass-v3), we announced the [v3](https://github.com/cloudflare/pp-browser-extension/tree/v3.0.0) version of this extension, which makes the code base more resilient, extensible, and maintainable. **2020** -- The CFRG (part of IRTF/IETF) started a [working group](https://datatracker.ietf.org/wg/privacypass/about/) seeking for the standardization of the Privacy Pass protocol. **2019** -- The CAPTCHA provider [hCaptcha](https://www.hcaptcha.com/privacy-pass) announced support for Privacy Pass, and the [v2](https://github.com/cloudflare/pp-browser-extension/tree/2.0.0) version was released. **2018** -- The Privacy Pass protocol is based on a _Verifiable, Oblivious Pseudorandom Function_ (VOPRF) first established by [Jarecki et al. 2014](https://eprint.iacr.org/2014/650.pdf). The details of the protocol were published at [PoPETS 2018](https://doi.org/10.1515/popets-2018-0026) paper authored by Alex Davidson, Ian Goldberg, Nick Sullivan, George Tankersley, and Filippo Valsorda. Its homepage is still available at [https://privacypass.github.io](https://privacypass.github.io/). #### Acknowledgements The creation of the Privacy Pass protocol was a joint effort by the team made up of George Tankersley, Ian Goldberg, Nick Sullivan, Filippo Valsorda, and Alex Davidson. The Privacy Pass team would like to thank Eric Tsai for creating the logo and extension design, Dan Boneh for helping us develop key parts of the protocol, as well as Peter Wu and Blake Loring for their helpful code reviews. We would also like to acknowledge Sharon Goldberg, Christopher Wood, Peter Eckersley, Brian Warner, Zaki Manian, Tony Arcieri, Prateek Mittal, Zhuotao Liu, Isis Lovecruft, Henry de Valence, Mike Perry, Trevor Perrin, Zi Lin, Justin Paine, Marek Majkowski, Eoin Brady, Aaran McGuire, Suphanat Chunhapanya, Armando Faz Hernández, Benedikt Wolters, Maxime Guerreiro, Cefan Rubin, Thibault Meunier and many others who were involved in one way or another and whose efforts are appreciated. --- ## FAQs #### As a user, how can I add new attestation methods Depending on your browser settings, the local storage of your browser may be cleared when it is restarted. Privacy Pass stores passes in local storage and so these will also be cleared. This behavior may also be observed if you clear out the cache of your browser. #### My website support Privacy Pass authentication scheme, but the extension does nothing This can be an issuer issue, or a Chrome issue. In the later case, make sure you implement a [client replay on your website](#chrome-support-via-client-replay-api). #### As a service operator, how to roll out out my own attestation method Privacy Pass does not propose a standard attester API. This extension relies on attester to implement the [cloudflare/pp-attester](https://github.com/cloudflare/pp-attester) API. If you have such an attester, you should ask your user to update their attesters in the extension options. The attester order matters, first one has a higher priority than the second. --- ## Known Issues #### Extensions that modify user-agent or headers There is a [conflict resolution](https://developer.chrome.com/docs/extensions/reference/webRequest/#conflict-resolution) happening when more than one extension tries to modify the headers of a request. According to documentation, the more recent installed extension is the one that can update headers, while others will fail. Compounded to that, Cloudflare will ignore clearance cookies when the user-agent request does not match the one used when obtaining the cookie. ## Chrome support via Client replay API ### Overview Chrome does not allow extensions to block a request and perform another action, such as completing an attestation flow. To this extent, the extension enables websites to orchestrate a client side replay as defined below. ### Requirements Your website fetches subressources with JavaScript. If your server returns a challenge on the main frame, the extension automatically refreshes the page without any action needed from your side. ### System definition On every request, the extension adds header `Private-Token-Client-Replay: `. `requestID` is a UUID identifying your next replay attempt. For resources you know need to be replayed because they contain a `WWW-Authenticate: PrivateToken ...` header, you are going to use this `requestID` to query the extension on the state of the associated token retrieval. Once the extension has retrieved the token, you can replay the request. Given a `requestID`, you can query `https://no-reply.private-token.research.cloudflare.com/requestID/`. The domain `no-reply.private-token.research.cloudflare.com` [does not resolve](https://dnsviz.net/d/no-reply.private-token.research.cloudflare.com/dnssec/) to an IP address, and is captured by the browser extension for replay purposes. Its reply is always going to be a redirect to a ["data" URL](https://www.rfc-editor.org/rfc/rfc2397#section-2) of the form `data:text/plain,`. **`GET /requestID/`** * Response URL: `data:text/plain,` * `status` is `pending`, `fulfilled`, `not-found` Your website should do the following depending on the returned `status`: * if `pending`, wait and query `/requestID/` again in the future * if `fulfilled`, replay the initial request * if `not-found`, cancel the initial request A sequence diagram illustrate the flow below ```mermaid sequenceDiagram participant E as Client participant O as Origin participant B as Extension E->>O: GET example.com/img.png O->>B: WWW-Authenticate: challenge=[x] Note over B: Cannot block request B->>B: Adds "Private-Token-Client-Replay: requestID" B->>E: 401 Unauthorized + "Private-Token-Client-Replay: requestID" par Extension fetches token Note over B: Interact with Attester to retrieve Token [x] and Client wait E->>E: Check "Private-Token-Client-Replay" header E->>B: GET /requestID/ B->>E: "200 data:text/plain,pending" end B->>E: "200 data:text/plain,fulfilled" Note over E: Extension is ready to query the Origin Note over B: Extension is intercepting request to example.com E->>B: GET example.com/img.png B->>O: GET example.com/img.png + Authorization [x] O->>E: Cat Picture ``` ### Design considerations The proposed replay mechanism has been contrained by [Chrome API reference](https://developer.chrome.com/docs/extensions/reference/), specifically the [declarativeNetRequest API](https://developer.chrome.com/docs/extensions/reference/declarativeNetRequest/). Specifically, the following choices have been engineered as follow * Use a dedicated replay domain to allow any website to query the extension without hardcoding its ID * Use a dedicated replay domain that does not resolve to prevent DoS risks, and ensure there are no traffic interception with a faulty extension * Have every parameter, namely `requestID` be part of the URL to allow for a synchronous response from the extension, headers not being available on every event * Redirect to a ["data" URL](https://www.rfc-editor.org/rfc/rfc2397#section-2) to prevent DNS resolution and other extensions interference * Return `Private-Token-Client-Replay: ` on every request due to the inability to dynamically append headers to responses in MV3 * Use a pull based method to ease implementation on the extension and origin side ================================================ FILE: devel.md ================================================ ## Setting Up Chrome Extension for Development In a terminal: ```sh $ git clone https://github.com/cloudflare/pp-browser-extension $ nvm use 20 // it should work with Node v16+. $ npm ci $ npm run build ``` In Chrome: 1. Remove all extensions of Privacy pass. current public extension is v3.0.5. 1. We will install extension v4.0.0 which has **Manifest v3**. 1. Load the extension from the source code. 1. Upon loading, you should see Privacy Pass extension with a red logo. This means you are using the local build. 1. Open the Service worker & Devtools of a blank tab. 1. Navigate to https://demo-pat.research.cloudflare.com/login. 1. The behaviour expected is that browser first receives a 401 error, which is catched by the extension and then a reload that brings the body 'Token OK'. Chrome extension uses a replay mechanism documented in the [README](./README.md) as request blocking is not supported. ================================================ FILE: devel_pat.md ================================================ ## Setting Up PAT in Local Machine. ```sh $ git clone https://github.com/armfazh/pat-app --branch devel_branch cd pat-app ``` ```sh $ make certs ``` Make sure to follow the instructions in README for setting up certificates in your machine using mkcert. In three terminals: - `make issuer` - `make origin` - `make attester` Running Go client ```sh $ ./pat-app fetch --origin origin.example:4568 --secret `cat client.secret` --attester attester.example:4569 --resource "/index.html" --token-type basic ``` When the client is run, it fetches the privacypass webpage from origin using 'basic' (Type 2) tokens. (We will use Type 2 tokens initially, so it must be explicitly specified in the command/URL) A succesfull run looks like: ```sh $ ./pat-app fetch --origin origin.example:4568 --secret `cat client.secret` --attester attester.example:4569 --resource "/index.html" --token-type basic body was fetched!! ", "Armando Faz ", "Thibault Meunier ", "Cefan Rubin " ], "main": "index.js", "license": "BSD-3-Clause", "type": "module", "engines": { "node": ">=18" }, "scripts": { "build:options": "esbuild src/options/index.ts --bundle --minify --format=esm --target=chrome100 --outfile=dist/build/options/index.mjs && cp src/options/index.html src/options/index.css dist/build/options/", "build:privacypass": "npm run build:options && esbuild platform/$PLATFORM/index.ts --bundle --format=esm --target=chrome100 --outfile=dist/$PLATFORM/background.js && cp -r public/icons dist/$PLATFORM/icons && cp platform/$PLATFORM/manifest.json dist/$PLATFORM && mkdir -p dist/$PLATFORM/options && cp -r dist/build/options/* dist/$PLATFORM/options", "build:chromium": "export PLATFORM='chromium' && npm run build:privacypass", "build:firefox": "export PLATFORM='firefox' && npm run build:privacypass", "build:mv3-chromium": "export PLATFORM='mv3/chromium' && npm run build:privacypass", "build": "npm run build:chromium && npm run build:firefox && npm run build:mv3-chromium", "bundle:chromium": "npm run build:chromium && mkdir -p dist/build/bundle/chromium && cp -r dist/chromium/* dist/build/bundle/chromium && sed -i '/\"key\"/d' dist/build/bundle/chromium/manifest.json && web-ext build -s dist/build/bundle/chromium --overwrite-dest -a dist/web-ext-artifacts -n chromium.zip", "bundle:mv3-chromium": "npm run build:mv3-chromium && mkdir -p dist/build/bundle/mv3/chromium && cp -r dist/mv3/chromium/* dist/build/bundle/mv3/chromium && sed -i '/\"key\"/d' dist/build/bundle/mv3/chromium/manifest.json && web-ext build -s dist/build/bundle/mv3/chromium --overwrite-dest -a dist/web-ext-artifacts -n mv3-chromium.zip", "bundle:firefox": "npm run build:firefox && web-ext build -s dist/firefox --overwrite-dest -a dist/web-ext-artifacts -n firefox.zip", "bundle": "npm run bundle:chromium && npm run bundle:firefox && npm run bundle:mv3-chromium", "test": "tsc -b && node --experimental-vm-modules node_modules/jest/bin/jest.js --ci", "lint": "eslint .", "format": "eslint . --fix", "clean": "rimraf lib coverage dist" }, "dependencies": { "@cloudflare/privacypass-ts": "0.4.0" }, "devDependencies": { "@types/chrome": "0.0.263", "@types/jest": "29.5.12", "@types/node": "20.11.25", "@typescript-eslint/eslint-plugin": "7.1.1", "@typescript-eslint/parser": "7.1.1", "esbuild": "0.25.0", "eslint": "8.57.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-prettier": "5.1.3", "eslint-plugin-security": "2.1.1", "jest": "29.7.0", "prettier": "3.2.5", "rimraf": "5.0.5", "web-ext": "8.10.0" } } ================================================ FILE: platform/chromium/index.ts ================================================ import { BROWSERS, handleBeforeRequest, handleBeforeSendHeaders, handleHeadersReceived, handleInstall, handleStartup, } from '../../src/background'; const BROWSER = BROWSERS.CHROME; const STORAGE = chrome.storage.local; chrome.runtime.onInstalled.addListener(handleInstall(STORAGE)); chrome.runtime.onStartup.addListener(handleStartup(STORAGE)); chrome.webRequest.onBeforeRequest.addListener(handleBeforeRequest(), { urls: [''] }, [ 'blocking', ]); chrome.webRequest.onBeforeSendHeaders.addListener( handleBeforeSendHeaders(STORAGE), { urls: [''] }, ['requestHeaders', 'blocking', 'extraHeaders'], ); chrome.webRequest.onHeadersReceived.addListener( handleHeadersReceived(BROWSER, STORAGE), { urls: [''] }, ['responseHeaders', 'blocking'], ); ================================================ FILE: platform/chromium/manifest.json ================================================ { "name": "Silk - Privacy Pass Client", "manifest_version": 2, "description": "Client support for Privacy Pass anonymous authorization protocol.", "version": "4.0.2", "icons": { "32": "icons/32/gold.png", "48": "icons/48/gold.png", "64": "icons/64/gold.png", "128": "icons/128/gold.png" }, "background": { "scripts": [ "background.js" ] }, "permissions": [ "", "storage", "tabs", "webRequest", "webRequestBlocking" ], "options_ui": { "page": "options/index.html" } } ================================================ FILE: platform/chromium/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "resolveJsonModule": true }, "include": [ ".", "../../src/background", "../../src/common" ] } ================================================ FILE: platform/firefox/global.d.ts ================================================ declare const browser: typeof chrome; ================================================ FILE: platform/firefox/index.ts ================================================ import { BROWSERS, handleBeforeSendHeaders, handleHeadersReceived, handleInstall, handleStartup, } from '../../src/background'; const BROWSER = BROWSERS.FIREFOX; const STORAGE = browser.storage.local; chrome.runtime.onInstalled.addListener(handleInstall(STORAGE)); chrome.runtime.onStartup.addListener(handleStartup(STORAGE)); chrome.webRequest.onBeforeSendHeaders.addListener( handleBeforeSendHeaders(STORAGE), { urls: [''] }, ['requestHeaders', 'blocking'], ); chrome.webRequest.onHeadersReceived.addListener( handleHeadersReceived(BROWSER, STORAGE), { urls: [''] }, ['responseHeaders', 'blocking'], ); ================================================ FILE: platform/firefox/manifest.json ================================================ { "name": "Silk - Privacy Pass Client", "manifest_version": 2, "description": "Client support for Privacy Pass anonymous authorization protocol.", "version": "4.0.2", "icons": { "32": "icons/32/gold.png", "48": "icons/48/gold.png", "64": "icons/64/gold.png", "128": "icons/128/gold.png" }, "background": { "scripts": [ "background.js" ] }, "permissions": [ "", "storage", "tabs", "webRequest", "webRequestBlocking" ], "options_ui": { "page": "options/index.html" }, "browser_specific_settings": { "gecko": { "id": "{48748554-4c01-49e8-94af-79662bf34d50}" } } } ================================================ FILE: platform/firefox/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "resolveJsonModule": true }, "include": [ ".", "../../src/background", "../../../src/common" ] } ================================================ FILE: platform/mv3/chromium/index.ts ================================================ import { BROWSERS, handleBeforeRequest, handleBeforeSendHeaders, handleHeadersReceived, handleInstall, handleStartup, } from '../../../src/background'; const BROWSER = BROWSERS.CHROME; const STORAGE = chrome.storage.local; chrome.runtime.onInstalled.addListener(handleInstall(STORAGE)); chrome.runtime.onStartup.addListener(handleStartup(STORAGE)); chrome.webRequest.onBeforeRequest.addListener(handleBeforeRequest(), { urls: [''] }); chrome.webRequest.onBeforeSendHeaders.addListener( handleBeforeSendHeaders(STORAGE), { urls: [''] }, ['requestHeaders'], ); chrome.webRequest.onHeadersReceived.addListener( handleHeadersReceived(BROWSER, STORAGE), { urls: [''] }, ['responseHeaders'], ); ================================================ FILE: platform/mv3/chromium/manifest.json ================================================ { "name": "Silk - Privacy Pass Client", "manifest_version": 3, "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp+7N/GLYPSBqf+O7fGOaLQ+3hIvXrhsjo8Pq73aSVw08YZRWIIsM+xRnaqdgcu3dXgQGjuVbFVD0nQzg2ymOJk40PSZ/EDdbmWmvD6IrKV88qdmLAlMCvgYW6oRQG5EIBNrmUOqQvTHpdaHrYxY3g4OQfaCe5mvYS64VG6Tb9f1G/UqUcNQzdo1A7x6ElCMjl+isMkt1in749KxgCmhHlZshQfCf1tXwcjhh09luatwP+KCFqCw0VPNMij1eBHi+Z1r43o1RNSTVoiot4mXHMej/rZiApG3U0eVfhp6yOLLakNGnzy1hR5OO00vlMVSyg2HR4VnvPyJuZ+VRNGBo7wIDAQAB", "description": "Client support for Privacy Pass anonymous authorization protocol.", "version": "4.0.2", "icons": { "32": "icons/32/gold.png", "48": "icons/48/gold.png", "64": "icons/64/gold.png", "128": "icons/128/gold.png" }, "background": { "service_worker": "background.js", "type": "module" }, "permissions": [ "declarativeNetRequest", "storage", "tabs", "webRequest" ], "options_ui": { "page": "options/index.html" }, "host_permissions": [ "" ] } ================================================ FILE: platform/mv3/chromium/tsconfig.json ================================================ { "extends": "../../../tsconfig.json", "compilerOptions": { "resolveJsonModule": true }, "include": [ ".", "../../../src/background", "../../../src/common" ] } ================================================ FILE: src/background/attesters.ts ================================================ export { refreshAttesterLookupByIssuerKey } from '../common'; import { STORAGE_ID_ATTESTER_CONFIGURATION } from './const'; // return an attester challenge URI based on the public key presented in 401 response export const keyToAttesterURI = async ( storage: chrome.storage.StorageArea, key: string, ): Promise => { // get attester hostname that corresponds to this key const storageItem: Record> = await new Promise((resolve) => storage.get([STORAGE_ID_ATTESTER_CONFIGURATION], resolve), ); const attestersByIssuerKey: Record = storageItem[STORAGE_ID_ATTESTER_CONFIGURATION]; return attestersByIssuerKey[key]; }; ================================================ FILE: src/background/const.ts ================================================ export * from '../common'; export const BROWSERS = { CHROME: 'Chrome', FIREFOX: 'Firefox', EDGE: 'Edge', } as const; export type BROWSERS = (typeof BROWSERS)[keyof typeof BROWSERS]; export const PRIVACY_PASS_API_REPLAY_HEADER = 'private-token-client-replay'; export const PRIVACY_PASS_API_REPLAY_URI = 'https://no-reply.private-token.research.cloudflare.com'; ================================================ FILE: src/background/index.ts ================================================ import { keyToAttesterURI, refreshAttesterLookupByIssuerKey } from './attesters'; import { SERVICE_WORKER_MODE, getRawSettings, getSettings } from '../common'; import { BROWSERS, PRIVACY_PASS_API_REPLAY_HEADER, PRIVACY_PASS_API_REPLAY_URI } from './const'; import { getLogger } from '../common/logger'; import { fetchPublicVerifToken } from './pubVerifToken'; import { REPLAY_STATE, getRequestID, setReplayDomainRule } from './replay'; import { getAuthorizationRule, getIdentificationRule, removeAuthorizationRule } from './rules'; import { QueryablePromise, isManifestV3, promiseToQueryable, uint8ToB64URL } from './util'; import { PrivateToken, TOKEN_TYPES } from '@cloudflare/privacypass-ts'; export { BROWSERS, PRIVACY_PASS_API_REPLAY_URI } from './const'; interface SessionCachedData { [key: string]: UrlOriginTabs; } interface UrlOriginTabs { [key: number]: OriginAttesterTabDetails; } interface OriginAttesterTabDetails { attesterTabId: number; attestationData: string; } const PENDING = REPLAY_STATE.PENDING; type PENDING = typeof PENDING; const TOKENS: Record = {}; const cachedTabs: Record = {}; export const headerToToken = async ( url: string, tabId: number, header: string, storage: chrome.storage.StorageArea, ): Promise => { const { serviceWorkerMode: mode } = getSettings(); const logger = getLogger(mode); const tokenDetails = PrivateToken.parse(header); if (tokenDetails.length === 0) { return undefined; } const td = tokenDetails.slice(-1)[0]; switch (td.challenge.tokenType) { case TOKEN_TYPES.BLIND_RSA.value: logger.debug(`type of challenge: ${td.challenge.tokenType} is supported`); // Slow down if demo if (mode === SERVICE_WORKER_MODE.DEMO) { await new Promise((resolve) => setTimeout(resolve, 5 * 1000)); } const tokenPublicKey: string = uint8ToB64URL(td.tokenKey); let attesterURI = await keyToAttesterURI(storage, tokenPublicKey); if (!attesterURI) { return undefined; } // API expects interactive Challenge at /challenge attesterURI = `${attesterURI}/challenge`; // To minimize the number of tabs opened, we check if the requesting tab is the focused tab const tabInfo: chrome.tabs.Tab | undefined = await new Promise((resolve) => { chrome.tabs.get(tabId, resolve); }); const tabWindow: chrome.windows.Window | undefined = await new Promise((resolve) => { if (tabInfo) { chrome.windows.get(tabInfo.windowId, resolve); } }); if (!tabWindow?.focused || !tabInfo?.active) { logger.debug('Not opening a new tab due to requesting tab not being active'); return undefined; } const tab: chrome.tabs.Tab = await new Promise((resolve) => chrome.tabs.create({ url: attesterURI }, resolve), ); // save this new tabId of attester tab to session storage under the originTabId const existing: SessionCachedData = await new Promise((resolve) => storage.get(url, resolve), ); if (existing[url][tabId]) { existing[url][tabId]['attesterTabId'] = tab.id!; storage.set({ [url]: existing[url] }); } const token = await fetchPublicVerifToken(td, tabId, storage); const encodedToken = uint8ToB64URL(token.serialize()); return encodedToken; default: logger.error(`unrecognized type of challenge: ${td.challenge.tokenType}`); } return undefined; }; export const handleInstall = (storage: chrome.storage.StorageArea) => async (_details: chrome.runtime.InstalledDetails) => { const { serviceWorkerMode: mode } = await getRawSettings(storage); const logger = getLogger(mode); if (isManifestV3(chrome)) { chrome.declarativeNetRequest .updateSessionRules(removeAuthorizationRule()) .catch((e: unknown) => logger.debug(`failed to remove session rules:`, e)); } // Refresh lookup of attester by issuer key lookup used for auto selection of attester handleStartup(storage); getRequestID(); setReplayDomainRule(REPLAY_STATE.NOT_FOUND); }; export const handleStartup = (storage: chrome.storage.StorageArea) => async () => { const { serviceWorkerMode: mode } = await getRawSettings(storage); const logger = getLogger(mode); const alarmName = 'refreshAttesterLookupByIssuerKey'; chrome.alarms.clear(alarmName); chrome.alarms.create(alarmName, { delayInMinutes: 0, periodInMinutes: 24 * 60, // trigger once a day }); chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === alarmName) { logger.debug('refreshing attester lookup by issuer key'); refreshAttesterLookupByIssuerKey(storage); } }); }; const pendingRequests: Map> = new Map(); export const handleBeforeRequest = () => (details: chrome.webRequest.WebRequestBodyDetails) => { const settings = getSettings(); const { serviceWorkerMode: mode } = settings; const logger = getLogger(mode); try { chrome.tabs.get(details.tabId, (tab) => (cachedTabs[details.tabId] = tab)); } catch (err) { logger.debug(err); } // Handle active replay without generating a network request const url = new URL(details.url); if (url.origin !== PRIVACY_PASS_API_REPLAY_URI) { return; } const labels = url.pathname.split('/'); if (labels.length !== 2 || labels[0] !== '' || labels[1] !== 'requestID') { return { redirectUrl: `data:text/plain,${REPLAY_STATE.NOT_FOUND}` }; } const requestID = labels[2]; const promise = pendingRequests.get(requestID); let state: REPLAY_STATE = REPLAY_STATE.NOT_FOUND; if (promise?.isFullfilled) { state = REPLAY_STATE.FULFILLED; pendingRequests.delete(requestID); } if (promise?.isPending) { state = REPLAY_STATE.PENDING; } return { redirectUrl: `data:text/plain,${state}`, }; }; export const handleBeforeSendHeaders = (storage: chrome.storage.StorageArea) => ( details: chrome.webRequest.WebRequestHeadersDetails, ): void | chrome.webRequest.BlockingResponse => { if (!details) { return; } const ppToken = TOKENS[details.url]; if (ppToken === PENDING) { return; } if (ppToken && !chrome.declarativeNetRequest) { const headers = details.requestHeaders ?? []; headers.push({ name: 'Authorization', value: `PrivateToken token=${ppToken}` }); delete TOKENS[details.url]; return { requestHeaders: headers }; } if (!details.requestHeaders) { return; } if (details.requestHeaders && details.url) { // check for an attestation data sent from attester const pp_hdr = details.requestHeaders.find( (x) => x.name.toLowerCase() === 'private-token-attester-data', )?.value; if (pp_hdr) { const callback = (url_tab_data: SessionCachedData) => { // if we opened an attesterTab that matches the source of this data then store it lookForAttesterTabId: for (const url in url_tab_data) { for (const originTab of Object.values(url_tab_data[url])) { const tabDetails: OriginAttesterTabDetails = originTab; if ( tabDetails.attesterTabId && tabDetails.attesterTabId === details.tabId ) { originTab.attestationData = pp_hdr; storage.set(url_tab_data); // break to label above break lookForAttesterTabId; } } } }; storage.get(null, callback); } } if (isManifestV3(chrome)) { const { serviceWorkerMode: mode } = getSettings(); const logger = getLogger(mode); chrome.declarativeNetRequest .updateSessionRules(removeAuthorizationRule()) .catch((e: unknown) => logger.debug(`failed to remove session rules:`, e)); } return; }; export const handleHeadersReceived = (browser: BROWSERS, storage: chrome.storage.StorageArea) => ( details: chrome.webRequest.WebResponseHeadersDetails & { frameAncestors?: Array<{ url: string }>; }, ): void | chrome.webRequest.BlockingResponse => { // if no header were received with request if (!details.responseHeaders) { return; } // Check if there's a valid PrivateToken header const privateTokenChl = details.responseHeaders.find( (x) => x.name.toLowerCase() == 'www-authenticate', )?.value; if (!privateTokenChl) { return; } if (PrivateToken.parse(privateTokenChl).length === 0) { return; } const settings = getSettings(); if (Object.keys(settings).length === 0) { getRawSettings(storage); return; } const { attesters, serviceWorkerMode: mode } = settings; const logger = getLogger(mode); let initiator: string | undefined = undefined; if (details.frameId === 0) { initiator = details.url; } else { initiator = details.frameAncestors?.at(0)?.url ?? cachedTabs[details.tabId]?.url ?? details.initiator; } if (!initiator) { return; } const initiatorURL = new URL(initiator)?.origin; const isAttesterFrame = attesters.map((a) => new URL(a).origin).includes(initiatorURL); if (isAttesterFrame) { logger.info('PrivateToken support disabled on attester websites.'); return; } if (TOKENS[details.url] === PENDING) { return; } if (isManifestV3(chrome)) { // TODO: convert to static rule for simplicity perhaps? chrome.declarativeNetRequest.updateSessionRules(getIdentificationRule(details.url)); } // create a new entry storing this originTabId storage.get(details.url, (existing: UrlOriginTabs) => { existing[details.tabId] = { attesterTabId: -1, attestationData: '' }; storage.set({ [details.url]: existing }); }); // turn this received header into a token (tab opening and attester handling within here) const w3HeaderValue = headerToToken(details.url, details.tabId, privateTokenChl, storage); // Add a rule to declarativeNetRequest here if you want to block // or modify a header from this request. The rule is registered and // changes are observed between the onBeforeSendHeaders and // onSendHeaders methods. if (!chrome.declarativeNetRequest) { TOKENS[details.url] = PENDING; } const redirectPromise = w3HeaderValue .then(async (value): Promise => { if (!value) { delete TOKENS[details.url]; return; } if (isManifestV3(chrome)) { await chrome.declarativeNetRequest.updateSessionRules( getAuthorizationRule(details.url, `PrivateToken token=${value}`), ); } else { TOKENS[details.url] = value; } return { redirectUrl: details.url }; }) .catch((err) => { logger.error(`failed to retrieve PrivateToken token: ${err}`); }); switch (browser) { case BROWSERS.FIREFOX: // typing is incorrect, but force it for Firefox because browser is compatible return redirectPromise as unknown as chrome.webRequest.BlockingResponse; case BROWSERS.CHROME: // Refresh tab in chrome. const requestID = getRequestID(); setReplayDomainRule('pending', requestID); pendingRequests.set( requestID, promiseToQueryable( redirectPromise.then(() => { setReplayDomainRule(REPLAY_STATE.FULFILLED, requestID); }), ), ); // Detect call context, and only refresh if it's a top level request redirectPromise.then(async () => { if (details.type === 'main_frame') { chrome.tabs.update(details.tabId, { url: details.url }); } }); const responseHeaders = details.responseHeaders ?? []; responseHeaders.push({ name: PRIVACY_PASS_API_REPLAY_HEADER, value: requestID }); return { responseHeaders, }; } }; ================================================ FILE: src/background/pubVerifToken.ts ================================================ import { Client, PrivateToken, Token, TokenResponse } from '@cloudflare/privacypass-ts'; export async function fetchPublicVerifToken( privateToken: PrivateToken, originTabId: number, storage: chrome.storage.StorageArea, ): Promise { let attesterIssuerProxyURI: string; const attesterToken: string = await new Promise((resolve) => { storage.onChanged.addListener(async (changes) => { for (const [, value] of Object.entries(changes)) { if (!value.newValue) { continue; } const newValue = value.newValue; if ( newValue[originTabId] && newValue[originTabId].hasOwnProperty('attestationData') && // eslint-disable-line no-prototype-builtins newValue[originTabId].attestationData != '' ) { // before we close it retrieve the URL of the attester tab const tab: chrome.tabs.Tab = await new Promise((resolve) => chrome.tabs.get(newValue[originTabId].attesterTabId, resolve), ); if (!tab) { continue; } attesterIssuerProxyURI = tab.url!; // close the attester tab as we no longer need to interact with the attester front-end chrome.tabs.remove(newValue[originTabId].attesterTabId); resolve(newValue[originTabId].attestationData); } } }); }); chrome.tabs.update(originTabId, { active: true }); // Create a TokenRequest. const client = new Client(); const tokenRequest = await client.createTokenRequest(privateToken); // Send TokenRequest to Issuer (via Attester) as proxy const tokenResponse = await tokenRequest.send( attesterIssuerProxyURI!, TokenResponse, new Headers({ 'private-token-attester-data': attesterToken }), ); // Produce a token by Finalizing the TokenResponse. const token = await client.finalize(tokenResponse); return token; } ================================================ FILE: src/background/replay.ts ================================================ import { PRIVACY_PASS_API_REPLAY_URI } from './const'; import { getRedirectRule, getReplayRule } from './rules'; import { isManifestV3 } from './util'; export const REPLAY_STATE = { FULFILLED: 'fulfilled', NOT_FOUND: 'not-found', PENDING: 'pending', } as const; export type REPLAY_STATE = (typeof REPLAY_STATE)[keyof typeof REPLAY_STATE]; export const getRequestID = (() => { let requestID = crypto.randomUUID(); return () => { const oldRequestID = requestID; requestID = crypto.randomUUID(); if (isManifestV3(chrome)) { chrome.declarativeNetRequest.updateSessionRules(getReplayRule(requestID)); } return oldRequestID; }; })(); type UUID = ReturnType; export const setReplayDomainRule = (state: REPLAY_STATE, requestID?: UUID) => { if (!chrome.declarativeNetRequest) { return; } const filterSuffix = requestID ? `/requestID/${requestID}` : '/*'; const urlFilter = `${PRIVACY_PASS_API_REPLAY_URI}${filterSuffix}`; chrome.declarativeNetRequest.updateSessionRules( getRedirectRule(urlFilter, `data:text/plain,${state}`), ); }; ================================================ FILE: src/background/rules.ts ================================================ import { PRIVACY_PASS_API_REPLAY_HEADER } from './const'; // First size digits of md5 of 'privacy-pass-extension-identification' as integer export const PRIVACY_PASS_EXTENSION_RULE_OFFSET = 11943591; export function getRuleID(x: number) { return PRIVACY_PASS_EXTENSION_RULE_OFFSET + x; } const RULE_IDS = { IDENTIFICATION: getRuleID(1), AUTHORIZATION: getRuleID(2), REPLAY: getRuleID(3), REDIRECT: getRuleID(4), }; const EXTENSION_SUPPORTED_RESOURCE_TYPES = chrome.declarativeNetRequest ? [ chrome.declarativeNetRequest.ResourceType.MAIN_FRAME, chrome.declarativeNetRequest.ResourceType.SUB_FRAME, chrome.declarativeNetRequest.ResourceType.XMLHTTPREQUEST, ] : []; export function getIdentificationRule(url: string): chrome.declarativeNetRequest.UpdateRuleOptions { // TODO: convert to static rule for simplicity perhaps? return { removeRuleIds: [RULE_IDS.IDENTIFICATION], addRules: [ { action: { type: chrome.declarativeNetRequest.RuleActionType.MODIFY_HEADERS, responseHeaders: [ // Use Server-Timimg header because Set-Cookie is unreliable in this context in Chrome { header: 'Server-Timing', operation: chrome.declarativeNetRequest.HeaderOperation.SET, value: 'PrivacyPassExtensionV; desc=4', }, ], }, condition: { urlFilter: new URL(url).toString(), resourceTypes: [chrome.declarativeNetRequest.ResourceType.MAIN_FRAME], }, id: RULE_IDS.IDENTIFICATION, priority: 1, }, ], }; } export function getAuthorizationRule( url: string, authorizationHeader: string, ): chrome.declarativeNetRequest.UpdateRuleOptions { return { removeRuleIds: [RULE_IDS.AUTHORIZATION], addRules: [ { id: RULE_IDS.AUTHORIZATION, priority: 1, action: { type: chrome.declarativeNetRequest.RuleActionType.MODIFY_HEADERS, requestHeaders: [ { header: 'Authorization', operation: chrome.declarativeNetRequest.HeaderOperation.SET, value: authorizationHeader, }, ], }, condition: { // Note: The urlFilter must be composed of only ASCII characters. urlFilter: new URL(url).toString(), resourceTypes: EXTENSION_SUPPORTED_RESOURCE_TYPES, }, }, ], }; } export function removeAuthorizationRule(): chrome.declarativeNetRequest.UpdateRuleOptions { return { removeRuleIds: [RULE_IDS.AUTHORIZATION], }; } export function getReplayRule( replayHeader: string, ): chrome.declarativeNetRequest.UpdateRuleOptions { return { removeRuleIds: [RULE_IDS.REPLAY], addRules: [ { id: RULE_IDS.REPLAY, priority: 10, action: { type: chrome.declarativeNetRequest.RuleActionType.MODIFY_HEADERS, responseHeaders: [ { header: PRIVACY_PASS_API_REPLAY_HEADER, operation: chrome.declarativeNetRequest.HeaderOperation.SET, value: replayHeader, }, ], }, condition: { // Chrome declarativeNetRequest have to be defined before the request is made. Given a PP request can be made from any URL, returning a responseID for all URLs is required. urlFilter: '*', resourceTypes: EXTENSION_SUPPORTED_RESOURCE_TYPES, }, }, ], }; } export function getRedirectRule( urlFilter: string, redirectURL: string, ): chrome.declarativeNetRequest.UpdateRuleOptions { return { removeRuleIds: [RULE_IDS.REDIRECT], addRules: [ { id: RULE_IDS.REDIRECT, priority: 5, action: { type: chrome.declarativeNetRequest.RuleActionType.REDIRECT, redirect: { url: redirectURL }, }, condition: { urlFilter, resourceTypes: EXTENSION_SUPPORTED_RESOURCE_TYPES, }, }, ], }; } ================================================ FILE: src/background/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "resolveJsonModule": true }, "include": [ ".", "../common" ] } ================================================ FILE: src/background/util.test.ts ================================================ import { uint8ToB64URL } from './util.js'; describe('uint8ToB64URL', () => { it('should convert simple arrays', () => { const u1 = Uint8Array.from([1]); expect(uint8ToB64URL(u1)).toBe('AQ=='); }); it('should return an empty string on an empty array', () => { const u = new Uint8Array(); expect(uint8ToB64URL(u)).toBe(''); }); it('should return 2 an empty string on an empty array', () => { const u = new Uint8Array(16); expect(uint8ToB64URL(u)).toBe('AAAAAAAAAAAAAAAAAAAAAA=='); }); }); ================================================ FILE: src/background/util.ts ================================================ function u8ToB64(u: Uint8Array): string { return btoa(String.fromCharCode(...u)); } function b64ToB64URL(s: string): string { return s.replace(/\+/g, '-').replace(/\//g, '_'); } export function uint8ToB64URL(u: Uint8Array): string { return b64ToB64URL(u8ToB64(u)); } // JavaScript Promise don't expose the current status of the promise // This extension aims to work exactly like a native Promise (therefore extends) with the addition of getter for the current status export interface QueryablePromise extends Promise { isPending: boolean; isFullfilled: boolean; isRejected: boolean; isResolved: boolean; } export function promiseToQueryable(p: Promise): QueryablePromise { let _isFullfilled = false; let _isRejected = false; let _isResolved = false; const ret: QueryablePromise = { get isPending() { return !_isFullfilled; }, get isFullfilled() { return _isFullfilled; }, get isRejected() { return _isRejected; }, get isResolved() { return _isResolved; }, then: p.then, catch: p.catch, finally: p.finally, [Symbol.toStringTag]: p[Symbol.toStringTag], }; p.then((_result) => { _isFullfilled = true; _isResolved = true; }).catch((_error) => { _isFullfilled = true; _isRejected = true; }); return ret; } export function isManifestV3(browser: typeof chrome) { return !!browser.declarativeNetRequest; } ================================================ FILE: src/common/const.ts ================================================ export const STORAGE_ID_ATTESTER_CONFIGURATION = 'attesters_by_issuer_key'; ================================================ FILE: src/common/index.ts ================================================ export * from './const'; export * from './logger'; export * from './settings'; ================================================ FILE: src/common/logger.ts ================================================ /* eslint-disable no-console */ import { SERVICE_WORKER_MODE } from './settings'; interface Logger { info: (...obj: unknown[]) => void; // Log an info level message error: (...obj: unknown[]) => void; // Log an error level message debug: (...obj: unknown[]) => void; // Log a debug level message warn: (...obj: unknown[]) => void; // Log a Warning level message log: (...obj: unknown[]) => void; // Log a message } class ConsoleLogger implements Logger { public info = (...obj: unknown[]): void => { console.info(...obj); }; public error = (...obj: unknown[]): void => { console.error(...obj); }; public debug = (...obj: unknown[]): void => { console.debug(...obj); }; public warn = (...obj: unknown[]): void => { console.warn(...obj); }; public log = (...obj: unknown[]): void => { console.log(...obj); }; } class ModeLogger extends ConsoleLogger { private static ALLOWED_CALLS: Record = { INFO: [SERVICE_WORKER_MODE.DEVELOPMENT], ERROR: [ SERVICE_WORKER_MODE.DEMO, SERVICE_WORKER_MODE.DEVELOPMENT, SERVICE_WORKER_MODE.PRODUCTION, ], DEBUG: [SERVICE_WORKER_MODE.DEVELOPMENT], WARN: [SERVICE_WORKER_MODE.DEVELOPMENT], LOG: [ SERVICE_WORKER_MODE.DEMO, SERVICE_WORKER_MODE.DEVELOPMENT, SERVICE_WORKER_MODE.PRODUCTION, ], }; constructor(public mode: SERVICE_WORKER_MODE) { super(); } public info = (...obj: unknown[]): void => { if (!ModeLogger.ALLOWED_CALLS.INFO.includes(this.mode)) { return; } console.info(...obj); }; public error = (...obj: unknown[]): void => { if (!ModeLogger.ALLOWED_CALLS.ERROR.includes(this.mode)) { return; } console.error(...obj); }; public debug = (...obj: unknown[]): void => { if (!ModeLogger.ALLOWED_CALLS.DEBUG.includes(this.mode)) { return; } console.debug(...obj); }; public warn = (...obj: unknown[]): void => { if (!ModeLogger.ALLOWED_CALLS.WARN.includes(this.mode)) { return; } console.warn(...obj); }; public log = (...obj: unknown[]): void => { if (!ModeLogger.ALLOWED_CALLS.LOG.includes(this.mode)) { return; } console.log(...obj); }; } export function getLogger(mode: SERVICE_WORKER_MODE) { return new ModeLogger(mode); } ================================================ FILE: src/common/settings.ts ================================================ import { IssuerConfig } from '@cloudflare/privacypass-ts'; import { STORAGE_ID_ATTESTER_CONFIGURATION } from './const'; import { getLogger } from './logger'; export const SETTING_NAMES = { SERVICE_WORKER_MODE: 'serviceWorkerMode', ATTESTERS: 'attesters', } as const; export type SettingsKeys = (typeof SETTING_NAMES)[keyof typeof SETTING_NAMES]; export type RawSettings = { serviceWorkerMode: SERVICE_WORKER_MODE; attesters: string; }; export type Settings = { serviceWorkerMode: SERVICE_WORKER_MODE; attesters: string[]; }; let settings: Settings = {} as unknown as Settings; export const rawSettingToSettingAttester = (attestersRaw: string): string[] => { return attestersRaw .split(/[\n,]+/) // either split on new line or comma .filter((attester) => { try { new URL(attester); return true; } catch (_) { return false; } }); }; export function getRawSettings(storage: chrome.storage.StorageArea): Promise { return new Promise((resolve) => storage.get(Object.values(SETTING_NAMES), async (items) => { if (Object.entries(items).length < 2) { await resetSettings(storage); const settings = getSettings(); resolve({ attesters: settings.attesters.join('\n'), serviceWorkerMode: settings.serviceWorkerMode, }); } else { const rawSettings = items as unknown as RawSettings; settings = { serviceWorkerMode: rawSettings.serviceWorkerMode, attesters: rawSettingToSettingAttester(rawSettings.attesters), }; resolve(rawSettings); } }), ); } export function getSettings(): Settings { return settings; } export const refreshAttesterLookupByIssuerKey = async (storage: chrome.storage.StorageArea) => { // Force reset associations between public keys and issuers that trust each attester that we know about const attestersByIssuerKey = new Map(); const { attesters, serviceWorkerMode: mode } = getSettings(); const logger = getLogger(mode); // Populate issuer keys for issuers that trust our ATTESTERS for (const attester of attesters) { const nowTimestamp = Date.now(); // Connect to each of the ATTESTERS we know about for their issuer directory const response = await fetch(`${attester}/v1/private-token-issuer-directory`); if (!response.ok) { logger.log(`"${attester}" issuer keys not available, attester will not be used.`); return; } const directory: Record = await response.json(); // for each attester in the directory for (const { 'token-keys': tokenKeys } of Object.values(directory)) { for (const key of tokenKeys) { const notBefore = key['not-before']; if (!notBefore || notBefore > nowTimestamp) { continue; } attestersByIssuerKey.set(key['token-key'], attester); } } storage.set({ [STORAGE_ID_ATTESTER_CONFIGURATION]: Object.fromEntries(attestersByIssuerKey), }); } logger.info('Attester lookup by Issuer key populated'); }; export async function saveSettings( storage: chrome.storage.StorageArea, name: SettingsKeys, value: string, ): Promise { switch (name) { case 'attesters': settings[name] = rawSettingToSettingAttester(value); await refreshAttesterLookupByIssuerKey(storage); break; case 'serviceWorkerMode': if ( Object.values(SERVICE_WORKER_MODE) .map((s) => s as string) .includes(value) ) { settings[name] = value as SERVICE_WORKER_MODE; } } return storage.set({ [name]: value }); } export async function resetSettings(storage: chrome.storage.StorageArea): Promise { const DEFAULT = { serviceWorkerMode: SERVICE_WORKER_MODE.PRODUCTION, attesters: [ 'https://pp-attester-turnstile.research.cloudflare.com', 'https://pp-attester-turnstile-dev.research.cloudflare.com', ], }; await saveSettings(storage, 'serviceWorkerMode', DEFAULT.serviceWorkerMode); await saveSettings(storage, 'attesters', DEFAULT.attesters.join('\n')); } export const SERVICE_WORKER_MODE = { PRODUCTION: 'production', DEVELOPMENT: 'development', DEMO: 'demo', } as const; export type SERVICE_WORKER_MODE = (typeof SERVICE_WORKER_MODE)[keyof typeof SERVICE_WORKER_MODE]; ================================================ FILE: src/options/global.d.ts ================================================ declare const browser: typeof chrome; ================================================ FILE: src/options/index.css ================================================ textarea { width: 80%; -webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */ -moz-box-sizing: border-box; /* Firefox, other Gecko */ box-sizing: border-box; /* Opera/IE 8+ */ } .hidden { display: none; } main { display: flex; flex-direction: column; } section { margin-bottom: 1em; } ================================================ FILE: src/options/index.html ================================================ Privacy Pass Popup
================================================ FILE: src/options/index.ts ================================================ import { getRawSettings, saveSettings, SETTING_NAMES, SettingsKeys } from '../common'; const STORAGE = typeof browser !== 'undefined' ? browser.storage.local : chrome.storage.local; // When the popup is loaded, load component that are stored in local/sync storage const onload = async () => { // Load current settings from sync storage const settings = await getRawSettings(STORAGE); // Every setting has a dedicated input which we need to set the default value, and onchange behaviour for (const name in settings) { const dropdown = document.getElementById(name) as HTMLInputElement; dropdown.value = settings[name]; dropdown.addEventListener('change', (event) => { const target = event.target as HTMLInputElement; if (!Object.values(SETTING_NAMES).includes(target.id as SettingsKeys)) { return; } saveSettings(STORAGE, target.id as SettingsKeys, target.value.trim()); }); } // Links won't open correctly within an extension popup. The following overwrite the default behaviour to open a new tab for (const a of [...document.getElementsByTagName('a')]) { a.addEventListener('click', (event) => { event.preventDefault(); chrome.tabs.create({ url: a.href }); }); } }; document.addEventListener('DOMContentLoaded', onload); ================================================ FILE: src/options/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "resolveJsonModule": true }, "include": [ ".", "../common" ] } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "es2020", "module": "es2020", "declaration": true, "declarationMap": true, "sourceMap": true, "composite": true, "incremental": true, "removeComments": true, "isolatedModules": true, "strict": true, "strictNullChecks": true, "noEmitOnError": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "moduleResolution": "node", "esModuleInterop": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "allowSyntheticDefaultImports": true, "rootDir": ".", "outDir": "lib" }, "files": [], "include": [], "references": [ { "path": "./src/background" } ] }