├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ ├── new_bug.md │ └── ui_ux_feedback.md └── workflows │ └── action.yml ├── .gitignore ├── .npmrc ├── .prettierrc.json ├── CONTRIBUTING.md ├── DEVELOPER.md ├── LICENSE ├── README.md ├── jest.config.mjs ├── jest.setup.mjs ├── package-lock.json ├── package.json ├── public ├── icons │ ├── 32 │ │ ├── gold.png │ │ └── grey.png │ ├── 48 │ │ ├── gold.png │ │ └── grey.png │ ├── 64 │ │ ├── gold.png │ │ └── grey.png │ ├── 128 │ │ ├── gold.png │ │ └── grey.png │ └── browser │ │ ├── chrome.png │ │ └── firefox.png ├── images │ ├── gold-badge.svg │ └── grey-badge.svg ├── manifest.json └── popup.html ├── src ├── background │ ├── index.ts │ ├── listeners │ │ ├── tabListener.ts │ │ └── webRequestListener.ts │ ├── providers │ │ ├── cloudflare.test.ts │ │ ├── cloudflare.ts │ │ ├── hcaptcha.test.ts │ │ ├── hcaptcha.ts │ │ └── index.ts │ ├── storage.ts │ ├── tab.ts │ ├── token.test.ts │ ├── token.ts │ ├── tsconfig.json │ ├── voprf.d.ts │ ├── voprf.js │ └── voprf.test.ts ├── blindrsa │ ├── blindrsa.test.ts │ ├── blindrsa.ts │ ├── index.ts │ ├── jsonModules.d.ts │ ├── sjcl.Makefile │ ├── sjcl │ │ ├── index.d.ts │ │ ├── index.js │ │ └── tsconfig.json │ ├── testdata │ │ ├── emsa_pss_vectors.json │ │ └── rsablind_vectors.json │ ├── tsconfig.json │ ├── util.test.ts │ └── util.ts └── popup │ ├── components │ ├── App │ │ ├── index.tsx │ │ └── styles.module.scss │ ├── Button │ │ ├── index.tsx │ │ └── styles.module.scss │ ├── ClearButton │ │ └── index.tsx │ ├── CloudflareButton │ │ └── index.tsx │ ├── Container │ │ ├── index.tsx │ │ └── styles.module.scss │ ├── GithubButton │ │ └── index.tsx │ ├── HcaptchaButton │ │ └── index.tsx │ ├── Header │ │ ├── index.tsx │ │ └── styles.module.scss │ └── PassButton │ │ ├── index.tsx │ │ └── styles.module.scss │ ├── index.tsx │ ├── store.ts │ ├── styles │ ├── _buttons.scss │ ├── _colors.scss │ └── body.scss │ ├── tsconfig.json │ └── types.d.ts ├── tsconfig.json └── webpack.config.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "ecmaVersion": 2020, 5 | "sourceType": "module", 6 | "ecmaFeatures": { 7 | "jsx": true 8 | } 9 | }, 10 | "plugins": [ 11 | "@typescript-eslint", 12 | "security", 13 | "prettier" 14 | ], 15 | "extends": [ 16 | "eslint:recommended", 17 | "plugin:@typescript-eslint/eslint-recommended", 18 | "plugin:@typescript-eslint/recommended", 19 | "plugin:security/recommended", 20 | "plugin:prettier/recommended", 21 | "prettier" 22 | ], 23 | "ignorePatterns": [ 24 | "**/*.d.ts", 25 | "**/*.js", 26 | "coverage/*", 27 | "lib/*" 28 | ], 29 | "rules": { 30 | "@typescript-eslint/member-delimiter-style": 0, 31 | "@typescript-eslint/no-namespace": [ 32 | "warn" 33 | ], 34 | "@typescript-eslint/no-unused-vars": [ 35 | "error", 36 | { 37 | "argsIgnorePattern": "^_" 38 | } 39 | ], 40 | "no-case-declarations": 0, 41 | "no-console": [ 42 | "error", 43 | { 44 | "allow": [ 45 | "warn", 46 | "error" 47 | ] 48 | } 49 | ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature-request 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem?** 11 | If so, make sure your problem hasn't been listed before. 12 | 13 | **Describe the solution you'd like** 14 | Comment about what can be improved, or what would you like to happen in response to some action. 15 | 16 | **Additional context** 17 | Add any other context about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new_bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report for a bug in Privacy Pass browser extension 4 | title: '' 5 | labels: triage 6 | assignees: '' 7 | 8 | --- 9 | 10 | Before reporting a new bug, verify if your request is already being tracked by another issue: https://github.com/privacypass/challenge-bypass-extension/issues. 11 | 12 | --- 13 | 14 | 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. 15 | 16 | **Describe the bug** 17 | A clear and concise description of the bug. 18 | 19 | **How to reproduce** 20 | Steps to reproduce the behavior: 21 | 1. Go to '...' 22 | 2. Click on '....' 23 | 3. Scroll down to '....' 24 | 4. See error 25 | 26 | **Expected behavior** 27 | A description of what is expected to happen. 28 | 29 | **System (please complete the following information):** 30 | - OS: [e.g. iOS/Windows] 31 | - Cloudflare tokens or hCaptcha tokens? 32 | - Browser [e.g. chrome, firefox] 33 | - Browser Version [e.g. 79, 80, ] 34 | - Privacy Pass Version [e.g. 2.0.4, 2.0.5 ] 35 | 36 | **Additional context** 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/ui_ux_feedback.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: UI/UX Feedback 3 | about: We welcome feedback regarding user experience 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe** how your experience can be improved. 11 | 12 | **Note** that this report does not consider errors or bugs in the extension. 13 | 14 | Get some inspiration from these questions: 15 | - *What do you expect to see when you perform some action?* 16 | - *There exist some troubles on rendering?* 17 | - *Would you like browsers have builtin support for Privacy Pass?* 18 | -------------------------------------------------------------------------------- /.github/workflows/action.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: PrivacyPass Challenge Extension 3 | 4 | on: 5 | push: 6 | branches: [master] 7 | pull_request: 8 | branches: [master] 9 | 10 | jobs: 11 | testing: 12 | name: Running on Node v${{ matrix.node-version }} 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: [18, 16] 17 | steps: 18 | - name: Checking out 19 | uses: actions/checkout@v3 20 | - name: Setup Node v${{ matrix.node-version }} 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - name: Installing 25 | run: npm ci 26 | - name: Linting 27 | run: npm run lint 28 | - name: Building 29 | run: npm run build 30 | - name: Testing 31 | run: npm test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | /node_modules 3 | /dist 4 | /lib 5 | /coverage 6 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 4, 5 | "trailingComma": "all", 6 | "printWidth": 100 7 | } 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contribution Guidelines 2 | 3 | ### Code Style 4 | 5 | Code is written in TypeScript and is automatically formatted with prettier. 6 | 7 | ``` 8 | $ npm run lint -- --fix 9 | ``` 10 | 11 | ### Naming convention 12 | 13 | It is recommended to follow style guide for [TypeScript](https://google.github.io/styleguide/tsguide.html). 14 | -------------------------------------------------------------------------------- /DEVELOPER.md: -------------------------------------------------------------------------------- 1 | ## Directory Structure 2 | 3 | ``` 4 | challenge-bypass-extension 5 | ├──📂 public: Contains all the assets which are neither the business logic files nor the style sheets. 6 | └──📂 src: Contains all the business logic files and the style sheets. 7 | └──📂 background: The business logic for the extension background process. 8 | │ └──📂 listeners: Contains all the listeners which listen on all the events happened in the browser. 9 | │ │ └──📜 tabListener.ts: The listeners which listen on all the tab related events [API](https://developer.chrome.com/docs/extensions/reference/tabs/). 10 | │ │ └──📜 webRequestListener.ts: The listeners which listen on all the web request related events [API](https://developer.chrome.com/docs/extensions/reference/webRequest/). 11 | │ └──📂 providers: Contains the provider-specific code of all the Privacy Pass providers in the extension. 12 | │ │ └──📜 cloudflare.ts: Code specific for Cloudflare provider. 13 | │ │ └──📜 hcaptcha.ts: Code specific for hCaptcha provider. 14 | │ └──📜 voprf.js: Legacy crypto code which is still in Vanilla JavaScript. 15 | │ └──📜 voprf.d.ts: TypeScript declaration file for the legacy crypto code. 16 | │ └──📜 tab.ts: Tab class to represent a tab and encapsulate everything which is Tab specific. 17 | │ └──📜 token.ts: Token class to represent a token and contain all the code related to tokens. 18 | └──📂 popup: The web app for the popup in the browser toolbar. 19 | └──📂 components: Contains all the React components. 20 | └──📂 styles: Contains all the style sheets which are shared among the React components. 21 | └──📜 types.d.ts: Global Typescript declaration. 22 | ``` 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-2022, Privacy Pass Team, Cloudflare, Inc., and other contributors. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | 3. Neither the name of the copyright holder nor the names of its contributors 14 | may be used to endorse or promote products derived from this software without 15 | specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 21 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 22 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 23 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 25 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deprecated 2 | 3 | #### This version of Privacy Pass extension is not currently under active development. More [details below](#deprecation). 4 | 5 | --- 6 | 7 | [![github release](https://img.shields.io/github/release/privacypass/challenge-bypass-extension.svg)](https://github.com/privacypass/challenge-bypass-extension/releases/) 8 | [![Privacy Pass](https://github.com/privacypass/challenge-bypass-extension/actions/workflows/action.yml/badge.svg)](https://github.com/privacypass/challenge-bypass-extension/actions) 9 | [![License](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) 10 | 11 | # Privacy Pass Extension 12 | 13 | ![Privacy Pass logo](./public/icons/128/gold.png) 14 | 15 | This browser extension implements the client-side of the Privacy Pass protocol providing unlinkable cryptographic tokens. For example, these tokens can be used on Cloudflare-protected websites to redeem a token instead of solving a CAPTCHA. 16 | 17 | Home page: **[https://privacypass.github.io][pp-home]** 18 | 19 | ## Installation 20 | 21 | | **[Chrome][chrome-store]** | **[Firefox][firefox-store]** | 22 | | -- | -- | 23 | | [![chrome logo](./public/icons/browser/chrome.png)][chrome-store] | [![firefox logo](./public/icons/browser/firefox.png)][firefox-store] | 24 | 25 | ## How it works? 26 | 27 | **Privacy Pass Providers:** 🟩 [Cloudflare][cf-url] 🟩 [hCaptcha][hc-url] 28 | 29 | [pp-home]: https://privacypass.github.io/ 30 | [cf-url]: https://issuance.privacypass.cloudflare.com/ 31 | [hc-url]: https://www.hcaptcha.com/privacy-pass/ 32 | [chrome-store]: https://chrome.google.com/webstore/detail/privacy-pass/ajhmfdgkijocedmfjonnpjfojldioehi/ 33 | [firefox-store]: https://addons.mozilla.org/firefox/addon/privacy-pass/ 34 | 35 | **Get tokens** 36 | - Click on the extension icon, and click on top of one of the **providers**. 37 | - One page will open with a CAPTCHA to be solved. 38 | - Solve successfully the CAPTCHA and the extenison will get some tokens. 39 | 40 | **Use tokens** 41 | - When a page shows a CAPTCHA from one of the **providers**, and if the extension has tokens, the browser uses a token to pass the provider's challenge without any interaction. 42 | - Otherwise, the user must solve the CAPTCHA, which grants some tokens for future use. 43 | 44 | See [FAQs](#faqs) and [Known Issues](#known-issues) section: if something is not working as expected. 45 | 46 | --- 47 | 48 | ## Installing from Sources 49 | 50 | 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. 51 | 52 | ### Building 53 | 54 | ```sh 55 | git clone https://github.com/privacypass/challenge-bypass-extension 56 | nvm use 16 57 | npm ci 58 | npm run build 59 | ``` 60 | 61 | Once these steps complete, the `dist` folder will contain all files required to load the extension. 62 | 63 | ### Running Tests 64 | 65 | ```sh 66 | nvm use 16 67 | npm ci 68 | npm test 69 | ``` 70 | 71 | ### Manually Loading Extension 72 | 73 | #### Firefox 74 | 75 | 1. Open Firefox and navigate to [about:debugging#/runtime/this-firefox/](about:debugging#/runtime/this-firefox/) 76 | 1. Click on 'Load Temporary Add-on' button. 77 | 1. Select `manifest.json` from the `dist` folder. 78 | 1. Check extension logo appears in the top-right corner of the browser. 79 | 80 | #### Chrome 81 | 82 | 1. Open Chrome and navigate to [chrome://extensions/](chrome://extensions/) 83 | 1. Turn on the 'Developer mode' on the top-right corner. 84 | 1. Click on 'Load unpacked' button. 85 | 1. Select the `dist` folder. 86 | 1. Check extension logo appears in the top-right corner of the browser. 87 | 1. If you cannot see the extension logo, it's likely not pinned to the toolbar. 88 | 89 | #### Edge 90 | 91 | - Open Edge and navigate to [edge://extensions/](edge://extensions/) 92 | - Turn on the 'Developer mode' on the left bar. 93 | - Click on 'Load unpacked' button in the main panel. 94 | - Select the `dist` folder. 95 | - The extension will appear listed in the main panel. 96 | - To see the extension in the bar, click in the puzzle icon and enable it, so it gets pinned to the toolbar. 97 | --- 98 | 99 | ### Highlights 100 | 101 | **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. 102 | 103 | **2019** -- The captcha provider [hCaptcha](https://www.hcaptcha.com/privacy-pass) announced support for Privacy Pass, and the [v2](https://github.com/privacypass/challenge-bypass-extension/tree/2.0.0) version was released. 104 | 105 | **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. 106 | 107 | **2021** -- In this [blog post](https://blog.cloudflare.com/privacy-pass-v3), we announced the [v3](https://github.com/privacypass/challenge-bypass-extension/tree/v3.0.0) version of this extension, which makes the code base more resilient, extensible, and maintainable. 108 | 109 | **2022** -- The Privacy Pass protocol can also use RSA blind signatures. 110 | 111 | 2024 -- The Privacy Pass protocol standardisation has diverged from the original PoPETS version, which this extension implements. To keep up with the protocol, CAPTCHA providers moved to this new version, and ended their support for PoPETS flavour. This repository remains as a relic of the past, but is not supported by any CAPTCHA providers. Cloudflare maintains [Silk - Privacy Pass client](https://github.com/cloudflare/pp-browser-extension) which forked this repository to provide IETF standard support. 112 | 113 | #### Acknowledgements 114 | 115 | 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. 116 | 117 | 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, and many others who were involved in one way or another and whose efforts are appreciated. 118 | 119 | --- 120 | 121 | ## FAQs 122 | 123 | #### What do I have to do to acquire new passes? 124 | 125 | 1. Click "Get More Passes" in the extension pop-up. 126 | 1. Solve the CAPTCHA that is presented on the webpage. 127 | 1. Your extension should be populated with new passes. 128 | 129 | #### Are passes stored after a browser restart? 130 | 131 | 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. 132 | 133 | --- 134 | 135 | ## Known Issues 136 | 137 | #### Extensions that modify user-agent or headers 138 | 139 | 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. 140 | 141 | Compounded to that, Cloudflare will ignore clearance cookies when the user-agent request does not match the one used when obtaining the cookie. 142 | 143 | #### hCaptcha support 144 | 145 | As of version 3.0.4, support for hCaptcha tokens has been re-enabled. Note: even though an hCaptcha captcha consumes one token from the extension, it is still possible that the user must solve an interactive captcha. This behaviour depends on the logic used by the captcha provider, and does not indicate a malfunctioning of the PrivacyPass extension. 146 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | moduleFileExtensions: ['js'], 3 | transform: {}, 4 | setupFiles: ['./jest.setup.mjs'], 5 | moduleNameMapper: { 6 | '^@root/(.*)': '/$1', 7 | '^@public/(.*)': '/public/$1', 8 | '^@popup/(.*)': '/src/popup/$1', 9 | }, 10 | collectCoverage: true, 11 | verbose: true, 12 | }; 13 | -------------------------------------------------------------------------------- /jest.setup.mjs: -------------------------------------------------------------------------------- 1 | // Mocking crypto with Node webcrypto API. 2 | import { webcrypto } from 'crypto'; 3 | 4 | if (typeof crypto === 'undefined') { 5 | global.crypto = webcrypto; 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "privacy-pass", 3 | "version": "3.0.6", 4 | "contributors": [ 5 | "Suphanat Chunhapanya ", 6 | "Armando Faz " 7 | ], 8 | "main": "index.js", 9 | "license": "BSD-3-Clause", 10 | "type": "module", 11 | "engines": { 12 | "node": ">=16" 13 | }, 14 | "scripts": { 15 | "sjcl": "cd node_modules/sjcl && perl configure --without-all --with-ecc --with-convenience --with-codecBytes --with-codecHex --compress=none && make sjcl.js", 16 | "prebuild": "npm run sjcl", 17 | "build": "webpack", 18 | "pretest": "npm run sjcl", 19 | "test": "tsc -b && node --experimental-vm-modules node_modules/jest/bin/jest.js --ci", 20 | "lint": "eslint .", 21 | "clean": "rimraf dist" 22 | }, 23 | "dependencies": { 24 | "asn1-parser": "1.1.8", 25 | "buffer": "6.0.3", 26 | "keccak": "3.0.2", 27 | "qs": "6.10.3", 28 | "react": "17.0.2", 29 | "react-dom": "17.0.2", 30 | "react-redux": "7.2.5", 31 | "redux": "4.1.1", 32 | "sjcl": "1.0.8", 33 | "stream-browserify": "3.0.0" 34 | }, 35 | "devDependencies": { 36 | "@types/chrome": "0.0.159", 37 | "@types/jest": "27.0.2", 38 | "@types/qs": "6.9.6", 39 | "@types/react": "17.0.5", 40 | "@types/react-dom": "17.0.5", 41 | "@typescript-eslint/eslint-plugin": "5.1.0", 42 | "@typescript-eslint/parser": "5.1.0", 43 | "copy-webpack-plugin": "8.1.1", 44 | "css-loader": "5.2.4", 45 | "eslint": "7.32.0", 46 | "eslint-config-prettier": "8.3.0", 47 | "eslint-plugin-prettier": "4.0.0", 48 | "eslint-plugin-security": "1.4.0", 49 | "file-loader": "6.2.0", 50 | "html-webpack-plugin": "5.3.1", 51 | "jest": "28.1.0", 52 | "mini-css-extract-plugin": "1.6.0", 53 | "prettier": "2.3.2", 54 | "rimraf": "3.0.2", 55 | "sass": "1.32.13", 56 | "sass-loader": "11.1.1", 57 | "ts-loader": "9.2.8", 58 | "tsconfig-paths-webpack-plugin": "3.5.1", 59 | "typescript": "4.6.3", 60 | "webpack-cli": "4.9.2" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /public/icons/128/gold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privacypass/challenge-bypass-extension/aa287f1d2c4ddd8fa1ffa993b13b3c07a13f28bf/public/icons/128/gold.png -------------------------------------------------------------------------------- /public/icons/128/grey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privacypass/challenge-bypass-extension/aa287f1d2c4ddd8fa1ffa993b13b3c07a13f28bf/public/icons/128/grey.png -------------------------------------------------------------------------------- /public/icons/32/gold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privacypass/challenge-bypass-extension/aa287f1d2c4ddd8fa1ffa993b13b3c07a13f28bf/public/icons/32/gold.png -------------------------------------------------------------------------------- /public/icons/32/grey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privacypass/challenge-bypass-extension/aa287f1d2c4ddd8fa1ffa993b13b3c07a13f28bf/public/icons/32/grey.png -------------------------------------------------------------------------------- /public/icons/48/gold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privacypass/challenge-bypass-extension/aa287f1d2c4ddd8fa1ffa993b13b3c07a13f28bf/public/icons/48/gold.png -------------------------------------------------------------------------------- /public/icons/48/grey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privacypass/challenge-bypass-extension/aa287f1d2c4ddd8fa1ffa993b13b3c07a13f28bf/public/icons/48/grey.png -------------------------------------------------------------------------------- /public/icons/64/gold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privacypass/challenge-bypass-extension/aa287f1d2c4ddd8fa1ffa993b13b3c07a13f28bf/public/icons/64/gold.png -------------------------------------------------------------------------------- /public/icons/64/grey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privacypass/challenge-bypass-extension/aa287f1d2c4ddd8fa1ffa993b13b3c07a13f28bf/public/icons/64/grey.png -------------------------------------------------------------------------------- /public/icons/browser/chrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privacypass/challenge-bypass-extension/aa287f1d2c4ddd8fa1ffa993b13b3c07a13f28bf/public/icons/browser/chrome.png -------------------------------------------------------------------------------- /public/icons/browser/firefox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privacypass/challenge-bypass-extension/aa287f1d2c4ddd8fa1ffa993b13b3c07a13f28bf/public/icons/browser/firefox.png -------------------------------------------------------------------------------- /public/images/gold-badge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 28 | 29 | 30 | 32 | 34 | 35 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /public/images/grey-badge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 23 | 24 | 26 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Privacy Pass", 3 | "manifest_version": 2, 4 | "description": "Client support for Privacy Pass anonymous authorization protocol.", 5 | "version": "3.0.6", 6 | "icons": { 7 | "32": "icons/32/gold.png", 8 | "48": "icons/48/gold.png", 9 | "64": "icons/64/gold.png", 10 | "128": "icons/128/gold.png" 11 | }, 12 | "background": { 13 | "scripts": [ 14 | "background.js" 15 | ] 16 | }, 17 | "permissions": [ 18 | "", 19 | "cookies", 20 | "webRequest", 21 | "webRequestBlocking" 22 | ], 23 | "browser_action": { 24 | "default_icon": "icons/32/grey.png", 25 | "default_title": "Privacy Pass", 26 | "default_popup": "popup.html" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /public/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /src/background/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | handleActivated, 3 | handleCreated, 4 | handleRemoved, 5 | handleReplaced, 6 | } from './listeners/tabListener'; 7 | import { 8 | handleBeforeRequest, 9 | handleBeforeSendHeaders, 10 | handleHeadersReceived, 11 | } from './listeners/webRequestListener'; 12 | 13 | import { Tab } from './tab'; 14 | 15 | /* Listeners for navigator */ 16 | 17 | declare global { 18 | interface Window { 19 | ACTIVE_TAB_ID: number; 20 | TABS: Map; 21 | } 22 | } 23 | 24 | declare let browser: unknown; 25 | 26 | window.ACTIVE_TAB_ID = chrome.tabs.TAB_ID_NONE; 27 | window.TABS = new Map(); 28 | 29 | const BROWSERS = { 30 | CHROME: 'Chrome', 31 | FIREFOX: 'Firefox', 32 | EDGE: 'Edge', 33 | } as const; 34 | type BROWSERS = typeof BROWSERS[keyof typeof BROWSERS]; 35 | 36 | function getBrowser(): BROWSERS { 37 | if (typeof chrome !== 'undefined') { 38 | if (typeof browser !== 'undefined') { 39 | return BROWSERS.FIREFOX; 40 | } 41 | return BROWSERS.CHROME; 42 | } 43 | return BROWSERS.EDGE; 44 | } 45 | 46 | /* Listeners for navigator */ 47 | 48 | chrome.tabs.onActivated.addListener(handleActivated); 49 | 50 | chrome.tabs.onCreated.addListener(handleCreated); 51 | 52 | chrome.tabs.onReplaced.addListener(handleReplaced); 53 | 54 | chrome.tabs.onRemoved.addListener(handleRemoved); 55 | 56 | // Loads all the existings tabs. 57 | chrome.tabs.query({}, function (existingTabs: chrome.tabs.Tab[]) { 58 | existingTabs.forEach((tab) => { 59 | if (tab.id === undefined) { 60 | throw new Error('tab undefined'); 61 | } 62 | window.TABS.set(tab.id, new Tab(tab.id)); 63 | }); 64 | }); 65 | 66 | // Finds which tab is currently active. 67 | chrome.tabs.query({ active: true, currentWindow: true }, function (tabs: chrome.tabs.Tab[]) { 68 | const [tab] = tabs; 69 | if (tab !== undefined && tab.id !== undefined) { 70 | window.ACTIVE_TAB_ID = tab.id; 71 | } 72 | }); 73 | 74 | chrome.webRequest.onBeforeRequest.addListener(handleBeforeRequest, { urls: [''] }, [ 75 | 'requestBody', 76 | 'blocking', 77 | ]); 78 | 79 | const extraInfos = ['requestHeaders', 'blocking']; 80 | if (getBrowser() === BROWSERS.CHROME) { 81 | extraInfos.push('extraHeaders'); 82 | } 83 | chrome.webRequest.onBeforeSendHeaders.addListener( 84 | handleBeforeSendHeaders, 85 | { urls: [''] }, 86 | extraInfos, 87 | ); 88 | 89 | chrome.webRequest.onHeadersReceived.addListener(handleHeadersReceived, { urls: [''] }, [ 90 | 'responseHeaders', 91 | 'blocking', 92 | ]); 93 | 94 | // TODO Using Message passing is dirty. It's better to use chrome.storage for sharing 95 | // common data between the popup and the background script. 96 | chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { 97 | if (request.clear === true) { 98 | window.localStorage.clear(); 99 | 100 | // Update the browser action icon after clearing the tokens. 101 | const activeTab = window.TABS.get(window.ACTIVE_TAB_ID); 102 | if (activeTab !== undefined) { 103 | activeTab.forceUpdateIcon(); 104 | } 105 | return; 106 | } 107 | 108 | if (request.key !== undefined && typeof request.key === 'string') { 109 | sendResponse(window.localStorage.getItem(request.key)); 110 | } 111 | }); 112 | 113 | // TODO It's better to move this to the provider class. Let's figure out how to do it later. 114 | // Removes cookies for captcha.website to enable getting more tokens in the future. 115 | chrome.cookies.onChanged.addListener((changeInfo) => { 116 | const cloudflareDomains = ['captcha.website', 'issuance.privacypass.cloudflare.com']; 117 | const domain = changeInfo.cookie.domain.replace(/^\./, ''); 118 | if ( 119 | !changeInfo.removed && 120 | cloudflareDomains.includes(domain) && 121 | changeInfo.cookie.name === 'cf_clearance' 122 | ) { 123 | chrome.cookies.remove({ url: `https://${domain}`, name: 'cf_clearance' }); 124 | } 125 | }); 126 | -------------------------------------------------------------------------------- /src/background/listeners/tabListener.ts: -------------------------------------------------------------------------------- 1 | import { Tab } from '../tab'; 2 | 3 | export function handleActivated(activeInfo: { tabId: number; windowId: number }) { 4 | const activeTab = window.TABS.get(window.ACTIVE_TAB_ID); 5 | if (activeTab !== undefined) { 6 | activeTab.handleDeactivated(); 7 | } 8 | 9 | window.ACTIVE_TAB_ID = activeInfo.tabId; 10 | window.TABS.get(activeInfo.tabId)?.handleActivated(); 11 | } 12 | 13 | export function handleCreated(tab: chrome.tabs.Tab): void { 14 | if (tab.id !== undefined && tab.id !== chrome.tabs.TAB_ID_NONE) { 15 | window.TABS.set(tab.id, new Tab(tab.id)); 16 | } 17 | } 18 | 19 | export function handleRemoved(tabId: number, _removeInfo: chrome.tabs.TabRemoveInfo): void { 20 | window.TABS.delete(tabId); 21 | } 22 | 23 | export function handleReplaced(addedTabId: number, removedTabId: number): void { 24 | window.TABS.delete(removedTabId); 25 | window.TABS.set(addedTabId, new Tab(addedTabId)); 26 | } 27 | -------------------------------------------------------------------------------- /src/background/listeners/webRequestListener.ts: -------------------------------------------------------------------------------- 1 | export function handleBeforeRequest( 2 | details: chrome.webRequest.WebRequestBodyDetails, 3 | ): chrome.webRequest.BlockingResponse | void { 4 | if (details.tabId === chrome.tabs.TAB_ID_NONE) { 5 | // The request does not correspond to any tab. 6 | return; 7 | } 8 | 9 | const tab = window.TABS.get(details.tabId); 10 | // The tab can be removed already if the request comes after the tab is closed. 11 | return tab?.handleBeforeRequest(details); 12 | } 13 | 14 | export function handleBeforeSendHeaders( 15 | details: chrome.webRequest.WebRequestHeadersDetails, 16 | ): chrome.webRequest.BlockingResponse | void { 17 | if (details.tabId === chrome.tabs.TAB_ID_NONE) { 18 | // The request does not correspond to any tab. 19 | return; 20 | } 21 | 22 | const tab = window.TABS.get(details.tabId); 23 | // The tab can be removed already if the request comes after the tab is closed. 24 | return tab?.handleBeforeSendHeaders(details); 25 | } 26 | 27 | export function handleHeadersReceived( 28 | details: chrome.webRequest.WebResponseHeadersDetails, 29 | ): chrome.webRequest.BlockingResponse | void { 30 | if (details.tabId === chrome.tabs.TAB_ID_NONE) { 31 | // The request does not correspond to any tab. 32 | return; 33 | } 34 | 35 | const tab = window.TABS.get(details.tabId); 36 | // The tab can be removed already if the response comes after the tab is closed. 37 | return tab?.handleHeadersReceived(details); 38 | } 39 | -------------------------------------------------------------------------------- /src/background/providers/cloudflare.test.ts: -------------------------------------------------------------------------------- 1 | import { CloudflareProvider } from './cloudflare'; 2 | import Token from '../token'; 3 | import { jest } from '@jest/globals'; 4 | 5 | export class StorageMock { 6 | store: Map; 7 | 8 | constructor() { 9 | this.store = new Map(); 10 | } 11 | 12 | getItem(key: string): string | null { 13 | return this.store.get(key) ?? null; 14 | } 15 | 16 | setItem(key: string, value: string): void { 17 | this.store.set(key, value); 18 | } 19 | } 20 | 21 | test('getStoredTokens', () => { 22 | const storage = new StorageMock(); 23 | const updateIcon = jest.fn(); 24 | const navigateUrl = jest.fn(); 25 | 26 | const provider = new CloudflareProvider(storage, { updateIcon, navigateUrl }); 27 | const tokens = [new Token(), new Token()]; 28 | provider['setStoredTokens'](tokens); 29 | const storedTokens = provider['getStoredTokens'](); 30 | expect(storedTokens.map((token) => token.toString())).toEqual( 31 | tokens.map((token) => token.toString()), 32 | ); 33 | }); 34 | 35 | test('setStoredTokens', () => { 36 | const storage = new StorageMock(); 37 | const updateIcon = jest.fn(); 38 | const navigateUrl = jest.fn(); 39 | 40 | const provider = new CloudflareProvider(storage, { updateIcon, navigateUrl }); 41 | const tokens = [new Token(), new Token()]; 42 | provider['setStoredTokens'](tokens); 43 | const tok = storage.store.get('tokens'); 44 | expect(tok).toBeDefined(); 45 | if (tok !== undefined) { 46 | const storedTokens = JSON.parse(tok); 47 | expect(storedTokens).toEqual(tokens.map((token) => token.toString())); 48 | } 49 | }); 50 | 51 | test('getBadgeText', () => { 52 | const storage = new StorageMock(); 53 | const updateIcon = jest.fn(); 54 | const navigateUrl = jest.fn(); 55 | 56 | const provider = new CloudflareProvider(storage, { updateIcon, navigateUrl }); 57 | const tokens = [new Token(), new Token()]; 58 | provider['setStoredTokens'](tokens); 59 | const text = provider['getBadgeText'](); 60 | expect(text).toBe('2'); 61 | }); 62 | 63 | /* 64 | * The issuance involves handleBeforeRequest and handleBeforeSendHeaders 65 | * listeners. In handleBeforeRequest listener, 66 | * 1. Firstly, the listener check if the request looks like the one that we 67 | * should send an issuance request. 68 | * 2. If it passes the check, The listener sets "issueInfo" property which 69 | * includes the request id and the form data of the request. The property 70 | * will be used by handleBeforeSendHeaders again to issue the tokens. If not, 71 | * it returns nothing and let the request continue. 72 | * 73 | * In handleBeforeSendHeaders, 74 | * 1. The listener will check if the provided request id matches the 75 | * request id in "issueInfo". If so, it means that we are issuing the tokens. 76 | * If not, it returns nothing and let the request continue. 77 | * 2. If it passes the check, the listener extract the form data from 78 | * "issueInfo" clears the "issueInfo" property because "issueInfo" is used 79 | * already. If not, it returns nothing and let the request continue. 80 | * 3. The listener tries to look for the Referer header to get 81 | * the (not PP) token from __cf_chl_tk query param in the Referer url. 82 | * 4. The listener returns the cancel command to cancel the request. 83 | * 5. At the same time the listener returns, it calls a private method 84 | * "issue" to send an issuance request to the server and the method return 85 | * an array of issued tokens. In the issuance request, the body will be the 86 | * form data extracted from "issueInfo" earlier and also include the 87 | * __cf_chl_f_tk query param with the token it got from Step 3 (if any). 88 | * 6. The listener stored the issued tokens in the storage. 89 | * 7. The listener reloads the tab to get the proper web page for the tab. 90 | */ 91 | describe('issuance', () => { 92 | describe('handleBeforeRequest', () => { 93 | test('valid request', async () => { 94 | const storage = new StorageMock(); 95 | const updateIcon = jest.fn(); 96 | const navigateUrl = jest.fn(); 97 | 98 | const provider = new CloudflareProvider(storage, { updateIcon, navigateUrl }); 99 | const issue = jest.fn(async () => []); 100 | provider['issue'] = issue; 101 | const url = 'https://captcha.website'; 102 | const details = { 103 | method: 'POST', 104 | url, 105 | requestId: 'xxx', 106 | frameId: 1, 107 | parentFrameId: 1, 108 | tabId: 1, 109 | type: 'xmlhttprequest' as chrome.webRequest.ResourceType, 110 | timeStamp: 1, 111 | requestBody: { 112 | formData: { 113 | ['md']: ['body-param'], 114 | }, 115 | }, 116 | }; 117 | const result = await provider.handleBeforeRequest(details); 118 | expect(result).toBeUndefined(); 119 | expect(issue).not.toHaveBeenCalled(); 120 | expect(navigateUrl).not.toHaveBeenCalled(); 121 | 122 | const issueInfo = provider['issueInfo']; 123 | expect(issueInfo).not.toBeNull(); 124 | if (issueInfo !== null) { 125 | expect(issueInfo.requestId).toEqual(details.requestId); 126 | expect(issueInfo.formData).toStrictEqual({ 127 | ['md']: 'body-param', 128 | }); 129 | } 130 | }); 131 | 132 | /* 133 | * The request is invalid if the body has no 'md' parameter. 134 | */ 135 | test('invalid request', async () => { 136 | const storage = new StorageMock(); 137 | const updateIcon = jest.fn(); 138 | const navigateUrl = jest.fn(); 139 | 140 | const provider = new CloudflareProvider(storage, { updateIcon, navigateUrl }); 141 | const issue = jest.fn(async () => []); 142 | provider['issue'] = issue; 143 | const details = { 144 | method: 'GET', 145 | url: 'https://cloudflare.com/', 146 | requestId: 'xxx', 147 | frameId: 1, 148 | parentFrameId: 1, 149 | tabId: 1, 150 | type: 'xmlhttprequest' as chrome.webRequest.ResourceType, 151 | timeStamp: 1, 152 | requestBody: { 153 | formData: { 154 | /* remove 'md' parameter. */ 155 | }, 156 | }, 157 | }; 158 | const result = await provider.handleBeforeRequest(details); 159 | expect(result).toBeUndefined(); 160 | expect(issue).not.toHaveBeenCalled(); 161 | expect(navigateUrl).not.toHaveBeenCalled(); 162 | const issueInfo = provider['issueInfo']; 163 | expect(issueInfo).toBeNull(); 164 | }); 165 | }); 166 | 167 | describe('handleBeforeSendHeaders', () => { 168 | test('with issueInfo with Referer header', async () => { 169 | const storage = new StorageMock(); 170 | const updateIcon = jest.fn(); 171 | const navigateUrl = jest.fn(); 172 | 173 | const provider = new CloudflareProvider(storage, { updateIcon, navigateUrl }); 174 | const tokens = [new Token(), new Token(), new Token()]; 175 | const issue = jest.fn(async () => { 176 | return tokens; 177 | }); 178 | provider['issue'] = issue; 179 | const issueInfo = { 180 | requestId: 'xxx', 181 | formData: { 182 | ['md']: 'body-param', 183 | }, 184 | }; 185 | provider['issueInfo'] = issueInfo; 186 | const details = { 187 | method: 'POST', 188 | url: 'https://captcha.website', 189 | requestId: 'xxx', 190 | frameId: 1, 191 | parentFrameId: 1, 192 | tabId: 1, 193 | type: 'xmlhttprequest' as chrome.webRequest.ResourceType, 194 | timeStamp: 1, 195 | requestHeaders: [ 196 | { 197 | name: 'Referer', 198 | value: 'https://captcha.website/?__cf_chl_tk=token', 199 | }, 200 | ], 201 | }; 202 | const result = await provider.handleBeforeSendHeaders(details); 203 | expect(result).toStrictEqual({ cancel: true }); 204 | const newIssueInfo = provider['issueInfo']; 205 | expect(newIssueInfo).toBeNull(); 206 | 207 | expect(issue.mock.calls.length).toBe(1); 208 | expect(issue).toHaveBeenCalledWith('https://captcha.website/?__cf_chl_f_tk=token', { 209 | ['md']: 'body-param', 210 | }); 211 | 212 | expect(navigateUrl.mock.calls.length).toBe(1); 213 | expect(navigateUrl).toHaveBeenCalledWith('https://captcha.website/'); 214 | 215 | // Expect the tokens are added. 216 | const storedTokens = provider['getStoredTokens'](); 217 | expect(storedTokens.map((token) => token.toString())).toEqual( 218 | tokens.map((token) => token.toString()), 219 | ); 220 | }); 221 | 222 | test('with issueInfo without Referer header', async () => { 223 | const storage = new StorageMock(); 224 | const updateIcon = jest.fn(); 225 | const navigateUrl = jest.fn(); 226 | 227 | const provider = new CloudflareProvider(storage, { updateIcon, navigateUrl }); 228 | const tokens = [new Token(), new Token(), new Token()]; 229 | const issue = jest.fn(async () => { 230 | return tokens; 231 | }); 232 | provider['issue'] = issue; 233 | const issueInfo = { 234 | requestId: 'xxx', 235 | formData: { 236 | ['md']: 'body-param', 237 | }, 238 | }; 239 | provider['issueInfo'] = issueInfo; 240 | const details = { 241 | method: 'POST', 242 | url: 'https://captcha.website/?__cf_chl_f_tk=token', 243 | requestId: 'xxx', 244 | frameId: 1, 245 | parentFrameId: 1, 246 | tabId: 1, 247 | type: 'xmlhttprequest' as chrome.webRequest.ResourceType, 248 | timeStamp: 1, 249 | requestHeaders: [], 250 | }; 251 | const result = await provider.handleBeforeSendHeaders(details); 252 | expect(result).toStrictEqual({ cancel: true }); 253 | const newIssueInfo = provider['issueInfo']; 254 | expect(newIssueInfo).toBeNull(); 255 | 256 | expect(issue.mock.calls.length).toBe(1); 257 | expect(issue).toHaveBeenCalledWith('https://captcha.website/?__cf_chl_f_tk=token', { 258 | ['md']: 'body-param', 259 | }); 260 | 261 | expect(navigateUrl.mock.calls.length).toBe(1); 262 | expect(navigateUrl).toHaveBeenCalledWith('https://captcha.website/'); 263 | 264 | // Expect the tokens are added. 265 | const storedTokens = provider['getStoredTokens'](); 266 | expect(storedTokens.map((token) => token.toString())).toEqual( 267 | tokens.map((token) => token.toString()), 268 | ); 269 | }); 270 | 271 | test('without issueInfo', async () => { 272 | const storage = new StorageMock(); 273 | const updateIcon = jest.fn(); 274 | const navigateUrl = jest.fn(); 275 | 276 | const provider = new CloudflareProvider(storage, { updateIcon, navigateUrl }); 277 | const issue = jest.fn(async () => []); 278 | provider['issue'] = issue; 279 | const details = { 280 | method: 'POST', 281 | url: 'https://captcha.website', 282 | requestId: 'xxx', 283 | frameId: 1, 284 | parentFrameId: 1, 285 | tabId: 1, 286 | type: 'xmlhttprequest' as chrome.webRequest.ResourceType, 287 | timeStamp: 1, 288 | requestHeaders: [ 289 | { 290 | name: 'Referer', 291 | value: 'https://captcha.website/?__cf_chl_tk=token', 292 | }, 293 | ], 294 | }; 295 | const result = await provider.handleBeforeSendHeaders(details); 296 | expect(result).toBeUndefined(); 297 | expect(issue).not.toHaveBeenCalled(); 298 | expect(navigateUrl).not.toHaveBeenCalled(); 299 | }); 300 | }); 301 | }); 302 | 303 | /* 304 | * The redemption involves handleHeadersReceived and handleBeforeSendHeaders 305 | * listeners. In handleHeadersReceived listener, 306 | * 1. Firstly, the listener check if the response is the challenge page and 307 | * it supports Privacy Pass redemption. 308 | * 2. If it passes the check, the listener gets a token from the storage to 309 | * redeem. 310 | * 3. The listener sets "redeemInfo" property which includes the request id 311 | * and the mentioned token. The property will be used by 312 | * handleBeforeSendHeaders to redeem the token. 313 | * 4. The listener returns the redirect command so that the browser will 314 | * send the same request again with the token attached. 315 | * 316 | * In handleBeforeSendHeaders, 317 | * 1. The listener will check if the provided request id matches the 318 | * request id in "redeemInfo". If so, it means that the request is from the 319 | * redirect command returned by handleHeadersReceived. If not, it returns 320 | * nothing and let the request continue. 321 | * 2. If it passes the check, the listener attaches the token from 322 | * "redeemInfo" in the "challenge-bypass-token" HTTP header and clears the 323 | * "redeemInfo" property because "redeemInfo" is used already. 324 | */ 325 | describe('redemption', () => { 326 | describe('handleHeadersReceived', () => { 327 | const validDetails = { 328 | url: 'https://cloudflare.com/', 329 | requestId: 'xxx', 330 | frameId: 1, 331 | parentFrameId: 1, 332 | tabId: 1, 333 | type: 'main_frame' as chrome.webRequest.ResourceType, 334 | timeStamp: 1, 335 | 336 | statusLine: 'HTTP/1.1 403 Forbidden', 337 | statusCode: 403, 338 | responseHeaders: [ 339 | { 340 | name: 'cf-chl-bypass', 341 | value: '1', 342 | }, 343 | ], 344 | method: 'GET', 345 | }; 346 | 347 | test('valid response with tokens', () => { 348 | const storage = new StorageMock(); 349 | const updateIcon = jest.fn(); 350 | const navigateUrl = jest.fn(); 351 | 352 | const provider = new CloudflareProvider(storage, { updateIcon, navigateUrl }); 353 | const tokens = [new Token(), new Token(), new Token()]; 354 | provider['setStoredTokens'](tokens); 355 | const details = validDetails; 356 | const result = provider.handleHeadersReceived(details); 357 | expect(result).toEqual({ redirectUrl: details.url }); 358 | // Expect redeemInfo to be set. 359 | const redeemInfo = provider['redeemInfo']; 360 | expect(redeemInfo).not.toBeNull(); 361 | if (redeemInfo !== null) { 362 | expect(redeemInfo.requestId).toEqual(details.requestId); 363 | expect(redeemInfo.token.toString()).toEqual(tokens[0].toString()); 364 | } 365 | // Expect a token is used. 366 | const storedTokens = provider['getStoredTokens'](); 367 | expect(storedTokens.map((token) => token.toString())).toEqual( 368 | tokens.slice(1).map((token) => token.toString()), 369 | ); 370 | }); 371 | 372 | test('valid response without tokens', () => { 373 | const storage = new StorageMock(); 374 | const updateIcon = jest.fn(); 375 | const navigateUrl = jest.fn(); 376 | 377 | const provider = new CloudflareProvider(storage, { updateIcon, navigateUrl }); 378 | provider['setStoredTokens']([]); 379 | const details = validDetails; 380 | const result = provider.handleHeadersReceived(details); 381 | expect(result).toBeUndefined(); 382 | }); 383 | 384 | test('captcha.website response', () => { 385 | const storage = new StorageMock(); 386 | const updateIcon = jest.fn(); 387 | const navigateUrl = jest.fn(); 388 | 389 | const provider = new CloudflareProvider(storage, { updateIcon, navigateUrl }); 390 | const tokens = [new Token(), new Token(), new Token()]; 391 | provider['setStoredTokens'](tokens); 392 | const details = validDetails; 393 | details.url = 'https://captcha.website/'; 394 | const result = provider.handleHeadersReceived(details); 395 | expect(result).toBeUndefined(); 396 | }); 397 | 398 | test('issuance.privacypass.cloudflare.com response', () => { 399 | const storage = new StorageMock(); 400 | const updateIcon = jest.fn(); 401 | const navigateUrl = jest.fn(); 402 | 403 | const provider = new CloudflareProvider(storage, { updateIcon, navigateUrl }); 404 | const tokens = [new Token(), new Token(), new Token()]; 405 | provider['setStoredTokens'](tokens); 406 | const details = validDetails; 407 | details.url = 'https://issuance.privacypass.cloudflare.com/'; 408 | const result = provider.handleHeadersReceived(details); 409 | expect(result).toBeUndefined(); 410 | }); 411 | 412 | /* 413 | * The response is invalid if any of the followings is true: 414 | * 1. The status code is not 403. 415 | * 2. There is no HTTP header of "cf-chl-bypass: 1" 416 | */ 417 | test('invalid response', () => { 418 | const storage = new StorageMock(); 419 | const updateIcon = jest.fn(); 420 | const navigateUrl = jest.fn(); 421 | 422 | const provider = new CloudflareProvider(storage, { updateIcon, navigateUrl }); 423 | const tokens = [new Token(), new Token(), new Token()]; 424 | provider['setStoredTokens'](tokens); 425 | const details = { 426 | url: 'https://cloudflare.com/', 427 | requestId: 'xxx', 428 | frameId: 1, 429 | parentFrameId: 1, 430 | tabId: 1, 431 | type: 'main_frame' as chrome.webRequest.ResourceType, 432 | timeStamp: 1, 433 | 434 | statusLine: 'HTTP/1.1 403 Forbidden', 435 | statusCode: 403, 436 | method: 'GET', 437 | }; 438 | const result = provider.handleHeadersReceived(details); 439 | expect(result).toBeUndefined(); 440 | }); 441 | }); 442 | 443 | describe('handleBeforeSendHeaders', () => { 444 | test('with redeemInfo', () => { 445 | const storage = new StorageMock(); 446 | const updateIcon = jest.fn(); 447 | const navigateUrl = jest.fn(); 448 | 449 | const provider = new CloudflareProvider(storage, { updateIcon, navigateUrl }); 450 | 451 | const token = Token.fromString( 452 | '{"input":[238,205,51,250,226,251,144,68,170,68,235,25,231,152,125,63,215,10,42,37,65,157,56,22,98,23,129,9,157,179,223,64],"factor":"0x359953995df006ba98bdcf1383a4c75ca79ae41d4e718dcb051832ce65c002bc","blindedPoint":"BCrzbuVf2eSD/5NtR+o09ovo+oRWAwjwopzl7lb+IuOPuj/ctLkdlkeJQUeyjtUbfgJqU4BFNBRz9ln4z3Dk7Us=","unblindedPoint":"BLKf1op+oq4FcbNdP5vygTkGO3WWLHD6oXCCZDfaFyuFlruih49BStHm6QxtZZAqgCR9i6SsO6VP69hHnfBDNeg=","signed":{"blindedPoint":"BKEnbsQSwnHCxEv4ppp6XuqLV60FiQpF8YWvodQHdnmFHv7CKyWHqBLBW8fJ2uuV+uLxl99+VRYPxr8Q8E7i2Iw=","unblindedPoint":"BA8G3dHM554FzDiOtEsSBu0XYW8p5vA2OIEvnYQcJlRGHTiq2N6j3BKUbiI7I6fAy2vsOrwhrLGHOD+q7YxO+UM="}}', 453 | ); 454 | const redeemInfo = { 455 | requestId: 'xxx', 456 | token, 457 | }; 458 | provider['redeemInfo'] = redeemInfo; 459 | const details = { 460 | method: 'GET', 461 | url: 'https://cloudflare.com/', 462 | requestId: 'xxx', 463 | frameId: 1, 464 | parentFrameId: 1, 465 | tabId: 1, 466 | type: 'main_frame' as chrome.webRequest.ResourceType, 467 | timeStamp: 1, 468 | requestHeaders: [], 469 | }; 470 | const result = provider.handleBeforeSendHeaders(details); 471 | expect(result).toEqual({ 472 | requestHeaders: [ 473 | { 474 | name: 'challenge-bypass-token', 475 | value: 'eyJ0eXBlIjoiUmVkZWVtIiwiY29udGVudHMiOlsiN3Mweit1TDdrRVNxUk9zWjU1aDlQOWNLS2lWQm5UZ1dZaGVCQ1oyejMwQT0iLCJyeXRSRExLN3J2THVhd09XZkJ0RXJTclVuUWpIaGpLbkNKK3RqQnhQSFYwPSIsImV5SmpkWEoyWlNJNkluQXlOVFlpTENKb1lYTm9Jam9pYzJoaE1qVTJJaXdpYldWMGFHOWtJam9pYVc1amNtVnRaVzUwSW4wPSJdfQ==', 476 | }, 477 | ], 478 | }); 479 | const newRedeemInfo = provider['redeemInfo']; 480 | expect(newRedeemInfo).toBeNull(); 481 | 482 | expect(updateIcon.mock.calls.length).toBe(1); 483 | expect(updateIcon).toHaveBeenCalledWith('0'); 484 | }); 485 | 486 | test('without redeemInfo', () => { 487 | const storage = new StorageMock(); 488 | const updateIcon = jest.fn(); 489 | const navigateUrl = jest.fn(); 490 | 491 | const provider = new CloudflareProvider(storage, { updateIcon, navigateUrl }); 492 | 493 | const details = { 494 | method: 'GET', 495 | url: 'https://cloudflare.com/', 496 | requestId: 'xxx', 497 | frameId: 1, 498 | parentFrameId: 1, 499 | tabId: 1, 500 | type: 'main_frame' as chrome.webRequest.ResourceType, 501 | timeStamp: 1, 502 | requestHeaders: [], 503 | }; 504 | const result = provider.handleBeforeSendHeaders(details); 505 | expect(result).toBeUndefined(); 506 | expect(updateIcon).not.toHaveBeenCalled(); 507 | }); 508 | }); 509 | }); 510 | -------------------------------------------------------------------------------- /src/background/providers/cloudflare.ts: -------------------------------------------------------------------------------- 1 | import * as voprf from '../voprf'; 2 | 3 | import { Callbacks, Provider } from '.'; 4 | 5 | import { Storage } from '../storage'; 6 | import Token from '../token'; 7 | import qs from 'qs'; 8 | 9 | const ISSUE_HEADER_NAME = 'cf-chl-bypass'; 10 | const NUMBER_OF_REQUESTED_TOKENS = 30; 11 | const ISSUANCE_BODY_PARAM_NAME = 'blinded-tokens'; 12 | 13 | const COMMITMENT_URL = 14 | 'https://raw.githubusercontent.com/privacypass/ec-commitments/master/commitments-p256.json'; 15 | 16 | const QUALIFIED_BODY_PARAMS = ['md']; 17 | 18 | const CHL_BYPASS_SUPPORT = 'cf-chl-bypass'; 19 | const ISSUING_HOSTNAMES = ['captcha.website', 'issuance.privacypass.cloudflare.com']; 20 | 21 | const REFERER_QUERY_PARAM = '__cf_chl_tk'; 22 | const QUERY_PARAM = '__cf_chl_f_tk'; 23 | 24 | const VERIFICATION_KEY = `-----BEGIN PUBLIC KEY----- 25 | MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExf0AftemLr0YSz5odoj3eJv6SkOF 26 | VcH7NNb2xwdEz6Pxm44tvovEl/E+si8hdIDVg1Ys+cbaWwP0jYJW3ygv+Q== 27 | -----END PUBLIC KEY-----`; 28 | 29 | const TOKEN_STORE_KEY = 'tokens'; 30 | 31 | interface RedeemInfo { 32 | requestId: string; 33 | token: Token; 34 | } 35 | 36 | interface IssueInfo { 37 | requestId: string; 38 | formData: { [key: string]: string[] | string }; 39 | } 40 | 41 | export class CloudflareProvider implements Provider { 42 | static readonly ID: number = 1; 43 | private callbacks: Callbacks; 44 | private storage: Storage; 45 | 46 | private issueInfo: IssueInfo | null; 47 | private redeemInfo: RedeemInfo | null; 48 | 49 | constructor(storage: Storage, callbacks: Callbacks) { 50 | // TODO This changes the global state in the crypto module, which can be a side effect outside of this object. 51 | // It's better if we can refactor the crypto module to be in object-oriented concept. 52 | voprf.initECSettings(voprf.defaultECSettings); 53 | 54 | this.callbacks = callbacks; 55 | this.storage = storage; 56 | this.issueInfo = null; 57 | this.redeemInfo = null; 58 | } 59 | 60 | private getStoredTokens(): Token[] { 61 | const stored = this.storage.getItem(TOKEN_STORE_KEY); 62 | if (stored === null) { 63 | return []; 64 | } 65 | 66 | const tokens: string[] = JSON.parse(stored); 67 | return tokens.map((token) => Token.fromString(token)); 68 | } 69 | 70 | private setStoredTokens(tokens: Token[]) { 71 | this.storage.setItem( 72 | TOKEN_STORE_KEY, 73 | JSON.stringify(tokens.map((token) => token.toString())), 74 | ); 75 | } 76 | 77 | getID(): number { 78 | return CloudflareProvider.ID; 79 | } 80 | 81 | private async getCommitment(version: string): Promise<{ G: string; H: string }> { 82 | const keyPrefix = 'commitment-'; 83 | const cached = this.storage.getItem(`${keyPrefix}${version}`); 84 | if (cached !== null) { 85 | return JSON.parse(cached); 86 | } 87 | 88 | interface Response { 89 | CF: { [version: string]: { H: string; expiry: string; sig: string } }; 90 | } 91 | 92 | // Download the commitment 93 | const data: Response = await fetch(COMMITMENT_URL).then((r) => r.json()); 94 | const commitment = data.CF[version as string]; 95 | if (commitment === undefined) { 96 | throw new Error(`No commitment for the version ${version} is found`); 97 | } 98 | 99 | // Check the expiry date. 100 | const expiry = new Date(commitment.expiry); 101 | if (Date.now() >= +expiry) { 102 | throw new Error(`Commitments expired in ${expiry.toString()}`); 103 | } 104 | 105 | // This will throw an error on a bad signature. 106 | voprf.verifyConfiguration( 107 | VERIFICATION_KEY, 108 | { 109 | H: commitment.H, 110 | expiry: commitment.expiry, 111 | }, 112 | commitment.sig, 113 | ); 114 | 115 | // Cache. 116 | const item = { 117 | G: voprf.sec1EncodeToBase64(voprf.getActiveECSettings().curve.G, false), 118 | H: commitment.H, 119 | }; 120 | this.storage.setItem(`${keyPrefix}${version}`, JSON.stringify(item)); 121 | return item; 122 | } 123 | 124 | private async issue( 125 | url: string, 126 | formData: { [key: string]: string[] | string }, 127 | ): Promise { 128 | const tokens = Array.from(Array(NUMBER_OF_REQUESTED_TOKENS).keys()).map(() => new Token()); 129 | const issuance = { 130 | type: 'Issue', 131 | contents: tokens.map((token) => token.getEncodedBlindedPoint()), 132 | }; 133 | const param = btoa(JSON.stringify(issuance)); 134 | 135 | const body = qs.stringify({ 136 | ...formData, 137 | [ISSUANCE_BODY_PARAM_NAME]: param, 138 | }); 139 | 140 | const headers = { 141 | 'content-type': 'application/x-www-form-urlencoded', 142 | [ISSUE_HEADER_NAME]: CloudflareProvider.ID.toString(), 143 | }; 144 | 145 | const response = await fetch(url, { 146 | method: 'POST', 147 | body, 148 | headers, 149 | }).then((r) => r.text()); 150 | 151 | const { signatures } = qs.parse(response); 152 | if (signatures === undefined) { 153 | throw new Error('There is no signatures parameter in the issuance response.'); 154 | } 155 | if (typeof signatures !== 'string') { 156 | throw new Error('The signatures parameter in the issuance response is not a string.'); 157 | } 158 | 159 | interface SignaturesParam { 160 | sigs: string[]; 161 | version: string; 162 | proof: string; 163 | prng: string; 164 | } 165 | 166 | const data: SignaturesParam = JSON.parse(atob(signatures)); 167 | const returned = voprf.getCurvePoints(data.sigs); 168 | 169 | const commitment = await this.getCommitment(data.version); 170 | 171 | const result = voprf.verifyProof( 172 | data.proof, 173 | tokens.map((token) => token.toLegacy()), 174 | returned, 175 | commitment, 176 | data.prng, 177 | ); 178 | if (!result) { 179 | throw new Error('DLEQ proof is invalid.'); 180 | } 181 | 182 | tokens.forEach((token, index) => { 183 | token.setSignedPoint(returned.points[index as number]); 184 | }); 185 | 186 | return tokens; 187 | } 188 | 189 | private getBadgeText(): string { 190 | return this.getStoredTokens().length.toString(); 191 | } 192 | 193 | forceUpdateIcon(): void { 194 | this.callbacks.updateIcon(this.getBadgeText()); 195 | } 196 | 197 | handleActivated(): void { 198 | this.callbacks.updateIcon(this.getBadgeText()); 199 | } 200 | 201 | handleBeforeSendHeaders( 202 | details: chrome.webRequest.WebRequestHeadersDetails, 203 | ): chrome.webRequest.BlockingResponse | void { 204 | // If we suppose to redeem a token with this request 205 | if (this.redeemInfo !== null && details.requestId === this.redeemInfo.requestId) { 206 | const url = new URL(details.url); 207 | 208 | const token = this.redeemInfo.token; 209 | // Clear the redeem info to indicate that we are already redeeming the token. 210 | this.redeemInfo = null; 211 | 212 | const key = token.getMacKey(); 213 | const binding = voprf.createRequestBinding(key, [ 214 | voprf.getBytesFromString(url.hostname), 215 | voprf.getBytesFromString(details.method + ' ' + url.pathname), 216 | ]); 217 | 218 | const contents = [ 219 | voprf.getBase64FromBytes(token.getInput()), 220 | binding, 221 | voprf.getBase64FromString(JSON.stringify(voprf.defaultECSettings)), 222 | ]; 223 | const redemption = btoa(JSON.stringify({ type: 'Redeem', contents })); 224 | 225 | const headers = details.requestHeaders ?? []; 226 | headers.push({ name: 'challenge-bypass-token', value: redemption }); 227 | 228 | this.callbacks.updateIcon(this.getBadgeText()); 229 | 230 | return { 231 | requestHeaders: headers, 232 | }; 233 | } 234 | 235 | // If we suppose to issue tokens with this request 236 | if (this.issueInfo !== null && details.requestId === this.issueInfo.requestId) { 237 | const formData = this.issueInfo.formData; 238 | // Clear the issue info to indicate that we are already issuing tokens. 239 | this.issueInfo = null; 240 | 241 | // We are supposed to also send a Referer header in the issuance request, if there is 242 | // any in the original request. But the browsers don't allow us to send a Referer 243 | // header according to https://xhr.spec.whatwg.org/#dom-xmlhttprequest-setrequestheader 244 | // So we need to extract the token from the Referer header and send it in the query 245 | // param __cf_chl_f_tk instead. (Note that this token is not a Privacy Pass token. 246 | let atoken: string | null = null; 247 | if (details.requestHeaders !== undefined) { 248 | details.requestHeaders.forEach((header) => { 249 | // Filter only for Referrer header. 250 | if (header.name === 'Referer' && header.value !== undefined) { 251 | const url = new URL(header.value); 252 | atoken = url.searchParams.get(REFERER_QUERY_PARAM); 253 | } 254 | }); 255 | } 256 | 257 | (async () => { 258 | const url = new URL(details.url); 259 | if (atoken !== null) { 260 | url.searchParams.append(QUERY_PARAM, atoken); 261 | } 262 | 263 | // Issue tokens. 264 | const tokens = await this.issue(url.href, formData); 265 | // Store tokens. 266 | const cached = this.getStoredTokens(); 267 | this.setStoredTokens(cached.concat(tokens)); 268 | 269 | this.callbacks.navigateUrl(`${url.origin}${url.pathname}`); 270 | })(); 271 | 272 | // TODO I tried to use redirectUrl with data URL or text/html and text/plain but it didn't work, so I continue 273 | // cancelling the request. However, it seems that we can use image/* except image/svg+html. Let's figure how to 274 | // use image data URL later. 275 | // https://blog.mozilla.org/security/2017/11/27/blocking-top-level-navigations-data-urls-firefox-59/ 276 | return { cancel: true }; 277 | } 278 | } 279 | 280 | handleBeforeRequest( 281 | details: chrome.webRequest.WebRequestBodyDetails, 282 | ): chrome.webRequest.BlockingResponse | void { 283 | if ( 284 | details.requestBody === null || 285 | details.requestBody === undefined || 286 | details.requestBody.formData === undefined 287 | ) { 288 | return; 289 | } 290 | 291 | const hasBodyParams = QUALIFIED_BODY_PARAMS.every((param) => { 292 | return ( 293 | details.requestBody !== null && 294 | details.requestBody.formData !== undefined && 295 | param in details.requestBody.formData 296 | ); 297 | }); 298 | if (!hasBodyParams) { 299 | return; 300 | } 301 | 302 | const flattenFormData: { [key: string]: string[] | string } = {}; 303 | for (const key in details.requestBody.formData) { 304 | if (details.requestBody.formData[key as string].length == 1) { 305 | const [value] = details.requestBody.formData[key as string]; 306 | flattenFormData[key as string] = value; 307 | } else { 308 | flattenFormData[key as string] = details.requestBody.formData[key as string]; 309 | } 310 | } 311 | 312 | this.issueInfo = { requestId: details.requestId, formData: flattenFormData }; 313 | } 314 | 315 | handleHeadersReceived( 316 | details: chrome.webRequest.WebResponseHeadersDetails, 317 | ): chrome.webRequest.BlockingResponse | void { 318 | // Don't redeem a token in the issuing website. 319 | const url = new URL(details.url); 320 | if (ISSUING_HOSTNAMES.includes(url.host)) { 321 | return; 322 | } 323 | 324 | // Check if it's the response of the request that we should insert a token. 325 | if (details.statusCode !== 403 || details.responseHeaders === undefined) { 326 | return; 327 | } 328 | const hasSupportHeader = details.responseHeaders.some((header) => { 329 | return ( 330 | header.name.toLowerCase() === CHL_BYPASS_SUPPORT && 331 | header.value !== undefined && 332 | +header.value === CloudflareProvider.ID 333 | ); 334 | }); 335 | if (!hasSupportHeader) { 336 | return; 337 | } 338 | 339 | // Let's try to redeem. 340 | 341 | // Get one token. 342 | const tokens = this.getStoredTokens(); 343 | const oneToken = tokens.shift(); 344 | this.setStoredTokens(tokens); 345 | 346 | if (oneToken === undefined) { 347 | return; 348 | } 349 | 350 | this.redeemInfo = { requestId: details.requestId, token: oneToken }; 351 | // Redirect to resend the request attached with the token. 352 | return { 353 | redirectUrl: details.url, 354 | }; 355 | } 356 | } 357 | -------------------------------------------------------------------------------- /src/background/providers/hcaptcha.test.ts: -------------------------------------------------------------------------------- 1 | import { HcaptchaProvider, IssueInfo } from './hcaptcha'; 2 | import Token from '../token'; 3 | import { jest } from '@jest/globals'; 4 | 5 | class TestHcaptchaProvider extends HcaptchaProvider { 6 | setIssueInfo(info: IssueInfo): void { 7 | this.issueInfo = info; 8 | } 9 | } 10 | 11 | class StorageMock { 12 | store: Map; 13 | 14 | constructor() { 15 | this.store = new Map(); 16 | } 17 | 18 | getItem(key: string): string | null { 19 | return this.store.get(key) ?? null; 20 | } 21 | 22 | setItem(key: string, value: string): void { 23 | this.store.set(key, value); 24 | } 25 | } 26 | 27 | test('getStoredTokens', () => { 28 | const storage = new StorageMock(); 29 | const updateIcon = jest.fn(); 30 | const navigateUrl = jest.fn(); 31 | 32 | const provider = new HcaptchaProvider(storage, { updateIcon, navigateUrl }); 33 | const tokens = [new Token(), new Token()]; 34 | provider['setStoredTokens'](tokens); 35 | const storedTokens = provider['getStoredTokens'](); 36 | expect(storedTokens.map((token) => token.toString())).toEqual( 37 | tokens.map((token) => token.toString()), 38 | ); 39 | }); 40 | 41 | test('setStoredTokens', () => { 42 | const storage = new StorageMock(); 43 | const updateIcon = jest.fn(); 44 | const navigateUrl = jest.fn(); 45 | 46 | const provider = new HcaptchaProvider(storage, { updateIcon, navigateUrl }); 47 | const tokens = [new Token(), new Token()]; 48 | provider['setStoredTokens'](tokens); 49 | const tok = storage.store.get('tokens'); 50 | expect(tok).toBeDefined(); 51 | if (tok !== undefined) { 52 | const storedTokens = JSON.parse(tok); 53 | expect(storedTokens).toEqual(tokens.map((token) => token.toString())); 54 | } 55 | }); 56 | 57 | describe('getBadgeText', () => { 58 | test('extension still has 2 tokens stored', () => { 59 | const storage = new StorageMock(); 60 | const updateIcon = jest.fn(); 61 | const navigateUrl = jest.fn(); 62 | 63 | const provider = new HcaptchaProvider(storage, { updateIcon, navigateUrl }); 64 | const tokens = [new Token(), new Token()]; 65 | provider['setStoredTokens'](tokens); 66 | const text = provider['getBadgeText'](); 67 | expect(text).toBe('2'); 68 | }); 69 | test('storage has no tokens left', () => { 70 | const storage = new StorageMock(); 71 | const updateIcon = jest.fn(); 72 | const navigateUrl = jest.fn(); 73 | 74 | const provider = new HcaptchaProvider(storage, { updateIcon, navigateUrl }); 75 | const text = provider['getBadgeText'](); 76 | expect(text).toBe('0'); 77 | }); 78 | }); 79 | 80 | describe('new tokens', () => { 81 | describe('handleHeadersReceived', () => { 82 | const storage = new StorageMock(); 83 | const updateIcon = jest.fn(); 84 | const navigateUrl = jest.fn(); 85 | const validDetails = { 86 | url: 'https://hcaptcha.com/checkcaptcha/00000000-0000-0000-0000-000000000000/data', 87 | requestId: 'xxx', 88 | frameId: 1, 89 | parentFrameId: 1, 90 | tabId: 1, 91 | type: 'main_frame' as chrome.webRequest.ResourceType, 92 | timeStamp: 1, 93 | method: 'POST', 94 | statusCode: 200, 95 | statusLine: 'HTTP/1.1 200 OK', 96 | }; 97 | const forbiddenDetails = { 98 | ...validDetails, 99 | statusCode: 403, 100 | statusLine: 'HTTP/1.1 403 Forbidden', 101 | }; 102 | const wrongUrlFormatDetails = { 103 | ...validDetails, 104 | url: 'https://example.com', 105 | }; 106 | 107 | test('do not try to get tokens after a captcha failure', () => { 108 | const provider = new TestHcaptchaProvider(storage, { updateIcon, navigateUrl }); 109 | provider.setIssueInfo({ 110 | newUrl: forbiddenDetails.url, 111 | tabId: forbiddenDetails.tabId, 112 | }); 113 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 114 | const issueToken = jest.spyOn(TestHcaptchaProvider.prototype as any, 'issue'); 115 | // eslint-disable-next-line @typescript-eslint/no-empty-function 116 | issueToken.mockImplementation(async () => {}); 117 | 118 | provider.handleHeadersReceived(forbiddenDetails); 119 | expect(provider['issueInfo']).not.toBeNull(); 120 | expect(issueToken).toBeCalledTimes(0); 121 | }); 122 | 123 | test('do not try to get tokens on a wrong url', () => { 124 | const provider = new TestHcaptchaProvider(storage, { updateIcon, navigateUrl }); 125 | provider.setIssueInfo({ 126 | newUrl: wrongUrlFormatDetails.url, 127 | tabId: wrongUrlFormatDetails.tabId, 128 | }); 129 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 130 | const issueToken = jest.spyOn(TestHcaptchaProvider.prototype as any, 'issue'); 131 | // eslint-disable-next-line @typescript-eslint/no-empty-function 132 | issueToken.mockImplementation(async () => {}); 133 | 134 | provider.handleHeadersReceived(wrongUrlFormatDetails); 135 | expect(provider['issueInfo']).not.toBeNull(); 136 | expect(issueToken).toBeCalledTimes(0); 137 | }); 138 | 139 | test('do not try to get tokens if issueInfo is null', () => { 140 | const provider = new TestHcaptchaProvider(storage, { updateIcon, navigateUrl }); 141 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 142 | const issueToken = jest.spyOn(TestHcaptchaProvider.prototype as any, 'issue'); 143 | // eslint-disable-next-line @typescript-eslint/no-empty-function 144 | issueToken.mockImplementation(async () => {}); 145 | 146 | provider.handleHeadersReceived(validDetails); 147 | expect(provider['issueInfo']).toBeNull(); 148 | expect(issueToken).toBeCalledTimes(0); 149 | }); 150 | 151 | test('get tokens after successful captcha response', () => { 152 | const provider = new TestHcaptchaProvider(storage, { updateIcon, navigateUrl }); 153 | provider.setIssueInfo({ 154 | newUrl: validDetails.url, 155 | tabId: validDetails.tabId, 156 | }); 157 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 158 | const issueToken = jest.spyOn(TestHcaptchaProvider.prototype as any, 'issue'); 159 | // eslint-disable-next-line @typescript-eslint/no-empty-function 160 | issueToken.mockImplementation(async () => {}); 161 | 162 | provider.handleHeadersReceived(validDetails); 163 | // Expect issueInfo to be null after calling issuer function. 164 | expect(provider['issueInfo']).toBeNull(); 165 | expect(issueToken).toBeCalledWith(validDetails.url); 166 | expect(issueToken).toBeCalledTimes(1); 167 | }); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /src/background/providers/hcaptcha.ts: -------------------------------------------------------------------------------- 1 | import * as voprf from '../voprf'; 2 | 3 | import { Callbacks, Provider } from '.'; 4 | import Token from '../token'; 5 | import { Storage } from '../storage'; 6 | import qs from 'qs'; 7 | 8 | const COMMITMENT_URL = 9 | 'https://raw.githubusercontent.com/privacypass/ec-commitments/master/commitments-p256.json'; 10 | const SPEND_REGEX = RegExp('^https:\\/\\/(.+\\.)*hcaptcha.com\\/getcaptcha\\/(.*)$'); 11 | const ISSUER_REGEX = RegExp('^https:\\/\\/(.+\\.)*hcaptcha.com\\/checkcaptcha\\/(.*)$'); 12 | const NON_SPEND_HCAPTCHA_URLS = [ 13 | 'https://hcaptcha.com/getcaptcha/00000000-0000-0000-0000-000000000000', 14 | 'https://hcaptcha.com/getcaptcha/10000000-ffff-ffff-ffff-000000000001', 15 | 'https://hcaptcha.com/getcaptcha/20000000-ffff-ffff-ffff-000000000002', 16 | 'https://hcaptcha.com/getcaptcha/30000000-ffff-ffff-ffff-000000000003', 17 | ]; 18 | 19 | const NUMBER_OF_REQUESTED_TOKENS = 5; 20 | const MAX_NUM_OF_TOKENS = 100; 21 | 22 | const TOKEN_STORE_KEY = 'tokens'; 23 | 24 | export interface IssueInfo { 25 | newUrl: string; 26 | tabId: number; 27 | } 28 | 29 | interface SignaturesParam { 30 | sigs: string[]; 31 | version: string; 32 | proof: string; 33 | prng: string; 34 | } 35 | 36 | export class HcaptchaProvider implements Provider { 37 | static readonly ID: number = 2; 38 | private callbacks: Callbacks; 39 | private storage: Storage; 40 | 41 | protected issueInfo: IssueInfo | null; 42 | 43 | constructor(storage: Storage, callbacks: Callbacks) { 44 | voprf.initECSettings(voprf.defaultECSettings); 45 | this.issueInfo = null; 46 | this.callbacks = callbacks; 47 | this.storage = storage; 48 | } 49 | 50 | private getStoredTokens(): Token[] { 51 | const stored = this.storage.getItem(TOKEN_STORE_KEY); 52 | if (stored === null) { 53 | return []; 54 | } 55 | 56 | const tokens: string[] = JSON.parse(stored); 57 | return tokens.map((token) => Token.fromString(token)); 58 | } 59 | 60 | private setStoredTokens(tokens: Token[]) { 61 | this.storage.setItem( 62 | TOKEN_STORE_KEY, 63 | JSON.stringify(tokens.map((token) => token.toString())), 64 | ); 65 | } 66 | 67 | getID(): number { 68 | return HcaptchaProvider.ID; 69 | } 70 | 71 | private getBadgeText(): string { 72 | return this.getStoredTokens().length.toString(); 73 | } 74 | 75 | forceUpdateIcon(): void { 76 | this.callbacks.updateIcon(this.getBadgeText()); 77 | } 78 | 79 | handleActivated(): void { 80 | this.callbacks.updateIcon(this.getBadgeText()); 81 | } 82 | 83 | private handleUrl(url: URL) { 84 | const reqUrl = url.origin + url.pathname; 85 | const isIssuerUrl = ISSUER_REGEX.test(reqUrl); 86 | 87 | // test if the URL is not a special hCaptcha url, and if it a valid spend URL. 88 | const isSpendUrl = !NON_SPEND_HCAPTCHA_URLS.includes(reqUrl) && SPEND_REGEX.test(reqUrl); 89 | 90 | return { 91 | reqUrl, 92 | isIssuerUrl, 93 | isSpendUrl, 94 | }; 95 | } 96 | 97 | private async getCommitment(version: string): Promise<{ G: string; H: string }> { 98 | const key = `commitment-${version}`; 99 | const cached = this.storage.getItem(key); 100 | if (cached !== null) { 101 | return JSON.parse(cached); 102 | } 103 | 104 | interface Response { 105 | HC: { [version: string]: { G: string; H: string; expiry: string; sig: string } }; 106 | } 107 | 108 | // Download the commitment 109 | const data: Response = await fetch(COMMITMENT_URL).then((r) => r.json()); 110 | const commitment = data.HC[version as string]; 111 | if (commitment === undefined) { 112 | throw new Error(`No commitment for the version ${version} is found`); 113 | } 114 | 115 | // Cache. 116 | const item = { 117 | G: commitment.G ?? voprf.sec1EncodeToBase64(voprf.getActiveECSettings().curve.G, false), 118 | H: commitment.H, 119 | }; 120 | this.storage.setItem(key, JSON.stringify(item)); 121 | return item; 122 | } 123 | 124 | private async issue(url: string) { 125 | const newTokens = Array(NUMBER_OF_REQUESTED_TOKENS).fill(new Token()); 126 | const issuePayload = { 127 | type: 'Issue', 128 | contents: newTokens.map((token) => token.getEncodedBlindedPoint()), 129 | }; 130 | const blindedTokens = btoa(JSON.stringify(issuePayload)); 131 | const requestBody = `blinded-tokens=${blindedTokens}&captcha-bypass=true`; 132 | const headers = { 133 | 'Content-Type': 'application/x-www-form-urlencoded', 134 | 'cf-chl-bypass': this.getID().toString(), 135 | }; 136 | 137 | const response = await fetch(url, { 138 | method: 'POST', 139 | body: requestBody, 140 | headers, 141 | }).then((r) => r.text()); 142 | 143 | const { signatures } = qs.parse(response); 144 | if (signatures === undefined) { 145 | throw new Error('There is no signatures parameter in the issuance response.'); 146 | } 147 | if (typeof signatures !== 'string') { 148 | throw new Error('The signatures parameter in the issuance response is not a string.'); 149 | } 150 | 151 | const data: SignaturesParam = JSON.parse(atob(signatures)); 152 | const returned = voprf.getCurvePoints(data.sigs); 153 | const commitment = await this.getCommitment(data.version); 154 | const prng = data.prng || 'shake'; 155 | 156 | const result = voprf.verifyProof( 157 | data.proof, 158 | newTokens.map((token) => token.toLegacy()), 159 | returned, 160 | commitment, 161 | prng, 162 | ); 163 | 164 | if (!result) { 165 | throw new Error('DLEQ proof is invalid.'); 166 | } 167 | 168 | newTokens.forEach((token, index) => { 169 | token.setSignedPoint(returned.points[index as number]); 170 | }); 171 | const oldTokens = this.getStoredTokens(); 172 | this.setStoredTokens(oldTokens.concat(newTokens)); 173 | this.forceUpdateIcon(); 174 | } 175 | 176 | handleBeforeRequest( 177 | _details: chrome.webRequest.WebRequestBodyDetails, 178 | ): chrome.webRequest.BlockingResponse | void { 179 | return; 180 | } 181 | 182 | handleBeforeSendHeaders( 183 | details: chrome.webRequest.WebRequestHeadersDetails, 184 | ): chrome.webRequest.BlockingResponse | void { 185 | if (details.method.toLowerCase() !== 'post') return; 186 | 187 | const url = new URL(details.url); 188 | const urlType = this.handleUrl(url); 189 | 190 | if (urlType.isIssuerUrl) { 191 | // Erase any previous attempt 192 | this.issueInfo = null; 193 | 194 | // Do not store infinite tokens 195 | const tokens = this.getStoredTokens(); 196 | if (tokens.length + NUMBER_OF_REQUESTED_TOKENS > MAX_NUM_OF_TOKENS) return; 197 | 198 | this.issueInfo = { 199 | newUrl: details.url, 200 | tabId: details.tabId, 201 | }; 202 | } else if (urlType.isSpendUrl) { 203 | // Get one token 204 | const tokens = this.getStoredTokens(); 205 | const oneToken = tokens.shift(); 206 | if (oneToken === undefined) return; 207 | this.setStoredTokens(tokens); 208 | this.forceUpdateIcon(); 209 | 210 | // Spend logic here 211 | const httpPath = `${details.method} ${url.pathname}`; 212 | const binding = voprf.createRequestBinding(oneToken.getMacKey(), [ 213 | voprf.getBytesFromString(url.hostname), 214 | voprf.getBytesFromString(httpPath), 215 | ]); 216 | const contents = [ 217 | voprf.getBase64FromBytes(oneToken.getInput()), 218 | binding, 219 | voprf.getBase64FromString(JSON.stringify(voprf.defaultECSettings)), 220 | ]; 221 | const redemption = btoa(JSON.stringify({ type: 'Redeem', contents })); 222 | 223 | const headers = details.requestHeaders ?? []; 224 | headers.push({ name: 'challenge-bypass-token', value: redemption }); 225 | headers.push({ name: 'challenge-bypass-host', value: url.hostname }); 226 | headers.push({ name: 'challenge-bypass-path', value: httpPath }); 227 | 228 | this.issueInfo = null; 229 | return { 230 | requestHeaders: headers, 231 | }; 232 | } 233 | return; 234 | } 235 | 236 | handleHeadersReceived( 237 | details: chrome.webRequest.WebResponseHeadersDetails, 238 | ): chrome.webRequest.BlockingResponse | void { 239 | const url = new URL(details.url); 240 | const urlType = this.handleUrl(url); 241 | // wrong url or invalid status code 242 | if (!urlType.isIssuerUrl || details.statusCode === 403) return; 243 | 244 | // issueInfo was not loaded or the tab changed 245 | if (this.issueInfo === null || details.tabId !== this.issueInfo.tabId) return; 246 | 247 | this.issue(this.issueInfo.newUrl).then(); 248 | 249 | this.issueInfo = null; 250 | return; 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /src/background/providers/index.ts: -------------------------------------------------------------------------------- 1 | export { CloudflareProvider } from './cloudflare'; 2 | export { HcaptchaProvider } from './hcaptcha'; 3 | 4 | export interface Provider { 5 | getID(): number; 6 | forceUpdateIcon(): void; 7 | handleBeforeRequest( 8 | details: chrome.webRequest.WebRequestBodyDetails, 9 | ): chrome.webRequest.BlockingResponse | void; 10 | handleHeadersReceived( 11 | details: chrome.webRequest.WebResponseHeadersDetails, 12 | ): chrome.webRequest.BlockingResponse | void; 13 | handleBeforeSendHeaders( 14 | details: chrome.webRequest.WebRequestHeadersDetails, 15 | ): chrome.webRequest.BlockingResponse | void; 16 | handleActivated(): void; 17 | } 18 | 19 | export interface Callbacks { 20 | updateIcon(text: string): void; 21 | navigateUrl(url: string): void; 22 | } 23 | -------------------------------------------------------------------------------- /src/background/storage.ts: -------------------------------------------------------------------------------- 1 | export class LocalStorage { 2 | private prefix: string; 3 | 4 | constructor(prefix: string) { 5 | this.prefix = prefix; 6 | } 7 | 8 | getItem(key: string): string | null { 9 | return window.localStorage.getItem(`${this.prefix}-${key}`); 10 | } 11 | 12 | setItem(key: string, value: string): void { 13 | window.localStorage.setItem(`${this.prefix}-${key}`, value); 14 | } 15 | } 16 | 17 | export interface Storage { 18 | getItem(key: string): string | null; 19 | setItem(key: string, value: string): void; 20 | } 21 | -------------------------------------------------------------------------------- /src/background/tab.ts: -------------------------------------------------------------------------------- 1 | import { CloudflareProvider, HcaptchaProvider, Provider } from './providers'; 2 | import { LocalStorage } from './storage'; 3 | 4 | // Header from server to indicate that Privacy Pass is supported. 5 | const CHL_BYPASS_SUPPORT = 'cf-chl-bypass'; 6 | 7 | export class Tab { 8 | private context: Provider | null; 9 | /* private */ chromeTabId: number; 10 | /* private */ active: boolean; 11 | 12 | constructor(tabId: number) { 13 | this.context = null; 14 | this.chromeTabId = tabId; 15 | this.active = false; 16 | 17 | this.updateIcon = this.updateIcon.bind(this); 18 | this.navigateUrl = this.navigateUrl.bind(this); 19 | } 20 | 21 | private updateIcon(text: string): void { 22 | if (this.active) { 23 | if (this.context !== null) { 24 | chrome.browserAction.setIcon({ path: 'icons/32/gold.png' }); 25 | chrome.browserAction.setBadgeText({ text }); 26 | } else { 27 | this.clearIcon(); 28 | } 29 | } 30 | } 31 | 32 | private clearIcon(): void { 33 | if (this.active) { 34 | chrome.browserAction.setIcon({ path: 'icons/32/grey.png' }); 35 | chrome.browserAction.setBadgeText({ text: '' }); 36 | } 37 | } 38 | 39 | forceUpdateIcon(): void { 40 | if (this.active) { 41 | if (this.context !== null) { 42 | this.context.forceUpdateIcon(); 43 | } else { 44 | this.clearIcon(); 45 | } 46 | } 47 | } 48 | 49 | private navigateUrl(url: string): void { 50 | chrome.tabs.update(this.chromeTabId, { url }); 51 | } 52 | 53 | handleActivated(): void { 54 | this.active = true; 55 | if (this.context !== null) { 56 | this.context.handleActivated(); 57 | } else { 58 | this.clearIcon(); 59 | } 60 | } 61 | 62 | handleDeactivated(): void { 63 | this.active = false; 64 | } 65 | 66 | handleBeforeRequest( 67 | details: chrome.webRequest.WebRequestBodyDetails, 68 | ): chrome.webRequest.BlockingResponse | void { 69 | let result; 70 | if (this.context !== null) { 71 | result = this.context.handleBeforeRequest(details); 72 | } 73 | 74 | return result; 75 | } 76 | 77 | handleBeforeSendHeaders( 78 | details: chrome.webRequest.WebRequestHeadersDetails, 79 | ): chrome.webRequest.BlockingResponse | void { 80 | let result; 81 | if (this.context !== null) { 82 | result = this.context.handleBeforeSendHeaders(details); 83 | } 84 | 85 | return result; 86 | } 87 | 88 | handleHeadersReceived( 89 | details: chrome.webRequest.WebResponseHeadersDetails, 90 | ): chrome.webRequest.BlockingResponse | void { 91 | if (details.responseHeaders === undefined) { 92 | return; 93 | } 94 | const [providerId] = details.responseHeaders 95 | .filter((header) => header.name.toLowerCase() === CHL_BYPASS_SUPPORT) 96 | .map((header) => header.value !== undefined && +header.value); 97 | 98 | if (details.type === 'main_frame') { 99 | // The page in the tab is changed, so the context should change. 100 | this.context = null; 101 | this.clearIcon(); 102 | } 103 | 104 | // Cloudflare has higher precedence than Hcaptcha. 105 | if (providerId === CloudflareProvider.ID && !(this.context instanceof CloudflareProvider)) { 106 | const context = new CloudflareProvider(new LocalStorage('cf'), { 107 | updateIcon: this.updateIcon, 108 | navigateUrl: this.navigateUrl, 109 | }); 110 | 111 | this.context = context; 112 | this.context.handleActivated(); 113 | } else if ( 114 | providerId === HcaptchaProvider.ID && 115 | !(this.context instanceof CloudflareProvider) && 116 | !(this.context instanceof HcaptchaProvider) 117 | ) { 118 | this.context = new HcaptchaProvider(new LocalStorage('hc'), { 119 | updateIcon: this.updateIcon, 120 | navigateUrl: this.navigateUrl, 121 | }); 122 | this.context.handleActivated(); 123 | } 124 | 125 | let result; 126 | if (this.context !== null) { 127 | result = this.context.handleHeadersReceived(details); 128 | } 129 | 130 | return result; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/background/token.test.ts: -------------------------------------------------------------------------------- 1 | import Token from './token'; 2 | import { initECSettings } from './voprf'; 3 | 4 | beforeAll(() => { 5 | // TODO This shouldn't be needed after refactoring the voprf module. 6 | initECSettings({ 7 | curve: 'p256', 8 | hash: 'sha256', 9 | method: 'increment', 10 | }); 11 | }); 12 | 13 | test('Construct a token', () => { 14 | new Token(); 15 | }); 16 | -------------------------------------------------------------------------------- /src/background/token.ts: -------------------------------------------------------------------------------- 1 | import * as voprf from './voprf'; 2 | 3 | interface SignedComponent { 4 | blindedPoint: voprf.Point; 5 | unblindedPoint: voprf.Point; 6 | } 7 | 8 | export default class Token { 9 | private input: voprf.Bytes; 10 | private factor: voprf.BigNum; 11 | 12 | private blindedPoint: voprf.Point; 13 | private unblindedPoint: voprf.Point; 14 | 15 | private signed: SignedComponent | null; 16 | 17 | constructor() { 18 | const { data: input, point: unblindedPoint } = voprf.newRandomPoint(); 19 | const { blind: factor, point: blindedPoint } = voprf.blindPoint(unblindedPoint); 20 | 21 | this.input = input; 22 | this.factor = factor; 23 | 24 | this.blindedPoint = blindedPoint; 25 | this.unblindedPoint = unblindedPoint; 26 | 27 | this.signed = null; 28 | } 29 | 30 | static fromString(str: string): Token { 31 | const json = JSON.parse(str); 32 | 33 | const token: Token = Object.create(Token.prototype); 34 | 35 | token.input = json.input; 36 | token.factor = voprf.newBigNum(json.factor); 37 | 38 | token.blindedPoint = voprf.sec1DecodeFromBase64(json.blindedPoint); 39 | token.unblindedPoint = voprf.sec1DecodeFromBase64(json.unblindedPoint); 40 | 41 | token.signed = 42 | json.signed !== null 43 | ? { 44 | blindedPoint: voprf.sec1DecodeFromBase64(json.signed.blindedPoint), 45 | unblindedPoint: voprf.sec1DecodeFromBase64(json.signed.unblindedPoint), 46 | } 47 | : null; 48 | 49 | return token; 50 | } 51 | 52 | setSignedPoint(point: voprf.Point): void { 53 | const blindedPoint = point; 54 | const unblindedPoint = voprf.unblindPoint(this.factor, point); 55 | 56 | this.signed = { 57 | blindedPoint, 58 | unblindedPoint, 59 | }; 60 | } 61 | 62 | // TODO This should be implemented in a new Point class. 63 | getEncodedBlindedPoint(): string { 64 | return voprf.sec1EncodeToBase64(this.blindedPoint, true); // true is for compression 65 | } 66 | 67 | toLegacy(): { data: voprf.Bytes; point: voprf.Point; blind: voprf.BigNum } { 68 | return { 69 | data: this.input, 70 | point: this.blindedPoint, 71 | blind: this.factor, 72 | }; 73 | } 74 | 75 | getMacKey(): voprf.Bytes { 76 | if (this.signed === null) { 77 | throw new Error('Unsigned token is used to derive a MAC key'); 78 | } 79 | return voprf.deriveKey(this.signed.unblindedPoint, this.input); 80 | } 81 | 82 | getInput(): voprf.Bytes { 83 | return this.input; 84 | } 85 | 86 | toString(): string { 87 | const signed = 88 | this.signed !== null 89 | ? { 90 | blindedPoint: voprf.sec1EncodeToBase64(this.signed.blindedPoint, false), 91 | unblindedPoint: voprf.sec1EncodeToBase64(this.signed.unblindedPoint, false), 92 | } 93 | : null; 94 | 95 | const json = { 96 | input: this.input, 97 | factor: this.factor.toString(), 98 | blindedPoint: voprf.sec1EncodeToBase64(this.blindedPoint, false), 99 | unblindedPoint: voprf.sec1EncodeToBase64(this.unblindedPoint, false), 100 | signed, 101 | }; 102 | return JSON.stringify(json); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/background/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "lib": [ 5 | "dom" 6 | ] 7 | }, 8 | "extends": "../../tsconfig.json", 9 | "include": [ 10 | "." 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/background/voprf.d.ts: -------------------------------------------------------------------------------- 1 | export type Point = unknown; 2 | export type Bytes = unknown; 3 | export type BigNum = { toString(): string }; 4 | 5 | export type Curve = 'p256'; 6 | export type Hash = 'sha256'; 7 | export type HashMethod = 'increment' | 'swu'; 8 | 9 | export interface ECSettings { 10 | curve: any; 11 | hash: Hash; 12 | method: HashMethod; 13 | } 14 | 15 | export const defaultECSettings: ECSettings; 16 | 17 | // TODO This should be implemented in a new Point class. 18 | export function blindPoint(point: Point): { blind: BigNum; point: Point }; 19 | // TODO This should be implemented in a new Point class. 20 | export function newRandomPoint(): { data: Bytes; point: Point }; 21 | 22 | export function getActiveECSettings(): ECSettings; 23 | 24 | export function initECSettings(params: { curve: Curve; hash: Hash; method: HashMethod }): void; 25 | 26 | // TODO This should be implemented in a new Point class. 27 | export function getCurvePoints(signatures: string[]): { 28 | points: Point[]; 29 | compressed: boolean; 30 | }; 31 | 32 | // TODO This should be implemented in a new Point class. 33 | export function sec1EncodeToBase64(point: Point, compressed: boolean): string; 34 | // TODO This should be implemented in a new Point class. 35 | export function sec1DecodeFromBase64(encoded: string): Point; 36 | 37 | export function verifyConfiguration(publicKey: string, config: any, signature: string): boolean; 38 | // TODO Proof verification should be inside Token class. 39 | export function verifyProof( 40 | proof: string, 41 | tokens: unknown[], 42 | signatures: { points: Point[]; compressed: boolean }, 43 | commitments: any, 44 | prngName: any, 45 | ): boolean; 46 | 47 | export function unblindPoint(factor: BigNum, blindedPoint: Point): Point; 48 | 49 | export function newBigNum(encoded: string): BigNum; 50 | 51 | export function deriveKey(N: Point, token: any): any; 52 | export function createRequestBinding(key: any, data: any): any; 53 | export function getBytesFromString(str: any): any; 54 | export function getBase64FromString(str: any): any; 55 | export function getBase64FromBytes(bytes: any): any; 56 | -------------------------------------------------------------------------------- /src/background/voprf.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This implements a 2HashDH-based token scheme using the SJCL ecc package. 3 | * 4 | * @author: George Tankersley 5 | * @author: Alex Davidson 6 | */ 7 | 8 | import 'asn1-parser'; 9 | 10 | import sjcl from 'sjcl'; 11 | 12 | import createKeccakHash from 'keccak'; 13 | 14 | let PEM; 15 | let ASN1; 16 | if (typeof window !== 'undefined') { 17 | PEM = window.PEM; 18 | ASN1 = window.ASN1; 19 | } 20 | 21 | export const shake256 = () => { 22 | return createKeccakHash("shake256", {}); 23 | }; 24 | 25 | const BATCH_PROOF_PREFIX = 'batch-proof='; 26 | const MASK = ['0xff', '0x1', '0x3', '0x7', '0xf', '0x1f', '0x3f', '0x7f']; 27 | 28 | const DIGEST_INEQUALITY_ERR = '[privacy-pass]: Recomputed digest does not equal received digest'; 29 | const PARSE_ERR = '[privacy-pass]: Error parsing proof'; 30 | 31 | // Globals for keeping track of EC curve settings 32 | let CURVE; 33 | let CURVE_H2C_HASH; 34 | let CURVE_H2C_METHOD; 35 | let CURVE_H2C_LABEL; 36 | 37 | // 1.2.840.10045.3.1.7 point generation seed 38 | const INC_H2C_LABEL = sjcl.codec.hex.toBits( 39 | '312e322e3834302e31303034352e332e312e3720706f696e742067656e65726174696f6e2073656564', 40 | ); 41 | const SSWU_H2C_LABEL = 'H2C-P256-SHA256-SSWU-'; 42 | 43 | export const defaultECSettings = { 44 | curve: 'p256', 45 | hash: 'sha256', 46 | method: 'increment', 47 | }; 48 | 49 | /** 50 | * Sets the curve parameters for the current session based on the contents of 51 | * activeConfig.h2c-params 52 | * @param h2cParams 53 | */ 54 | export function initECSettings(h2cParams) { 55 | const curveStr = h2cParams.curve; 56 | const hashStr = h2cParams.hash; 57 | const methodStr = h2cParams.method; 58 | switch (curveStr) { 59 | case 'p256': 60 | if (methodStr !== 'swu' && methodStr !== 'increment') { 61 | throw new Error( 62 | "[privacy-pass]: Incompatible h2c method: '" + 63 | methodStr + 64 | "', for curve " + 65 | curveStr, 66 | ); 67 | } else if (hashStr !== 'sha256') { 68 | throw new Error( 69 | "[privacy-pass]: Incompatible h2c hash: '" + 70 | hashStr + 71 | "', for curve " + 72 | curveStr, 73 | ); 74 | } 75 | CURVE = sjcl.ecc.curves.c256; 76 | CURVE_H2C_HASH = sjcl.hash.sha256; 77 | CURVE_H2C_METHOD = methodStr; 78 | CURVE_H2C_LABEL = methodStr === 'increment' ? INC_H2C_LABEL : SSWU_H2C_LABEL; 79 | break; 80 | default: 81 | throw new Error('[privacy-pass]: Incompatible curve chosen: ' + curveStr); 82 | } 83 | } 84 | 85 | /** 86 | * Returns the active configuration for the elliptic curve setting 87 | * @return {Object} Object containing the active curve and h2c configuration 88 | */ 89 | export function getActiveECSettings() { 90 | return { curve: CURVE, hash: CURVE_H2C_HASH, method: CURVE_H2C_METHOD, label: CURVE_H2C_LABEL }; 91 | } 92 | 93 | /** 94 | * Multiplies the point P with the scalar k and outputs kP 95 | * @param {sjcl.bn} k scalar 96 | * @param {sjcl.ecc.point} P curve point 97 | * @return {sjcl.ecc.point} 98 | */ 99 | function _scalarMult(k, P) { 100 | const Q = P.mult(k); 101 | return Q; 102 | } 103 | 104 | /** 105 | * Samples a random scalar and uses it to blind the point P 106 | * @param {sjcl.ecc.point} P curve point 107 | * @return {sjcl.ecc.point} 108 | */ 109 | export function blindPoint(P) { 110 | const bF = sjcl.bn.random(CURVE.r, 10); 111 | const bP = _scalarMult(bF, P); 112 | return { point: bP, blind: bF }; 113 | } 114 | 115 | /** 116 | * unblindPoint takes an assumed-to-be blinded point Q and an accompanying 117 | * blinding scalar b, then returns the point (1/b)*Q. 118 | * @param {sjcl.bn} b scalar blinding factor 119 | * @param {sjcl.ecc.point} Q curve point 120 | * @return {sjcl.ecc.point} 121 | */ 122 | export function unblindPoint(b, Q) { 123 | const binv = b.inverseMod(CURVE.r); 124 | return _scalarMult(binv, Q); 125 | } 126 | 127 | /** 128 | * Creates a new random point on the curve by sampling random bytes and then 129 | * hashing to the chosen curve. 130 | * @return {sjcl.ecc.point} 131 | */ 132 | export function newRandomPoint() { 133 | const random = crypto.getRandomValues(new Int32Array(8)); 134 | 135 | // Choose hash-to-curve method 136 | const point = h2Curve(random, getActiveECSettings()); 137 | 138 | let t; 139 | if (point) { 140 | t = { data: sjcl.codec.bytes.fromBits(random), point: point }; 141 | } 142 | return t; 143 | } 144 | 145 | /** 146 | * Encodes a curve point as bytes in SEC1 uncompressed format 147 | * @param {sjcl.ecc.point} P 148 | * @param {bool} compressed 149 | * @return {sjcl.codec.bytes} 150 | */ 151 | export function sec1Encode(P, compressed) { 152 | let out = []; 153 | if (!compressed) { 154 | const xyBytes = sjcl.codec.bytes.fromBits(P.toBits()); 155 | out = [0x04].concat(xyBytes); 156 | } else { 157 | const xBytes = sjcl.codec.bytes.fromBits(P.x.toBits()); 158 | const y = P.y.normalize(); 159 | const sign = y.limbs[0] & 1 ? 0x03 : 0x02; 160 | out = [sign].concat(xBytes); 161 | } 162 | return out; 163 | } 164 | 165 | /** 166 | * Encodes a curve point into bits for using as input to hash functions etc 167 | * @param {sjcl.ecc.point} point curve point 168 | * @param {bool} compressed flag indicating whether points have been compressed 169 | * @return {sjcl.bitArray} 170 | */ 171 | function sec1EncodeToBits(point, compressed) { 172 | return sjcl.codec.bytes.toBits(sec1Encode(point, compressed)); 173 | } 174 | 175 | /** 176 | * Encodes a point into a base 64 string 177 | * @param {sjcl.ecc.point} point 178 | * @param {bool} compressed 179 | * @return {string} 180 | */ 181 | export function sec1EncodeToBase64(point, compressed) { 182 | return sjcl.codec.base64.fromBits(sec1EncodeToBits(point, compressed)); 183 | } 184 | 185 | /** 186 | * Decodes a base64-encoded string into a curve point 187 | * @param {string} p a base64-encoded, uncompressed curve point 188 | * @return {sjcl.ecc.point} 189 | */ 190 | export function sec1DecodeFromBase64(p) { 191 | const sec1Bits = sjcl.codec.base64.toBits(p); 192 | const sec1Bytes = sjcl.codec.bytes.fromBits(sec1Bits); 193 | return sec1DecodeFromBytes(sec1Bytes); 194 | } 195 | 196 | /** 197 | * Decodes (SEC1) curve point bytes into a valid curve point 198 | * @param {sjcl.codec.bytes} sec1Bytes bytes of an uncompressed curve point 199 | * @return {sjcl.ecc.point} 200 | */ 201 | export function sec1DecodeFromBytes(sec1Bytes) { 202 | let P; 203 | switch (sec1Bytes[0]) { 204 | case 0x02: 205 | case 0x03: 206 | P = decompressPoint(sec1Bytes); 207 | break; 208 | case 0x04: 209 | P = CURVE.fromBits(sjcl.codec.bytes.toBits(sec1Bytes.slice(1))); 210 | break; 211 | default: 212 | throw new Error( 213 | '[privacy-pass]: attempted sec1 point decoding with incorrect tag: ' + sec1Bytes[0], 214 | ); 215 | } 216 | return P; 217 | } 218 | 219 | /** 220 | * Attempts to decompress a curve point in SEC1 encoded format. Returns null if 221 | * the point is invalid 222 | * @param {sjcl.codec.bytes} bytes bytes of a compressed curve point (SEC1) 223 | * @return {sjcl.ecc.point} may be null if compressed bytes are not valid 224 | */ 225 | function decompressPoint(bytes) { 226 | const yTag = bytes[0]; 227 | const expLength = CURVE.r.bitLength() / 8 + 1; // bitLength rounds up 228 | if (yTag != 2 && yTag != 3) { 229 | throw new Error('[privacy-pass]: compressed point is invalid, bytes[0] = ' + yTag); 230 | } else if (bytes.length !== expLength) { 231 | throw new Error( 232 | `[privacy-pass]: compressed point is too long, actual = ${bytes.length}, expected = ${expLength}`, 233 | ); 234 | } 235 | const xBytes = bytes.slice(1); 236 | const x = CURVE.field.fromBits(sjcl.codec.bytes.toBits(xBytes)).normalize(); 237 | const sign = yTag & 1; 238 | 239 | // y^2 = x^3 - 3x + b (mod p) 240 | let rh = x.power(3); 241 | const threeTimesX = x.mul(CURVE.a); 242 | rh = rh.add(threeTimesX).add(CURVE.b).mod(CURVE.field.modulus); // mod() normalizes 243 | 244 | // modsqrt(z) for p = 3 mod 4 is z^(p+1/4) 245 | const sqrt = CURVE.field.modulus.add(1).normalize().halveM().halveM(); 246 | let y = new CURVE.field(rh.powermod(sqrt, CURVE.field.modulus)); 247 | 248 | const parity = y.limbs[0] & 1; 249 | if (parity != sign) { 250 | y = CURVE.field.modulus.sub(y).normalize(); 251 | } 252 | 253 | const point = new sjcl.ecc.point(CURVE, x, y); 254 | if (!point.isValid()) { 255 | // we return null here rather than an error as we iterate over this 256 | // method during hash-and-inc 257 | return null; 258 | } 259 | return point; 260 | } 261 | 262 | /** 263 | * Decodes the received curve points 264 | * @param {Array} signatures An array of base64-encoded signed points 265 | * @return {Object} object containing array of curve points and compression flag 266 | */ 267 | export function getCurvePoints(signatures) { 268 | const compression = { on: false, set: false }; 269 | const sigBytes = []; 270 | signatures.forEach(function (signature) { 271 | const buf = sjcl.codec.bytes.fromBits(sjcl.codec.base64.toBits(signature)); 272 | let setting = false; 273 | switch (buf[0]) { 274 | case 2: 275 | case 3: 276 | setting = true; 277 | break; 278 | case 4: 279 | // do nothing 280 | break; 281 | default: 282 | throw new Error(`[privacy-pass]: point, ${buf}, is not encoded correctly`); 283 | } 284 | if (!validResponseCompression(compression, setting)) { 285 | throw new Error('[privacy-pass]: inconsistent point compression in server response'); 286 | } 287 | sigBytes.push(buf); 288 | }); 289 | 290 | const usablePoints = []; 291 | sigBytes.forEach(function (buf) { 292 | const usablePoint = sec1DecodeFromBytes(buf); 293 | if (usablePoint == null) { 294 | throw new Error('[privacy-pass]: unable to decode point: ' + buf); 295 | } 296 | usablePoints.push(usablePoint); 297 | }); 298 | return { points: usablePoints, compressed: compression.on }; 299 | } 300 | 301 | /** 302 | * Checks that the signed points from the IssueResponse have consistent 303 | * compression 304 | * @param {Object} compression compression object to be checked for consistency 305 | * @param {bool} setting new setting based on point data 306 | * @return {bool} 307 | */ 308 | function validResponseCompression(compression, setting) { 309 | if (!compression.set) { 310 | compression.on = setting; 311 | compression.set = true; 312 | } else if (compression.on !== setting) { 313 | return false; 314 | } 315 | return true; 316 | } 317 | 318 | // Commitments verification 319 | 320 | /** 321 | * Parse a PEM-encoded signature. 322 | * @param {string} pemSignature - A signature in PEM format. 323 | * @return {sjcl.bitArray} a signature object for sjcl library. 324 | */ 325 | function parseSignaturefromPEM(pemSignature) { 326 | try { 327 | const bytes = PEM.parseBlock(pemSignature); 328 | const json = ASN1.parse(bytes.der); 329 | const r = sjcl.codec.bytes.toBits(json.children[0].value); 330 | const s = sjcl.codec.bytes.toBits(json.children[1].value); 331 | return sjcl.bitArray.concat(r, s); 332 | } catch (e) { 333 | throw new Error('[privacy-pass]: Failed on parsing commitment signature. ' + e.message); 334 | } 335 | } 336 | 337 | /** 338 | * Parse a PEM-encoded public key. 339 | * @param {string} pemPublicKey - A public key in PEM format. 340 | * @return {sjcl.ecc.ecdsa.publicKey} a public key for sjcl library. 341 | */ 342 | function parsePublicKeyfromPEM(pemPublicKey) { 343 | try { 344 | let bytes = PEM.parseBlock(pemPublicKey); 345 | let json = ASN1.parse(bytes.der); 346 | let xy = json.children[1].value; 347 | const point = sec1DecodeFromBytes(xy); 348 | return new sjcl.ecc.ecdsa.publicKey(CURVE, point); 349 | } catch (e) { 350 | throw new Error('[privacy-pass]: Failed on parsing public key. ' + e.message); 351 | } 352 | } 353 | 354 | /** 355 | * Verify the signature of the retrieved configuration portion. 356 | * @param {Number} cfgId - ID of configuration being used. 357 | * @param {json} config - commitments to verify 358 | * @return {boolean} True, if the commitment has valid signature and is not 359 | * expired; otherwise, throws an exception. 360 | */ 361 | export function verifyConfiguration(publicKey, config, signature) { 362 | const sig = parseSignaturefromPEM(signature); 363 | const msg = JSON.stringify(config); 364 | const pk = parsePublicKeyfromPEM(publicKey); 365 | const hmsg = sjcl.hash.sha256.hash(msg); 366 | try { 367 | return pk.verify(hmsg, sig); 368 | } catch (error) { 369 | throw new Error('[privacy-pass]: Invalid configuration verification.'); 370 | } 371 | } 372 | 373 | /** 374 | * DLEQ proof verification logic 375 | */ 376 | 377 | /** 378 | * Verify the DLEQ proof object using the information provided 379 | * @param {string} proofObj base64-encoded batched DLEQ proof object 380 | * @param {Object} tokens array of token objects containing blinded curve points 381 | * @param {Array} signatures an array of signed points 382 | * @param {Object} commitments JSON object containing encoded curve points 383 | * @param {string} prngName name of the PRNG used for verifying proof 384 | * @return {boolean} 385 | */ 386 | export function verifyProof(proofObj, tokens, signatures, commitments, prngName) { 387 | const bp = getMarshaledBatchProof(proofObj); 388 | const dleq = retrieveProof(bp); 389 | if (!dleq) { 390 | // Error has probably occurred 391 | return false; 392 | } 393 | if (tokens.length !== signatures.points.length) { 394 | return false; 395 | } 396 | const pointG = sec1DecodeFromBase64(commitments.G); 397 | const pointH = sec1DecodeFromBase64(commitments.H); 398 | 399 | // Recompute A and B for proof verification 400 | const cH = _scalarMult(dleq.C, pointH); 401 | const rG = _scalarMult(dleq.R, pointG); 402 | const A = cH.toJac().add(rG).toAffine(); 403 | 404 | const composites = recomputeComposites(tokens, signatures, pointG, pointH, prngName); 405 | const cZ = _scalarMult(dleq.C, composites.Z); 406 | const rM = _scalarMult(dleq.R, composites.M); 407 | const B = cZ.toJac().add(rM).toAffine(); 408 | 409 | // Recalculate C' and check if C =?= C' 410 | const h = new CURVE_H2C_HASH(); // use the h2c hash for convenience 411 | h.update(sec1EncodeToBits(pointG, signatures.compressed)); 412 | h.update(sec1EncodeToBits(pointH, signatures.compressed)); 413 | h.update(sec1EncodeToBits(composites.M, signatures.compressed)); 414 | h.update(sec1EncodeToBits(composites.Z, signatures.compressed)); 415 | h.update(sec1EncodeToBits(A, signatures.compressed)); 416 | h.update(sec1EncodeToBits(B, signatures.compressed)); 417 | const digestBits = h.finalize(); 418 | const receivedDigestBits = dleq.C.toBits(); 419 | if (!sjcl.bitArray.equal(digestBits, receivedDigestBits)) { 420 | console.error(DIGEST_INEQUALITY_ERR); 421 | console.error('Computed digest: ' + digestBits.toString()); 422 | console.error('Received digest: ' + receivedDigestBits.toString()); 423 | return false; 424 | } 425 | return true; 426 | } 427 | 428 | /** 429 | * Recompute the composite M and Z values for verifying DLEQ 430 | * @param {Array} tokens array of token objects containing blinded curve points 431 | * @param {Object} signatures contains array of signed curve points and compression flag 432 | * @param {sjcl.ecc.point} pointG curve point 433 | * @param {sjcl.ecc.point} pointH curve point 434 | * @param {string} prngName name of PRNG used to verify proof 435 | * @return {Object} Object containing composite points M and Z 436 | */ 437 | function recomputeComposites(tokens, signatures, pointG, pointH, prngName) { 438 | const seed = computeSeed(tokens, signatures, pointG, pointH); 439 | let cM = new sjcl.ecc.pointJac(CURVE); // can only add points in jacobian representation 440 | let cZ = new sjcl.ecc.pointJac(CURVE); 441 | const prng = { name: prngName }; 442 | switch (prng.name) { 443 | case 'shake': 444 | prng.func = shake256(); 445 | prng.func.update(seed, 'hex'); 446 | break; 447 | case 'hkdf': 448 | prng.func = evaluateHkdf; 449 | break; 450 | default: 451 | throw new Error(`Server specified PRNG is not compatible: ${prng.name}`); 452 | } 453 | let iter = -1; 454 | for (let i = 0; i < tokens.length; i++) { 455 | iter++; 456 | const ci = computePRNGScalar(prng, seed, new sjcl.bn(iter).toBits()); 457 | // Moved this check out of computePRNGScalar to here 458 | if (ci.greaterEquals(CURVE.r)) { 459 | i--; 460 | continue; 461 | } 462 | const cMi = _scalarMult(ci, tokens[i].point); 463 | const cZi = _scalarMult(ci, signatures.points[i]); 464 | cM = cM.add(cMi); 465 | cZ = cZ.add(cZi); 466 | } 467 | return { M: cM.toAffine(), Z: cZ.toAffine() }; 468 | } 469 | 470 | /** 471 | * Computes an output of a PRNG (using the seed if it is HKDF) as a sjcl bn 472 | * object 473 | * @param {Object} prng PRNG object for generating output 474 | * @param {string} seed hex-encoded seed 475 | * @param {sjcl.bitArray} salt optional salt for each PRNG eval 476 | * @return {sjcl.bn} PRNG output as scalar value 477 | */ 478 | function computePRNGScalar(prng, seed, salt) { 479 | const bitLen = CURVE.r.bitLength(); 480 | const mask = MASK[bitLen % 8]; 481 | let out; 482 | switch (prng.name) { 483 | case 'shake': 484 | out = prng.func.squeeze(32, 'hex'); 485 | break; 486 | case 'hkdf': 487 | out = sjcl.codec.hex.fromBits( 488 | prng.func( 489 | sjcl.codec.hex.toBits(seed), 490 | bitLen / 8, 491 | sjcl.codec.utf8String.toBits('DLEQ_PROOF'), 492 | salt, 493 | CURVE_H2C_HASH, 494 | ), 495 | ); 496 | break; 497 | default: 498 | throw new Error(`Server specified PRNG is not compatible: ${prng.name}`); 499 | } 500 | // Masking is not strictly necessary for p256 but better to be completely 501 | // compatible in case that the curve changes 502 | const h = parseInt(out.substr(0, 2), 16); 503 | const mh = sjcl.codec.hex.fromBits(sjcl.codec.bytes.toBits([h & mask])); 504 | out = mh + out.substr(2); 505 | const nOut = getBigNumFromHex(out); 506 | return nOut; 507 | } 508 | 509 | /** 510 | * Computes a seed for the PRNG for verifying batch DLEQ proofs 511 | * @param {Object} chkM array of token objects containing blinded curve points 512 | * @param {sjcl.ecc.point[]} chkZ array of signed curve points 513 | * @param {sjcl.ecc.point} pointG curve point 514 | * @param {sjcl.ecc.point} pointH curve point 515 | * @return {string} hex-encoded PRNG seed 516 | */ 517 | function computeSeed(chkM, chkZ, pointG, pointH) { 518 | const compressed = chkZ.compressed; 519 | const h = new CURVE_H2C_HASH(); // we use the h2c hash for convenience 520 | h.update(sec1EncodeToBits(pointG, compressed)); 521 | h.update(sec1EncodeToBits(pointH, compressed)); 522 | for (let i = 0; i < chkM.length; i++) { 523 | h.update(sec1EncodeToBits(chkM[i].point, compressed)); 524 | h.update(sec1EncodeToBits(chkZ.points[i], compressed)); 525 | } 526 | return sjcl.codec.hex.fromBits(h.finalize()); 527 | } 528 | 529 | /** 530 | * hkdf - The HMAC-based Key Derivation Function 531 | * based on https://github.com/mozilla/node-hkdf 532 | * 533 | * This Source Code Form is subject to the terms of the Mozilla Public 534 | * License, v. 2.0. If a copy of the MPL was not distributed with this 535 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 536 | * 537 | * @param {bitArray} ikm Initial keying material 538 | * @param {integer} length Length of the derived key in bytes 539 | * @param {bitArray} info Key derivation data 540 | * @param {bitArray} salt Salt 541 | * @param {sjcl.hash} hash hash function 542 | * @return {bitArray} 543 | */ 544 | function evaluateHkdf(ikm, length, info, salt, hash) { 545 | const mac = new sjcl.misc.hmac(salt, hash); 546 | mac.update(ikm); 547 | const prk = mac.digest(); 548 | 549 | const hashLength = Math.ceil(sjcl.bitArray.bitLength(prk) / 8); 550 | const numBlocks = Math.ceil(length / hashLength); 551 | if (numBlocks > 255) { 552 | throw new Error( 553 | `[privacy-pass]: HKDF error, number of proposed iterations too large: ${numBlocks}`, 554 | ); 555 | } 556 | 557 | let prev = sjcl.codec.hex.toBits(''); 558 | let output = ''; 559 | for (let i = 0; i < numBlocks; i++) { 560 | const hmac = new sjcl.misc.hmac(prk, hash); 561 | const input = sjcl.bitArray.concat( 562 | sjcl.bitArray.concat(prev, info), 563 | sjcl.codec.utf8String.toBits(String.fromCharCode(i + 1)), 564 | ); 565 | hmac.update(input); 566 | prev = hmac.digest(); 567 | output += sjcl.codec.hex.fromBits(prev); 568 | } 569 | return sjcl.bitArray.clamp(sjcl.codec.hex.toBits(output), length * 8); 570 | } 571 | 572 | /** 573 | * Returns a decoded DLEQ proof as an object that can be verified 574 | * @param {Object} bp batch proof as encoded JSON 575 | * @return {Object} DLEQ proof object 576 | */ 577 | function retrieveProof(bp) { 578 | let dleqProof; 579 | try { 580 | dleqProof = parseDleqProof(atob(bp.P)); 581 | } catch (e) { 582 | console.error(`${PARSE_ERR}: ${e}`); 583 | return; 584 | } 585 | return dleqProof; 586 | } 587 | 588 | /** 589 | * Decode proof string and remove prefix 590 | * @param {string} proof base64-encoded batched DLEQ proof 591 | * @return {Object} JSON batched DLEQ proof 592 | */ 593 | function getMarshaledBatchProof(proof) { 594 | let proofStr = atob(proof); 595 | if (proofStr.indexOf(BATCH_PROOF_PREFIX) === 0) { 596 | proofStr = proofStr.substring(BATCH_PROOF_PREFIX.length); 597 | } 598 | return JSON.parse(proofStr); 599 | } 600 | 601 | /** 602 | * Decode the proof that is sent into an Object 603 | * @param {string} proofStr proof JSON as string 604 | * @return {Object} 605 | */ 606 | function parseDleqProof(proofStr) { 607 | const dleqProofM = JSON.parse(proofStr); 608 | const dleqProof = {}; 609 | dleqProof.R = getBigNumFromB64(dleqProofM.R); 610 | dleqProof.C = getBigNumFromB64(dleqProofM.C); 611 | return dleqProof; 612 | } 613 | 614 | /** 615 | * Return a bignum from a base64-encoded string 616 | * @param {string} b64Str 617 | * @return {sjcl.bn} 618 | */ 619 | function getBigNumFromB64(b64Str) { 620 | const bits = sjcl.codec.base64.toBits(b64Str); 621 | return sjcl.bn.fromBits(bits); 622 | } 623 | 624 | /** 625 | * Return a big number from an array of bytes 626 | * @param {sjcl.codec.bytes} bytes 627 | * @return {sjcl.bn} 628 | */ 629 | export function getBigNumFromBytes(bytes) { 630 | const bits = sjcl.codec.bytes.toBits(bytes); 631 | return sjcl.bn.fromBits(bits); 632 | } 633 | 634 | /** 635 | * Return a big number from hex-encoded string 636 | * @param {string} hex hex-encoded string 637 | * @return {sjcl.bn} 638 | */ 639 | function getBigNumFromHex(hex) { 640 | return sjcl.bn.fromBits(sjcl.codec.hex.toBits(hex)); 641 | } 642 | 643 | const p256Curve = sjcl.ecc.curves.c256; 644 | const precomputedP256 = { 645 | // a=-3, but must be reduced mod p for P256; otherwise, 646 | // inverseMod function loops forever. 647 | A: p256Curve.a.fullReduce(), 648 | B: p256Curve.b, 649 | baseField: p256Curve.field, 650 | c1: p256Curve.b.mul(-1).mul(p256Curve.a.inverseMod(p256Curve.field.modulus)), 651 | c2: p256Curve.field.modulus.sub(1).cnormalize().halveM(), 652 | sqrt: p256Curve.field.modulus.add(1).cnormalize().halveM().halveM(), 653 | }; 654 | 655 | /** 656 | * Converts the number x into a byte array of length n 657 | * @param {Number} x 658 | * @param {Number} n 659 | * @return {sjcl.codec.bytes} 660 | */ 661 | function i2osp(x, n) { 662 | const bytes = []; 663 | for (let i = n - 1; i > -1; i--) { 664 | bytes[i] = x & 0xff; 665 | x = x >> 8; 666 | } 667 | 668 | if (x > 0) { 669 | throw new Error(`[privacy-pass]: number to convert (${x}) is too long for ${n} bytes.`); 670 | } 671 | return bytes; 672 | } 673 | 674 | /** 675 | * hashes bits to the base field (as described in 676 | * draft-irtf-cfrg-hash-to-curve) 677 | * @param {sjcl.bitArray} x bits of element to be translated 678 | * @param {sjcl.ecc.curve} curve elliptic curve 679 | * @param {sjcl.hash} hash hash function object 680 | * @param {string} label context label for domain separation 681 | * @return {int} integer in the base field of curve 682 | */ 683 | function h2Base(x, curve, hash, label) { 684 | const dataLen = sjcl.codec.bytes.fromBits(x).length; 685 | const h = new hash(); 686 | h.update('h2b'); 687 | h.update(label); 688 | h.update(sjcl.codec.bytes.toBits(i2osp(dataLen, 4))); 689 | h.update(x); 690 | const t = h.finalize(); 691 | const y = curve.field.fromBits(t).cnormalize(); 692 | return y; 693 | } 694 | 695 | /** 696 | * hashes bits to the chosen elliptic curve 697 | * @param {sjcl.bitArray} alpha bits to be encoded onto curve 698 | * @param {Object} ecSettings the curve settings being used by the extension 699 | * @return {sjcl.ecc.point} point on curve 700 | */ 701 | function h2Curve(alpha, ecSettings) { 702 | let point; 703 | switch (ecSettings.method) { 704 | case 'swu': 705 | point = simplifiedSWU(alpha, ecSettings.curve, ecSettings.hash, ecSettings.label); 706 | break; 707 | case 'increment': 708 | point = hashAndInc(alpha, ecSettings.hash, ecSettings.label); 709 | break; 710 | default: 711 | throw new Error( 712 | '[privacy-pass]: Incompatible curve chosen for hashing, SJCL chosen curve: ' + 713 | sjcl.ecc.curveName(ecSettings.curve), 714 | ); 715 | } 716 | return point; 717 | } 718 | 719 | /** 720 | * hashes bits onto affine curve point using simplified SWU encoding algorithm 721 | * Not constant-time due to conditional check 722 | * @param {sjcl.bitArray} alpha bits to be encoded 723 | * @param {sjcl.ecc.curve} activeCurve elliptic curve 724 | * @param {sjcl.hash} hash hash function for hashing bytes to base field 725 | * @param {String} label 726 | * @return {sjcl.ecc.point} curve point 727 | */ 728 | function simplifiedSWU(alpha, activeCurve, hash, label) { 729 | const params = getCurveParams(activeCurve); 730 | const u = h2Base(alpha, activeCurve, hash, label); 731 | const { X, Y } = computeSWUCoordinates(u, params); 732 | const point = new sjcl.ecc.point(activeCurve, X, Y); 733 | if (!point.isValid()) { 734 | throw new Error(`[privacy-pass]: Generated point is not on curve, X: ${X}, Y: ${Y}`); 735 | } 736 | return point; 737 | } 738 | 739 | /** 740 | * Compute (X,Y) coordinates from integer u 741 | * Operations taken from draft-irtf-cfrg-hash-to-curve.txt at commit 742 | * cea8485220812a5d371deda25b5eca96bd7e6c0e 743 | * @param {sjcl.bn} u integer to map 744 | * @param {Object} params curve parameters 745 | * @return {Object} curve coordinates 746 | */ 747 | function computeSWUCoordinates(u, params) { 748 | const { A, B, baseField, c1, c2, sqrt } = params; 749 | const p = baseField.modulus; 750 | const t1 = u.square().mul(-1); // steps 2-3 751 | const t2 = t1.square(); // step 4 752 | let x1 = t2.add(t1); // step 5 753 | x1 = x1.inverse(); // step 6 754 | x1 = x1.add(1); // step 7 755 | x1 = x1.mul(c1); // step 8 756 | 757 | let gx1 = x1.square().mod(p); // steps 9-12 758 | gx1 = gx1.add(A); 759 | gx1 = gx1.mul(x1); 760 | gx1 = gx1.add(B); 761 | gx1 = gx1.mod(p); 762 | 763 | const x2 = t1.mul(x1); // step 13 764 | let gx2 = x2.square().mod(p); // step 14-17 765 | gx2 = gx2.add(A); 766 | gx2 = gx2.mul(x2); 767 | gx2 = gx2.add(B); 768 | gx2 = gx2.mod(p); 769 | 770 | const e = new baseField(gx1.powermod(c2, p)).equals(new sjcl.bn(1)); // step 18 771 | const X = cmov(x2, x1, e, baseField); // step 19 772 | const gx = cmov(gx2, gx1, e, baseField); // step 20 773 | let y1 = gx.powermod(sqrt, p); // step 21 774 | // choose the positive (the smallest) root 775 | const r = c2.greaterEquals(y1); 776 | let y2 = y1.mul(-1).mod(p); 777 | const Y = cmov(y2, y1, r, baseField); 778 | return { X: X, Y: Y }; 779 | } 780 | 781 | /** 782 | * Return the parameters for the active curve 783 | * @param {sjcl.ecc.curve} curve elliptic curve 784 | * @return {p;A;B} 785 | */ 786 | function getCurveParams(curve) { 787 | let curveParams; 788 | switch (sjcl.ecc.curveName(curve)) { 789 | case 'c256': 790 | curveParams = precomputedP256; 791 | break; 792 | default: 793 | throw new Error( 794 | '[privacy-pass]: Incompatible curve chosen for H2C: ' + sjcl.ecc.curveName(curve), 795 | ); 796 | } 797 | return curveParams; 798 | } 799 | 800 | /** 801 | * DEPRECATED: Method for hashing to curve based on the principal of attempting 802 | * to hash the bytes multiple times and recover a curve point. Has non-negligble 803 | * probailistic failure conditions. 804 | * @param {sjcl.bitArray} seed 805 | * @param {sjcl.hash} hash hash function for hashing bytes to base field 806 | * @param {sjcl.bitArray} label 807 | * @return {sjcl.ecc.point} returns a curve point on the active curve 808 | */ 809 | function hashAndInc(seed, hash, label) { 810 | const h = new hash(); 811 | 812 | // Need to match the Go curve hash, so we decode the exact bytes of the 813 | // string "1.2.840.100045.3.1.7 point generation seed" instead of relying 814 | // on the utf8 codec that didn't match. 815 | const separator = label; 816 | 817 | h.update(separator); 818 | 819 | let i = 0; 820 | // Increased increments to decrease chance of failure 821 | for (i = 0; i < 20; i++) { 822 | // little endian uint32 823 | const ctr = new Uint8Array(4); 824 | // typecast hack: number -> Uint32, bitwise Uint8 825 | ctr[0] = (i >>> 0) & 0xff; 826 | const ctrBits = sjcl.codec.bytes.toBits(ctr); 827 | 828 | // H(s||ctr) 829 | h.update(seed); 830 | h.update(ctrBits); 831 | 832 | const digestBits = h.finalize(); 833 | const bytes = sjcl.codec.bytes.fromBits(digestBits); 834 | 835 | // attempt to decompress a point with a valid tag (don't need to try 836 | // 0x03 because this is just the negative version) 837 | // curve choice is implicit based on active curve parameters 838 | const point = sec1DecodeFromBytes([2].concat(bytes)); 839 | if (point !== null) { 840 | return point; 841 | } 842 | 843 | seed = digestBits; 844 | h.reset(); 845 | } 846 | 847 | throw new Error('Unable to construct point using hash and increment'); 848 | } 849 | 850 | /** 851 | * Conditional move selects x or y depending on the bit input. 852 | * @param {sjcl.bn} x is a big number 853 | * @param {sjcl.bn} y is a big number 854 | * @param {boolean} b is a bit 855 | * @param {sjcl.bn} field is the prime field used. 856 | * @return {sjcl.bn} returns x is b=0, otherwise return y. 857 | */ 858 | function cmov(x, y, b, field) { 859 | let z = new field(); 860 | const m = z.radixMask; 861 | const m0 = m & (m + b); 862 | const m1 = m & (m + !b); 863 | x.fullReduce(); 864 | y.fullReduce(); 865 | for (let i = Math.max(x.limbs.length, y.limbs.length) - 1; i >= 0; i--) { 866 | z.limbs.unshift((x.getLimb(i) & m0) ^ (y.getLimb(i) & m1)); 867 | } 868 | return z.mod(field.modulus); 869 | } 870 | 871 | export function newBigNum(s) { 872 | return new sjcl.bn(s); 873 | } 874 | 875 | /** 876 | * Derives the shared key used for redemption MACs 877 | * @param {sjcl.ecc.point} N Signed curve point associated with token 878 | * @param {Object} token client-generated token data 879 | * @return {sjcl.codec.bytes} bytes of derived key 880 | */ 881 | export function deriveKey(N, token) { 882 | // the exact bits of the string "hash_derive_key" 883 | const tagBits = sjcl.codec.hex.toBits('686173685f6465726976655f6b6579'); 884 | const hash = getActiveECSettings().hash; 885 | const h = new sjcl.misc.hmac(tagBits, hash); 886 | 887 | // Always compute derived key using uncompressed point bytes 888 | const encodedPoint = sec1Encode(N, false); 889 | const tokenBits = sjcl.codec.bytes.toBits(token); 890 | const pointBits = sjcl.codec.bytes.toBits(encodedPoint); 891 | 892 | h.update(tokenBits); 893 | h.update(pointBits); 894 | 895 | const keyBytes = sjcl.codec.bytes.fromBits(h.digest()); 896 | return keyBytes; 897 | } 898 | 899 | export function getBytesFromString(str) { 900 | const bits = sjcl.codec.utf8String.toBits(str); 901 | const bytes = sjcl.codec.bytes.fromBits(bits); 902 | return bytes; 903 | } 904 | 905 | export function getBase64FromBytes(bytes) { 906 | const bits = sjcl.codec.bytes.toBits(bytes); 907 | const encoded = sjcl.codec.base64.fromBits(bits); 908 | return encoded; 909 | } 910 | 911 | export function getBase64FromString(str) { 912 | const bits = sjcl.codec.utf8String.toBits(str); 913 | const encoded = sjcl.codec.base64.fromBits(bits); 914 | return encoded; 915 | } 916 | 917 | export function createRequestBinding(key, data) { 918 | // the exact bits of the string "hash_request_binding" 919 | const tagBits = sjcl.codec.utf8String.toBits('hash_request_binding'); 920 | const keyBits = sjcl.codec.bytes.toBits(key); 921 | const hash = getActiveECSettings().hash; 922 | 923 | const h = new sjcl.misc.hmac(keyBits, hash); 924 | h.update(tagBits); 925 | 926 | let dataBits = null; 927 | for (let i = 0; i < data.length; i++) { 928 | dataBits = sjcl.codec.bytes.toBits(data[i]); 929 | h.update(dataBits); 930 | } 931 | 932 | return sjcl.codec.base64.fromBits(h.digest()); 933 | } 934 | -------------------------------------------------------------------------------- /src/background/voprf.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultECSettings, initECSettings, newRandomPoint } from './voprf'; 2 | 3 | test('randomPoint', () => { 4 | initECSettings(defaultECSettings); 5 | const P = newRandomPoint(); 6 | expect(P).toBeDefined(); 7 | }); 8 | -------------------------------------------------------------------------------- /src/blindrsa/blindrsa.test.ts: -------------------------------------------------------------------------------- 1 | import * as blindRSA from './index'; 2 | 3 | import { jest } from '@jest/globals'; 4 | import sjcl from './sjcl'; 5 | // Test vector 6 | // https://www.ietf.org/archive/id/draft-irtf-cfrg-rsa-blind-signatures-03.html#appendix-A 7 | import vectors from './testdata/rsablind_vectors.json'; 8 | 9 | function hexToB64URL(x: string): string { 10 | return sjcl.codec.base64url.fromBits(sjcl.codec.hex.toBits(x)); 11 | } 12 | 13 | function hexToUint8(x: string): Uint8Array { 14 | return new Uint8Array(sjcl.codec.bytes.fromBits(sjcl.codec.hex.toBits(x))); 15 | } 16 | 17 | type Vectors = typeof vectors[number]; 18 | 19 | function paramsFromVector(v: Vectors): { 20 | n: string; 21 | e: string; 22 | d: string; 23 | p: string; 24 | q: string; 25 | dp: string; 26 | dq: string; 27 | qi: string; 28 | } { 29 | const n = hexToB64URL(v.n); 30 | const e = hexToB64URL(v.e); 31 | const d = hexToB64URL(v.d); 32 | const p = hexToB64URL(v.p); 33 | const q = hexToB64URL(v.q); 34 | 35 | // Calculate CRT values 36 | const bnD = new sjcl.bn(v.d); 37 | const bnP = new sjcl.bn(v.p); 38 | const bnQ = new sjcl.bn(v.q); 39 | const one = new sjcl.bn(1); 40 | const dp = hexToB64URL(bnD.mod(bnP.sub(one)).toString()); 41 | const dq = hexToB64URL(bnD.mod(bnQ.sub(one)).toString()); 42 | const qi = hexToB64URL(bnQ.inverseMod(bnP).toString()); 43 | return { n, e, d, p, q, dp, dq, qi }; 44 | } 45 | 46 | async function keysFromVector(v: Vectors, extractable: boolean): Promise { 47 | const params = paramsFromVector(v); 48 | const { n, e } = params; 49 | const publicKey = await crypto.subtle.importKey( 50 | 'jwk', 51 | { kty: 'RSA', ext: true, n, e }, 52 | { name: 'RSA-PSS', hash: 'SHA-384' }, 53 | extractable, 54 | ['verify'], 55 | ); 56 | 57 | const privateKey = await crypto.subtle.importKey( 58 | 'jwk', 59 | { kty: 'RSA', ext: true, ...params }, 60 | { name: 'RSA-PSS', hash: 'SHA-384' }, 61 | extractable, 62 | ['sign'], 63 | ); 64 | return { privateKey, publicKey }; 65 | } 66 | 67 | describe.each(vectors)('BlindRSA-vec$#', (v: Vectors) => { 68 | test('test-vector', async () => { 69 | const r_inv = new sjcl.bn(v.inv); 70 | const r = r_inv.inverseMod(new sjcl.bn(v.n)); 71 | const r_bytes = hexToUint8(r.toString().slice(2)); 72 | 73 | const { privateKey, publicKey } = await keysFromVector(v, true); 74 | const msg = hexToUint8(v.msg); 75 | const saltLength = v.salt.length / 2; 76 | 77 | // Mock for randomized blind operation. 78 | jest.spyOn(crypto, 'getRandomValues') 79 | .mockReturnValueOnce(hexToUint8(v.salt)) // mock for random salt 80 | .mockReturnValueOnce(r_bytes); // mock for random blind 81 | 82 | const { blindedMsg, blindInv } = await blindRSA.blind(publicKey, msg, saltLength); 83 | expect(blindedMsg).toStrictEqual(hexToUint8(v.blinded_msg)); 84 | expect(blindInv).toStrictEqual(hexToUint8(v.inv)); 85 | 86 | const blindedSig = await blindRSA.blindSign(privateKey, blindedMsg); 87 | expect(blindedSig).toStrictEqual(hexToUint8(v.blind_sig)); 88 | 89 | const signature = await blindRSA.finalize(publicKey, msg, blindInv, blindedSig, saltLength); 90 | expect(signature).toStrictEqual(hexToUint8(v.sig)); 91 | }); 92 | 93 | test('non-extractable-keys', async () => { 94 | const { privateKey, publicKey } = await keysFromVector(v, false); 95 | const msg = crypto.getRandomValues(new Uint8Array(10)); 96 | const blindedMsg = crypto.getRandomValues(new Uint8Array(32)); 97 | const blindInv = crypto.getRandomValues(new Uint8Array(32)); 98 | const blindedSig = crypto.getRandomValues(new Uint8Array(32)); 99 | const errorMsg = 'key is not extractable'; 100 | 101 | await expect(blindRSA.blind(publicKey, msg, 32)).rejects.toThrow(errorMsg); 102 | await expect(blindRSA.blindSign(privateKey, blindedMsg)).rejects.toThrow(errorMsg); 103 | await expect(blindRSA.finalize(publicKey, msg, blindInv, blindedSig, 32)).rejects.toThrow( 104 | errorMsg, 105 | ); 106 | }); 107 | 108 | test('wrong-key-type', async () => { 109 | const { privateKey, publicKey } = await crypto.subtle.generateKey( 110 | { 111 | name: 'RSASSA-PKCS1-v1_5', // not RSA-PSS. 112 | modulusLength: 2048, 113 | publicExponent: Uint8Array.from([0x01, 0x00, 0x01]), 114 | hash: 'SHA-256', 115 | }, 116 | true, 117 | ['sign', 'verify'], 118 | ); 119 | 120 | const msg = crypto.getRandomValues(new Uint8Array(10)); 121 | const blindedMsg = crypto.getRandomValues(new Uint8Array(32)); 122 | const blindInv = crypto.getRandomValues(new Uint8Array(32)); 123 | const blindedSig = crypto.getRandomValues(new Uint8Array(32)); 124 | const errorMsg = 'key is not RSA-PSS'; 125 | 126 | await expect(blindRSA.blind(publicKey, msg, 32)).rejects.toThrow(errorMsg); 127 | await expect(blindRSA.blindSign(privateKey, blindedMsg)).rejects.toThrow(errorMsg); 128 | await expect(blindRSA.finalize(publicKey, msg, blindInv, blindedSig, 32)).rejects.toThrow( 129 | errorMsg, 130 | ); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /src/blindrsa/blindrsa.ts: -------------------------------------------------------------------------------- 1 | import { emsa_pss_encode, i2osp, os2ip, rsasp1, rsavp1 } from './util'; 2 | 3 | import sjcl from './sjcl'; 4 | 5 | export async function blind( 6 | publicKey: CryptoKey, 7 | msg: Uint8Array, 8 | saltLength = 0, 9 | ): Promise<{ 10 | blindedMsg: Uint8Array; 11 | blindInv: Uint8Array; 12 | }> { 13 | if (publicKey.type !== 'public' || publicKey.algorithm.name !== 'RSA-PSS') { 14 | throw new Error('key is not RSA-PSS'); 15 | } 16 | if (!publicKey.extractable) { 17 | throw new Error('key is not extractable'); 18 | } 19 | 20 | const { modulusLength, hash: hashFn } = publicKey.algorithm as RsaHashedKeyGenParams; 21 | const kBits = modulusLength; 22 | const kLen = Math.ceil(kBits / 8); 23 | const hash = (hashFn as Algorithm).name; 24 | 25 | // 1. encoded_msg = EMSA-PSS-ENCODE(msg, kBits - 1) 26 | // with MGF and HF as defined in the parameters 27 | // 2. If EMSA-PSS-ENCODE raises an error, raise the error and stop 28 | const encoded_msg = await emsa_pss_encode(msg, kBits - 1, { sLen: saltLength, hash }); 29 | 30 | // 3. m = bytes_to_int(encoded_msg) 31 | const m = os2ip(encoded_msg); 32 | const jwkKey = await crypto.subtle.exportKey('jwk', publicKey); 33 | if (!jwkKey.n || !jwkKey.e) { 34 | throw new Error('key has invalid parameters'); 35 | } 36 | const n = sjcl.bn.fromBits(sjcl.codec.base64url.toBits(jwkKey.n)); 37 | const e = sjcl.bn.fromBits(sjcl.codec.base64url.toBits(jwkKey.e)); 38 | 39 | // 4. r = random_integer_uniform(1, n) 40 | let r: sjcl.bn; 41 | do { 42 | r = os2ip(crypto.getRandomValues(new Uint8Array(kLen))); 43 | } while (r.greaterEquals(n)); 44 | 45 | // 5. r_inv = inverse_mod(r, n) 46 | // 6. If inverse_mod fails, raise an "invalid blind" error 47 | // and stop 48 | let r_inv: sjcl.bn; 49 | try { 50 | r_inv = r.inverseMod(n); 51 | } catch (e) { 52 | throw new Error('invalid blind'); 53 | } 54 | // 7. x = RSAVP1(pkS, r) 55 | const x = rsavp1({ n, e }, r); 56 | 57 | // 8. z = m * x mod n 58 | const z = m.mulmod(x, n); 59 | 60 | // 9. blinded_msg = int_to_bytes(z, kLen) 61 | const blindedMsg = i2osp(z, kLen); 62 | 63 | // 10. inv = int_to_bytes(r_inv, kLen) 64 | const blindInv = i2osp(r_inv, kLen); 65 | 66 | // 11. output blinded_msg, inv 67 | return { blindedMsg, blindInv }; 68 | } 69 | 70 | export async function finalize( 71 | publicKey: CryptoKey, 72 | msg: Uint8Array, 73 | blindInv: Uint8Array, 74 | blindSig: Uint8Array, 75 | saltLength = 0, 76 | ): Promise { 77 | if (publicKey.type !== 'public' || publicKey.algorithm.name !== 'RSA-PSS') { 78 | throw new Error('key is not RSA-PSS'); 79 | } 80 | if (!publicKey.extractable) { 81 | throw new Error('key is not extractable'); 82 | } 83 | const { modulusLength } = publicKey.algorithm as RsaHashedKeyGenParams; 84 | const kLen = Math.ceil(modulusLength / 8); 85 | 86 | // 1. If len(blind_sig) != kLen, raise "unexpected input size" and stop 87 | // 2. If len(inv) != kLen, raise "unexpected input size" and stop 88 | if (blindSig.length != kLen || blindInv.length != kLen) { 89 | throw new Error('unexpected input size'); 90 | } 91 | 92 | // 3. z = bytes_to_int(blind_sig) 93 | const z = os2ip(blindSig); 94 | 95 | // 4. r_inv = bytes_to_int(inv) 96 | const r_inv = os2ip(blindInv); 97 | 98 | // 5. s = z * r_inv mod n 99 | const jwkKey = await crypto.subtle.exportKey('jwk', publicKey); 100 | if (!jwkKey.n) { 101 | throw new Error('key has invalid parameters'); 102 | } 103 | const n = sjcl.bn.fromBits(sjcl.codec.base64url.toBits(jwkKey.n)); 104 | const s = z.mulmod(r_inv, n); 105 | 106 | // 6. sig = int_to_bytes(s, kLen) 107 | const sig = i2osp(s, kLen); 108 | 109 | // 7. result = RSASSA-PSS-VERIFY(pkS, msg, sig) 110 | // 8. If result = "valid signature", output sig, else 111 | // raise "invalid signature" and stop 112 | const algorithm = { name: 'RSA-PSS', saltLength }; 113 | if (!(await crypto.subtle.verify(algorithm, publicKey, sig, msg))) { 114 | throw new Error('invalid signature'); 115 | } 116 | 117 | return sig; 118 | } 119 | 120 | export async function blindSign(privateKey: CryptoKey, blindMsg: Uint8Array): Promise { 121 | if (privateKey.type !== 'private' || privateKey.algorithm.name !== 'RSA-PSS') { 122 | throw new Error('key is not RSA-PSS'); 123 | } 124 | if (!privateKey.extractable) { 125 | throw new Error('key is not extractable'); 126 | } 127 | const { modulusLength } = privateKey.algorithm as RsaHashedKeyGenParams; 128 | const kLen = Math.ceil(modulusLength / 8); 129 | 130 | // 1. If len(blinded_msg) != kLen, raise "unexpected input size" 131 | // and stop 132 | if (blindMsg.length != kLen) { 133 | throw new Error('unexpected input size'); 134 | } 135 | 136 | // 2. m = bytes_to_int(blinded_msg) 137 | const m = os2ip(blindMsg); 138 | 139 | // 3. If m >= n, raise "invalid message length" and stop 140 | const jwkKey = await crypto.subtle.exportKey('jwk', privateKey); 141 | if (!jwkKey.n || !jwkKey.d) { 142 | throw new Error('key is not a private key'); 143 | } 144 | const n = sjcl.bn.fromBits(sjcl.codec.base64url.toBits(jwkKey.n)); 145 | const d = sjcl.bn.fromBits(sjcl.codec.base64url.toBits(jwkKey.d)); 146 | if (m.greaterEquals(n)) { 147 | throw new Error('invalid message length'); 148 | } 149 | 150 | // 4. s = RSASP1(skS, m) 151 | const s = rsasp1({ n, d }, m); 152 | 153 | // 5. blind_sig = int_to_bytes(s, kLen) 154 | // 6. output blind_sig 155 | return i2osp(s, kLen); 156 | } 157 | -------------------------------------------------------------------------------- /src/blindrsa/index.ts: -------------------------------------------------------------------------------- 1 | export { blind, blindSign, finalize } from './blindrsa'; 2 | -------------------------------------------------------------------------------- /src/blindrsa/jsonModules.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.json'; 2 | -------------------------------------------------------------------------------- /src/blindrsa/sjcl.Makefile: -------------------------------------------------------------------------------- 1 | SJCL_PATH=node_modules/sjcl 2 | 3 | all: 4 | cd ${SJCL_PATH} && \ 5 | ./configure --without-all --with-ecc --with-convenience --compress=none \ 6 | --with-codecBytes --with-codecHex --with-codecArrayBuffer && \ 7 | make 8 | npm i -D dts-gen 9 | npx dts-gen -m sjcl -o -f ./src/sjcl/index 10 | npm un -D dts-gen 11 | echo "export default sjcl;" >> ${SJCL_PATH}/sjcl.js 12 | cp ${SJCL_PATH}/sjcl.js ./src/sjcl/index.js 13 | patch src/sjcl/index.d.ts sjcl.point.patch 14 | 15 | clean: 16 | rm -f src/sjcl/index.js src/sjcl/index.d.ts 17 | -------------------------------------------------------------------------------- /src/blindrsa/sjcl/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "declarationMap": false, 6 | "composite": false, 7 | "sourceMap": false, 8 | "allowJs": true 9 | }, 10 | "files": [ 11 | "index.js" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/blindrsa/testdata/emsa_pss_vectors.json: -------------------------------------------------------------------------------- 1 | { 2 | "msg": "859eef2fd78aca00308bdc471193bf55bf9d78db8f8a672b484634f3c9c26e6478ae10260fe0dd8c082e53a5293af2173cd50c6d5d354febf78b26021c25c02712e78cd4694c9f469777e451e7f8e9e04cd3739c6bbfedae487fb55644e9ca74ff77a53cb729802f6ed4a5ffa8ba159890fc", 3 | "salt": "e3b5d5d002c1bce50c2b65ef88a188d83bce7e61", 4 | "expected": "66e4672e836ad121ba244bed6576b867d9a447c28a6e66a5b87dee7fbc7e65af5057f86fae8984d9ba7f969ad6fe02a4d75f7445fefdd85b6d3a477c28d24ba1e3756f792dd1dce8ca94440ecb5279ecd3183a311fc896da1cb39311af37ea4a75e24bdbfd5c1da0de7cecdf1a896f9d8bc816d97cd7a2c43bad546fbe8cfebc" 5 | } 6 | -------------------------------------------------------------------------------- /src/blindrsa/testdata/rsablind_vectors.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "p": "e1f4d7a34802e27c7392a3cea32a262a34dc3691bd87f3f310dc75673488930559c120fd0410194fb8a0da55bd0b81227e843fdca6692ae80e5a5d414116d4803fca7d8c30eaaae57e44a1816ebb5c5b0606c536246c7f11985d731684150b63c9a3ad9e41b04c0b5b27cb188a692c84696b742a80d3cd00ab891f2457443dadfeba6d6daf108602be26d7071803c67105a5426838e6889d77e8474b29244cefaf418e381b312048b457d73419213063c60ee7b0d81820165864fef93523c9635c22210956e53a8d96322493ffc58d845368e2416e078e5bcb5d2fd68ae6acfa54f9627c42e84a9d3f2774017e32ebca06308a12ecc290c7cd1156dcccfb2311", 4 | "q": "c601a9caea66dc3835827b539db9df6f6f5ae77244692780cd334a006ab353c806426b60718c05245650821d39445d3ab591ed10a7339f15d83fe13f6a3dfb20b9452c6a9b42eaa62a68c970df3cadb2139f804ad8223d56108dfde30ba7d367e9b0a7a80c4fdba2fd9dde6661fc73fc2947569d2029f2870fc02d8325acf28c9afa19ecf962daa7916e21afad09eb62fe9f1cf91b77dc879b7974b490d3ebd2e95426057f35d0a3c9f45f79ac727ab81a519a8b9285932d9b2e5ccd347e59f3f32ad9ca359115e7da008ab7406707bd0e8e185a5ed8758b5ba266e8828f8d863ae133846304a2936ad7bc7c9803879d2fc4a28e69291d73dbd799f8bc238385", 5 | "n": "aec4d69addc70b990ea66a5e70603b6fee27aafebd08f2d94cbe1250c556e047a928d635c3f45ee9b66d1bc628a03bac9b7c3f416fe20dabea8f3d7b4bbf7f963be335d2328d67e6c13ee4a8f955e05a3283720d3e1f139c38e43e0338ad058a9495c53377fc35be64d208f89b4aa721bf7f7d3fef837be2a80e0f8adf0bcd1eec5bb040443a2b2792fdca522a7472aed74f31a1ebe1eebc1f408660a0543dfe2a850f106a617ec6685573702eaaa21a5640a5dcaf9b74e397fa3af18a2f1b7c03ba91a6336158de420d63188ee143866ee415735d155b7c2d854d795b7bc236cffd71542df34234221a0413e142d8c61355cc44d45bda94204974557ac2704cd8b593f035a5724b1adf442e78c542cd4414fce6f1298182fb6d8e53cef1adfd2e90e1e4deec52999bdc6c29144e8d52a125232c8c6d75c706ea3cc06841c7bda33568c63a6c03817f722b50fcf898237d788a4400869e44d90a3020923dc646388abcc914315215fcd1bae11b1c751fd52443aac8f601087d8d42737c18a3fa11ecd4131ecae017ae0a14acfc4ef85b83c19fed33cfd1cd629da2c4c09e222b398e18d822f77bb378dea3cb360b605e5aa58b20edc29d000a66bd177c682a17e7eb12a63ef7c2e4183e0d898f3d6bf567ba8ae84f84f1d23bf8b8e261c3729e2fa6d07b832e07cddd1d14f55325c6f924267957121902dc19b3b32948bdead5", 6 | "e": "010001", 7 | "d": "0d43242aefe1fb2c13fbc66e20b678c4336d20b1808c558b6e62ad16a287077180b177e1f01b12f9c6cd6c52630257ccef26a45135a990928773f3bd2fc01a313f1dac97a51cec71cb1fd7efc7adffdeb05f1fb04812c924ed7f4a8269925dad88bd7dcfbc4ef01020ebfc60cb3e04c54f981fdbd273e69a8a58b8ceb7c2d83fbcbd6f784d052201b88a9848186f2a45c0d2826870733e6fd9aa46983e0a6e82e35ca20a439c5ee7b502a9062e1066493bdadf8b49eb30d9558ed85abc7afb29b3c9bc644199654a4676681af4babcea4e6f71fe4565c9c1b85d9985b84ec1abf1a820a9bbebee0df1398aae2c85ab580a9f13e7743afd3108eb32100b870648fa6bc17e8abac4d3c99246b1f0ea9f7f93a5dd5458c56d9f3f81ff2216b3c3680a13591673c43194d8e6fc93fc1e37ce2986bd628ac48088bc723d8fbe293861ca7a9f4a73e9fa63b1b6d0074f5dea2a624c5249ff3ad811b6255b299d6bc5451ba7477f19c5a0db690c3e6476398b1483d10314afd38bbaf6e2fbdbcd62c3ca9797a420ca6034ec0a83360a3ee2adf4b9d4ba29731d131b099a38d6a23cc463db754603211260e99d19affc902c915d7854554aabf608e3ac52c19b8aa26ae042249b17b2d29669b5c859103ee53ef9bdc73ba3c6b537d5c34b6d8f034671d7f3a8a6966cc4543df223565343154140fd7391c7e7be03e241f4ecfeb877a051", 8 | "msg": "8f3dc6fb8c4a02f4d6352edf0907822c1210a9b32f9bdda4c45a698c80023aa6b59f8cfec5fdbb36331372ebefedae7d", 9 | "salt": "051722b35f458781397c3a671a7d3bd3096503940e4c4f1aaa269d60300ce449555cd7340100df9d46944c5356825abf", 10 | "inv": "80682c48982407b489d53d1261b19ec8627d02b8cda5336750b8cee332ae260de57b02d72609c1e0e9f28e2040fc65b6f02d56dbd6aa9af8fde656f70495dfb723ba01173d4707a12fddac628ca29f3e32340bd8f7ddb557cf819f6b01e445ad96f874ba235584ee71f6581f62d4f43bf03f910f6510deb85e8ef06c7f09d9794a008be7ff2529f0ebb69decef646387dc767b74939265fec0223aa6d84d2a8a1cc912d5ca25b4e144ab8f6ba054b54910176d5737a2cff011da431bd5f2a0d2d66b9e70b39f4b050e45c0d9c16f02deda9ddf2d00f3e4b01037d7029cd49c2d46a8e1fc2c0c17520af1f4b5e25ba396afc4cd60c494a4c426448b35b49635b337cfb08e7c22a39b256dd032c00adddafb51a627f99a0e1704170ac1f1912e49d9db10ec04c19c58f420212973e0cb329524223a6aa56c7937c5dffdb5d966b6cd4cbc26f3201dd25c80960a1a111b32947bb78973d269fac7f5186530930ed19f68507540eed9e1bab8b00f00d8ca09b3f099aae46180e04e3584bd7ca054df18a1504b89d1d1675d0966c4ae1407be325cdf623cf13ff13e4a28b594d59e3eadbadf6136eee7a59d6a444c9eb4e2198e8a974f27a39eb63af2c9af3870488b8adaad444674f512133ad80b9220e09158521614f1faadfe8505ef57b7df6813048603f0dd04f4280177a11380fbfc861dbcbd7418d62155248dad5fdec0991f", 11 | "encoded_msg": "6e0c464d9c2f9fbc147b43570fc4f238e0d0b38870b3addcf7a4217df912ccef17a7f629aa850f63a063925f312d61d6437be954b45025e8282f9c0b1131bc8ff19a8a928d859b37113db1064f92a27f64761c181c1e1f9b251ae5a2f8a4047573b67a270584e089beadcb13e7c82337797119712e9b849ff56e04385d144d3ca9d8d92bf78adb20b5bbeb3685f17038ec6afade3ef354429c51c687b45a7018ee3a6966b3af15c9ba8f40e6461ba0a17ef5a799672ad882bab02b518f9da7c1a962945c2e9b0f02f29b31b9cdf3e633f9d9d2a22e96e1de28e25241ca7dd04147112f578973403e0f4fd80865965475d22294f065e17a1c4a201de93bd14223e6b1b999fd548f2f759f52db71964528b6f15b9c2d7811f2a0a35d534b8216301c47f4f04f412cae142b48c4cdff78bc54df690fd43142d750c671dd8e2e938e6a440b2f825b6dbb3e19f1d7a3c0150428a47948037c322365b7fe6fe57ac88d8f80889e9ff38177bad8c8d8d98db42908b389cb59692a58ce275aa15acb032ca951b3e0a3404b7f33f655b7c7d83a2f8d1b6bbff49d5fcedf2e030e80881aa436db27a5c0dea13f32e7d460dbf01240c2320c2bb5b3225b17145c72d61d47c8f84d1e19417ebd8ce3638a82d395cc6f7050b6209d9283dc7b93fecc04f3f9e7f566829ac41568ef799480c733c09759aa9734e2013d7640dc6151018ea902bc", 12 | "blinded_msg": "10c166c6a711e81c46f45b18e5873cc4f494f003180dd7f115585d871a28930259654fe28a54dab319cc5011204c8373b50a57b0fdc7a678bd74c523259dfe4fd5ea9f52f170e19dfa332930ad1609fc8a00902d725cfe50685c95e5b2968c9a2828a21207fcf393d15f849769e2af34ac4259d91dfd98c3a707c509e1af55647efaa31290ddf48e0133b798562af5eabd327270ac2fb6c594734ce339a14ea4fe1b9a2f81c0bc230ca523bda17ff42a377266bc2778a274c0ae5ec5a8cbbe364fcf0d2403f7ee178d77ff28b67a20c7ceec009182dbcaa9bc99b51ebbf13b7d542be337172c6474f2cd3561219fe0dfa3fb207cff89632091ab841cf38d8aa88af6891539f263adb8eac6402c41b6ebd72984e43666e537f5f5fe27b2b5aa114957e9a580730308a5f5a9c63a1eb599f093ab401d0c6003a451931b6d124180305705845060ebba6b0036154fcef3e5e9f9e4b87e8f084542fd1dd67e7782a5585150181c01eb6d90cb95883837384a5b91dbb606f266059ecc51b5acbaa280e45cfd2eec8cc1cdb1b7211c8e14805ba683f9b78824b2eb005bc8a7d7179a36c152cb87c8219e5569bba911bb32a1b923ca83de0e03fb10fba75d85c55907dda5a2606bf918b056c3808ba496a4d95532212040a5f44f37e1097f26dc27b98a51837daa78f23e532156296b64352669c94a8a855acf30533d8e0594ace7c442", 13 | "blind_sig": "364f6a40dbfbc3bbb257943337eeff791a0f290898a6791283bba581d9eac90a6376a837241f5f73a78a5c6746e1306ba3adab6067c32ff69115734ce014d354e2f259d4cbfb890244fd451a497fe6ecf9aa90d19a2d441162f7eaa7ce3fc4e89fd4e76b7ae585be2a2c0fd6fb246b8ac8d58bcb585634e30c9168a434786fe5e0b74bfe8187b47ac091aa571ffea0a864cb906d0e28c77a00e8cd8f6aba4317a8cc7bf32ce566bd1ef80c64de041728abe087bee6cadd0b7062bde5ceef308a23bd1ccc154fd0c3a26110df6193464fc0d24ee189aea8979d722170ba945fdcce9b1b4b63349980f3a92dc2e5418c54d38a862916926b3f9ca270a8cf40dfb9772bfbdd9a3e0e0892369c18249211ba857f35963d0e05d8da98f1aa0c6bba58f47487b8f663e395091275f82941830b050b260e4767ce2fa903e75ff8970c98bfb3a08d6db91ab1746c86420ee2e909bf681cac173697135983c3594b2def673736220452fde4ddec867d40ff42dd3da36c84e3e52508b891a00f50b4f62d112edb3b6b6cc3dbd546ba10f36b03f06c0d82aeec3b25e127af545fac28e1613a0517a6095ad18a98ab79f68801e05c175e15bae21f821e80c80ab4fdec6fb34ca315e194502b8f3dcf7892b511aee45060e3994cd15e003861bc7220a2babd7b40eda03382548a34a7110f9b1779bf3ef6011361611e6bc5c0dc851e1509de1a", 14 | "sig": "6fef8bf9bc182cd8cf7ce45c7dcf0e6f3e518ae48f06f3c670c649ac737a8b8119a34d51641785be151a697ed7825fdfece82865123445eab03eb4bb91cecf4d6951738495f8481151b62de869658573df4e50a95c17c31b52e154ae26a04067d5ecdc1592c287550bb982a5bb9c30fd53a768cee6baabb3d483e9f1e2da954c7f4cf492fe3944d2fe456c1ecaf0840369e33fb4010e6b44bb1d721840513524d8e9a3519f40d1b81ae34fb7a31ee6b7ed641cb16c2ac999004c2191de0201457523f5a4700dd649267d9286f5c1d193f1454c9f868a57816bf5ff76c838a2eeb616a3fc9976f65d4371deecfbab29362caebdff69c635fe5a2113da4d4d8c24f0b16a0584fa05e80e607c5d9a2f765f1f069f8d4da21f27c2a3b5c984b4ab24899bef46c6d9323df4862fe51ce300fca40fb539c3bb7fe2dcc9409e425f2d3b95e70e9c49c5feb6ecc9d43442c33d50003ee936845892fb8be475647da9a080f5bc7f8a716590b3745c2209fe05b17992830ce15f32c7b22cde755c8a2fe50bd814a0434130b807dc1b7218d4e85342d70695a5d7f29306f25623ad1e8aa08ef71b54b8ee447b5f64e73d09bdd6c3b7ca224058d7c67cc7551e9241688ada12d859cb7646fbd3ed8b34312f3b49d69802f0eaa11bc4211c2f7a29cd5c01ed01a39001c5856fab36228f5ee2f2e1110811872fe7c865c42ed59029c706195d52" 15 | }, 16 | { 17 | "p": "ca9d82e9059fa3b145da850e0c451ff31093d819644ba29a3409393de2adfa1bcd65e8669a5c5140142c1404204edbc380d4e7a5c866c06bb2427c76b9e3d16bbfc1b1668dec219b8c59fee90b7baf557fc2feb13f2f4b30d8606d20b9928f4f588a3b34baa659b3bd1dd590c83e90e6251b5239fbbb73b12e90534a375e3f71", 18 | "q": "c075694f69db6a07456e19eeace01b430f2d6cc6cd5495d569e242b6f5e8ded7df27e6aeea4db4e307554fb519b68279a58d9e2d25cee4b37668554eec2f2feb79246955a07bd526f02a6afedc7a3aff2b8953287fef2c4a02207ccb9f14e4612e9af3447dd3401728a8957871b759b6bbf22aa0e8271b82f32dd5a2d2550197", 19 | "n": "98530f850dcc894d84ecfce9dec3a475bf30ec3ce4606f677ac4a6ef63f763ff64a162ef1c991d8094b5652d0d78c126b3e97d1d77eba2f833b5be9a124e003065ec2a3ea4fbc31bc283de1c7cd8a971eb57aa7284b082562ccde572b73702068a6143e6dabf886538ff419874c300a85f3d9d50f0731fc6b9c92a121fefb7911f5ea92d25b17a4f3b2883eff34a221b5c28c488e35067a8460d8fab1c405704ebfa1ca165d69cd4e425995a03a447f6cbba5d20d459707ab4a2c537a5dbd02801d7b19a03aaa9aec21d1c363996c6b9fee2cab370d501c9b67e7dc4a20eb0cdc3b24be242093b5a66119b96da0fb0ec0b1b0da0bd0b92236ece47d5c95bdca7", 20 | "e": "010001", 21 | "d": "6b15d18e4f8220709fe75f7226ca517ef9b7320d28dc66d54fa89a5727670f24c7a0f1857a0c6682338946a4a298e6e90788390e137553afbbe2a4297a7edd8128d61b68c8e1b96b7596f0fa0406e9308e2ba64735e344edc237c97b993411b7796721ae54d05bda1574d5af913e59e30479b373e86676cb6566f7ada0480d3ae21d50ac94c0b41c476e566d6bcdef88eeab3042ef1016527558e794b6029cff1120596fe2104fac928a66ad2fb1094d1ae1231abf95206cae7cd4e7aad388199d7ac1fe17e3f917436232cffe70e12056e02cfb9604e73cc34984bb83f7112ed197bf3a4d9f6d0c0e3c4dd8f2d9cbe17185f1e63561b08f7d14bd36112f3ea1", 22 | "msg": "5465737420766563746f7220776974682064657465726d696e69737469632070616464696e67", 23 | "salt": "", 24 | "inv": "6e69972553327ee6240ce0de7146aea2243927cf9f7f52c0103367df79e3bafebfa61c2ffdc41ea397a38523654a1a806f4eebcd5fe9a2592a463f1faa26c3601f83f29141eda488f14f7c0aa82faa025e37adbe77e02e575f72f7b9d095882923476f2328dfaeb23b607d2f706c6c8ef6c2aee50ddb14e6d27e043e7dec8e5dede6844aa80b2206b6019350d37925bb8819653aa7a13bfb9cc3c95b53378f278903b5c06a10c0b3ce0aa028e9600f7b2733f0278565f9b88e9d92e039db78300170d7bbd32ce2b89ad8944167839880e3a2aeba05bf00edc8032a63e6279bf42a131ccc9bb95b8693764b27665274fb673bdfb7d69b7957ee8b64a99efbeed9", 25 | "encoded_msg": "4021ac68705782fb7587bf24ac0528853025aa4a998db7b1a503afb5b266cbd1876710a2b0aa6e37b70fca538d42285beddd61d965c02b2162c86445873bdaf687a29bf6b2ab10fa22013cae53ff1c78969ef6c3eb069bfef339a5df788044d159678e571e50fc3fa40a30fe183348453542f258c663dc9c4b372895120ad12ff8b8ec1d37d766b2604fbf50bf9d84432a59593d21d7f379d6bf9198ea2fa90ee5abadb27eada5d6f40a2ec45aa4bb8710042beab5c6afb4381fc57012e61b3a815800e53e69fe2fdccb3b4ee51968c1ef6281d7e8fe08c4337bad73d99e947df834e5402378a66142bf032dfade7e6e2d43ae90b145055861e06eff189b63bc", 26 | "blinded_msg": "5a631b41e7759a634cef04359436e358143ee2892fbebd072d1e5cc45475ff55b6b492e13c59979f4b968994ddca3cc068084d3b176a6132039c584707acbb9862c009fa5b63cfb7b6f6d577825c1e81ad11059cb87a524083230f906ea0a4d9db3434d49cf9f0ea52b2425db4d319f51540e5de6cfb30b86d5e5d810a284f3478f6259f054407c854303ec23c2e0989dd57aa002e56ab6287594c25154a1646060cb4f6479b07f627991f7089ac0491d5841d6461166b324b3d448b2a8071de68505503feadf7d8182d18d8b0d3b91d77b627a5ffae68f913efbbb2fc082437f845880f94f07d873bc0c0688f60033235bcc1701dcba83dca944b05227884e3", 27 | "blind_sig": "817596a0b568088b60c29482c0178d34e0d54dc34a9375152701e4e6d5ef76c92f4281a377d8b2f438f6af4ef9c26dd38ad2cc932f90fe45d4c0a1ba10e695a1c8e797aa5023f84385904e5f378df5677b8eb7312f835f9e3a097b1b7e55fece0d00ec3f52ba26b39c91322b6404eef4e567d909195bfc0f72690805ea3f71736d7eb51e84556c5241786f5f37bf9d2a0305bf36454d9ab8b5a9f6fe03fd4ab472b5379d7e8ab92e803c7c15bf3d0234653e1f6d80d23c7f127bed7fba3d297b62fee51b8e71b04d402cf291ac87460011fd222cfd27b5669d79d1e0dcc8d911c2dc6d0edcd205a91278cc97019cfc709ce8a50297409e66f27b1299e386a6cd", 28 | "sig": "848fc8a032ea073280a7d9146ae55bb0199cd1941c10a03cce1dc38579c4e77e87f259e250b16a9912ce2c085cb9489846f803fd6ed09bf8605c4aa8b0ebf2c938093e53ad025a48b97f7975255805118c33fa0f73ec204b9723acefacd8031ab3d9f7ebeaf996eee3678c788cea96932dd723b236355c0e6864fad2fc87b00e4eda476e90f000936b0d9fa65bf1112fc296e8aa5bb05ca7cb32dec01407e3d3ed94c1ebb0dc430ea59588ccc0995a6e2f1423dbe06c6f27650b23b12eb343b9e461ba532825e5e26572fbe723b69753c178361e7a834a566ce950df55ff97d314b384b3fa8c0098d560d4c6ba519a9b6040f908adf34f6b2d5d30c265cd0fb1" 29 | } 30 | ] 31 | -------------------------------------------------------------------------------- /src/blindrsa/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "resolveJsonModule": true 5 | }, 6 | "include": [ 7 | ".", 8 | "testdata/*.json" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /src/blindrsa/util.test.ts: -------------------------------------------------------------------------------- 1 | import { emsa_pss_encode } from './util'; 2 | import { jest } from '@jest/globals'; 3 | // Test vector in file pss_test.go from: https://cs.opensource.google/go/go/+/refs/tags/go1.18.2:src/crypto/rsa/pss_test.go 4 | // Test vector in file pss-int.txt from: ftp://ftp.rsasecurity.com/pub/pkcs/pkcs-1/pkcs-1v2-1-vec.zip 5 | import vector from './testdata/emsa_pss_vectors.json'; 6 | 7 | function hexToUint8(x: string): Uint8Array { 8 | return new Uint8Array(Buffer.from(x, 'hex')); 9 | } 10 | 11 | test('emsa_pss_encode', async () => { 12 | const hash = 'SHA-1'; 13 | const msg = hexToUint8(vector.msg); 14 | const salt = hexToUint8(vector.salt); 15 | const sLen = salt.length; 16 | 17 | jest.spyOn(crypto, 'getRandomValues').mockReturnValueOnce(salt); 18 | 19 | const encoded = await emsa_pss_encode(msg, 1023, { hash, sLen }); 20 | expect(encoded).toStrictEqual(hexToUint8(vector.expected)); 21 | }); 22 | -------------------------------------------------------------------------------- /src/blindrsa/util.ts: -------------------------------------------------------------------------------- 1 | import sjcl from './sjcl'; 2 | 3 | function assertNever(name: string, x: unknown): never { 4 | throw new Error(`unexpected ${name} identifier: ${x}`); 5 | } 6 | 7 | interface HashParams { 8 | name: string; 9 | hLen: number; 10 | } 11 | 12 | function getHashParams(hash: string): HashParams { 13 | switch (hash) { 14 | case 'SHA-1': 15 | return { name: hash, hLen: 20 }; 16 | case 'SHA-256': 17 | return { name: hash, hLen: 32 }; 18 | case 'SHA-384': 19 | return { name: hash, hLen: 48 }; 20 | case 'SHA-512': 21 | return { name: hash, hLen: 64 }; 22 | default: 23 | assertNever('Hash', hash); 24 | } 25 | } 26 | 27 | export function os2ip(bytes: Uint8Array): sjcl.bn { 28 | return sjcl.bn.fromBits(sjcl.codec.bytes.toBits(bytes)); 29 | } 30 | 31 | export function i2osp(num: sjcl.bn, byteLength: number): Uint8Array { 32 | if (Math.ceil(num.bitLength() / 8) > byteLength) { 33 | throw new Error(`number does not fit in ${byteLength} bytes`); 34 | } 35 | const bytes = new Uint8Array(byteLength); 36 | const unpadded = new Uint8Array(sjcl.codec.bytes.fromBits(num.toBits(undefined), false)); 37 | bytes.set(unpadded, byteLength - unpadded.length); 38 | return bytes; 39 | } 40 | 41 | export function joinAll(a: Uint8Array[]): Uint8Array { 42 | let size = 0; 43 | for (let i = 0; i < a.length; i++) { 44 | size += a[i as number].length; 45 | } 46 | const ret = new Uint8Array(new ArrayBuffer(size)); 47 | for (let i = 0, offset = 0; i < a.length; i++) { 48 | ret.set(a[i as number], offset); 49 | offset += a[i as number].length; 50 | } 51 | return ret; 52 | } 53 | 54 | export function xor(a: Uint8Array, b: Uint8Array): Uint8Array { 55 | if (a.length !== b.length || a.length === 0) { 56 | throw new Error('arrays of different length'); 57 | } 58 | const n = a.length; 59 | const c = new Uint8Array(n); 60 | for (let i = 0; i < n; i++) { 61 | c[i as number] = a[i as number] ^ b[i as number]; 62 | } 63 | return c; 64 | } 65 | 66 | function incCounter(c: Uint8Array) { 67 | c[3]++; 68 | if (c[3] != 0) { 69 | return; 70 | } 71 | c[2]++; 72 | if (c[2] != 0) { 73 | return; 74 | } 75 | c[1]++; 76 | if (c[1] != 0) { 77 | return; 78 | } 79 | c[0]++; 80 | } 81 | 82 | type MGFFn = (h: HashParams, seed: Uint8Array, mLen: number) => Promise; 83 | 84 | // MGF1 (mgfSeed, maskLen) 85 | // 86 | // https://www.rfc-editor.org/rfc/rfc8017#appendix-B.2.1 87 | // 88 | // Options: 89 | // Hash hash function (hLen denotes the length in octets of 90 | // the hash function output) 91 | // 92 | // Input: 93 | // mgfSeed seed from which mask is generated, an octet string 94 | // maskLen intended length in octets of the mask, at most 2^32 hLen 95 | // 96 | // Output: 97 | // mask mask, an octet string of length maskLen 98 | // 99 | // Error: "mask too long" 100 | async function mgf1(h: HashParams, seed: Uint8Array, mLen: number): Promise { 101 | // 1. If maskLen > 2^32 hLen, output "mask too long" and stop. 102 | const n = Math.ceil(mLen / h.hLen); 103 | if (n > Math.pow(2, 32)) { 104 | throw new Error('mask too long'); 105 | } 106 | 107 | // 2. Let T be the empty octet string. 108 | let T = new Uint8Array(); 109 | 110 | // 3. For counter from 0 to \ceil (maskLen / hLen) - 1, do the 111 | // following: 112 | const counter = new Uint8Array(4); 113 | for (let i = 0; i < n; i++) { 114 | // A. Convert counter to an octet string C of length 4 octets (see 115 | // Section 4.1): 116 | // 117 | // C = I2OSP (counter, 4) . 118 | // B. Concatenate the hash of the seed mgfSeed and C to the octet 119 | // string T: 120 | // 121 | // T = T || Hash(mgfSeed || C) . 122 | const hash = new Uint8Array(await crypto.subtle.digest(h.name, joinAll([seed, counter]))); 123 | T = joinAll([T, hash]); 124 | incCounter(counter); 125 | } 126 | 127 | // 4. Output the leading maskLen octets of T as the octet string mask. 128 | return T.subarray(0, mLen); 129 | } 130 | 131 | // EMSA-PSS-ENCODE (M, emBits) 132 | // 133 | // https://www.rfc-editor.org/rfc/rfc3447.html#section-9.1.1 134 | // 135 | // Input: 136 | // M message to be encoded, an octet string 137 | // emBits maximal bit length of the integer OS2IP (EM) (see Section 138 | // 4.2), at least 8hLen + 8sLen + 9 139 | // MGF mask generation function 140 | // 141 | // Output: 142 | // EM encoded message, an octet string of length emLen = \ceil 143 | // (emBits/8) 144 | // 145 | // Errors: "encoding error"; "message too long" 146 | export async function emsa_pss_encode( 147 | msg: Uint8Array, 148 | emBits: number, 149 | opts: { 150 | hash: string; 151 | sLen: number; 152 | }, 153 | mgf: MGFFn = mgf1, 154 | ): Promise { 155 | const { hash, sLen } = opts; 156 | const hashParams = getHashParams(hash); 157 | const { hLen } = hashParams; 158 | const emLen = Math.ceil(emBits / 8); 159 | 160 | // 1. If the length of M is greater than the input limitation for the 161 | // hash function (2^61 - 1 octets for SHA-1), output "message too 162 | // long" and stop. 163 | // 164 | // 2. Let mHash = Hash(M), an octet string of length hLen. 165 | const mHash = new Uint8Array(await crypto.subtle.digest(hash, msg)); 166 | // 3. If emLen < hLen + sLen + 2, output "encoding error" and stop. 167 | if (emLen < hLen + sLen + 2) { 168 | throw new Error('encoding error'); 169 | } 170 | // 4. Generate a random octet string salt of length sLen; if sLen = 0, 171 | // then salt is the empty string. 172 | const salt = crypto.getRandomValues(new Uint8Array(sLen)); 173 | // 5. Let 174 | // M' = (0x)00 00 00 00 00 00 00 00 || mHash || salt; 175 | // 176 | // M' is an octet string of length 8 + hLen + sLen with eight 177 | // initial zero octets. 178 | // 179 | const mPrime = joinAll([new Uint8Array(8), mHash, salt]); 180 | // 6. Let H = Hash(M'), an octet string of length hLen. 181 | const h = new Uint8Array(await crypto.subtle.digest(hash, mPrime)); 182 | // 7. Generate an octet string PS consisting of emLen - sLen - hLen - 2 183 | // zero octets. The length of PS may be 0. 184 | const ps = new Uint8Array(emLen - sLen - hLen - 2); 185 | // 8. Let DB = PS || 0x01 || salt; DB is an octet string of length 186 | // emLen - hLen - 1. 187 | const db = joinAll([ps, Uint8Array.of(0x01), salt]); 188 | // 9. Let dbMask = MGF(H, emLen - hLen - 1). 189 | const dbMask = await mgf(hashParams, h, emLen - hLen - 1); 190 | // 10. Let maskedDB = DB \xor dbMask. 191 | const maskedDB = xor(db, dbMask); 192 | // 11. Set the leftmost 8emLen - emBits bits of the leftmost octet 193 | // in maskedDB to zero. 194 | maskedDB[0] &= 0xff >> (8 * emLen - emBits); 195 | // 12. Let EM = maskedDB || H || 0xbc. 196 | const em = joinAll([maskedDB, h, Uint8Array.of(0xbc)]); 197 | 198 | // 13. Output EM. 199 | return em; 200 | } 201 | 202 | // RSAVP1 203 | // https://www.rfc-editor.org/rfc/rfc3447.html#section-5.2.2 204 | export function rsavp1(pkS: { n: sjcl.bn; e: sjcl.bn }, s: sjcl.bn): sjcl.bn { 205 | // 1. If the signature representative s is not between 0 and n - 1, 206 | // output "signature representative out of range" and stop. 207 | if (!s.greaterEquals(new sjcl.bn(0)) || s.greaterEquals(pkS.n) == 1) { 208 | throw new Error('signature representative out of range'); 209 | } 210 | // 2. Let m = s^e mod n. 211 | const m = s.powermod(pkS.e, pkS.n); 212 | // 3. Output m. 213 | return m; 214 | } 215 | 216 | // RSASP1 217 | // https://www.rfc-editor.org/rfc/rfc3447.html#section-5.2.1 218 | export function rsasp1(skS: { n: sjcl.bn; d: sjcl.bn }, m: sjcl.bn): sjcl.bn { 219 | // 1. If the message representative m is not between 0 and n - 1, 220 | // output "message representative out of range" and stop. 221 | if (!m.greaterEquals(new sjcl.bn(0)) || m.greaterEquals(skS.n) == 1) { 222 | throw new Error('signature representative out of range'); 223 | } 224 | // 2. The signature representative s is computed as follows. 225 | // 226 | // a. If the first form (n, d) of K is used, let s = m^d mod n. 227 | const s = m.powermod(skS.d, skS.n); 228 | /* TODO: implement the CRT variant. 229 | // b. If the second form (p, q, dP, dQ, qInv) and (r_i, d_i, t_i) 230 | // of K is used, proceed as follows: 231 | // 232 | // i. Let s_1 = m^dP mod p and s_2 = m^dQ mod q. 233 | // 234 | // ii. If u > 2, let s_i = m^(d_i) mod r_i, i = 3, ..., u. 235 | // 236 | // iii. Let h = (s_1 - s_2) * qInv mod p. 237 | // 238 | // iv. Let s = s_2 + q * h. 239 | // 240 | // v. If u > 2, let R = r_1 and for i = 3 to u do 241 | // 242 | // 1. Let R = R * r_(i-1). 243 | // 2. Let h = (s_i - s) * t_i mod r_i. 244 | // 3. Let s = s + R * h. 245 | */ 246 | // 3. Output s. 247 | return s; 248 | } 249 | -------------------------------------------------------------------------------- /src/popup/components/App/index.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from '@popup/components/Container'; 2 | import { ClearButton } from '@popup/components/ClearButton'; 3 | import { CloudflareButton } from '@popup/components/CloudflareButton'; 4 | import { GithubButton } from '@popup/components/GithubButton'; 5 | import { Header } from '@popup/components/Header'; 6 | import React from 'react'; 7 | import styles from './styles.module.scss'; 8 | import { HcaptchaButton } from '@popup/components/HcaptchaButton'; 9 | 10 | export function App(): JSX.Element { 11 | return ( 12 |
13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/popup/components/App/styles.module.scss: -------------------------------------------------------------------------------- 1 | @use '@popup/styles/colors'; 2 | 3 | $_app-width: 260px; 4 | 5 | .app { 6 | width: $_app-width; 7 | background-color: colors.$white; 8 | font-family: helvetica; 9 | } 10 | -------------------------------------------------------------------------------- /src/popup/components/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './styles.module.scss'; 3 | 4 | export function Button(props: Props): JSX.Element { 5 | return ( 6 |
7 | {props.children} 8 |
9 | ); 10 | } 11 | 12 | interface Props { 13 | children: string; 14 | onClick?: () => void; 15 | } 16 | -------------------------------------------------------------------------------- /src/popup/components/Button/styles.module.scss: -------------------------------------------------------------------------------- 1 | @use '@popup/styles/buttons'; 2 | 3 | .button { 4 | @include buttons.button; 5 | } 6 | -------------------------------------------------------------------------------- /src/popup/components/ClearButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | 4 | import { Button } from '@popup/components/Button'; 5 | 6 | export function ClearButton(): JSX.Element { 7 | const dispatch = useDispatch(); 8 | 9 | const clearPasses = () => { 10 | dispatch({ type: 'CLEAR_TOKENS' }); 11 | }; 12 | return ; 13 | } 14 | -------------------------------------------------------------------------------- /src/popup/components/CloudflareButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { PassButton } from '@popup/components/PassButton'; 5 | 6 | export function CloudflareButton(): JSX.Element { 7 | const tokens: string[] = useSelector((state: { ['cf-tokens']?: string[] } | undefined) => { 8 | if (state !== undefined && state['cf-tokens'] !== undefined) { 9 | return state['cf-tokens']; 10 | } 11 | return []; 12 | }); 13 | 14 | const openHomePage = () => { 15 | chrome.tabs.create({ url: 'https://issuance.privacypass.cloudflare.com' }); 16 | }; 17 | 18 | return ( 19 | 20 | Cloudflare 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/popup/components/Container/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './styles.module.scss'; 3 | 4 | export function Container(props: Props): JSX.Element { 5 | return
{props.children}
; 6 | } 7 | 8 | interface Props { 9 | children: React.ReactNode; 10 | } 11 | -------------------------------------------------------------------------------- /src/popup/components/Container/styles.module.scss: -------------------------------------------------------------------------------- 1 | @use '@popup/styles/buttons'; 2 | 3 | .container { 4 | @include buttons.container; 5 | } 6 | -------------------------------------------------------------------------------- /src/popup/components/GithubButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Button } from '@popup/components/Button'; 4 | 5 | export function GithubButton(): JSX.Element { 6 | const openGithub = () => { 7 | chrome.tabs.create({ url: 'https://github.com/privacypass/challenge-bypass-extension' }); 8 | }; 9 | 10 | return ; 11 | } 12 | -------------------------------------------------------------------------------- /src/popup/components/HcaptchaButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { PassButton } from '@popup/components/PassButton'; 4 | import { useSelector } from 'react-redux'; 5 | 6 | export function HcaptchaButton(): JSX.Element { 7 | const tokens: number = useSelector((state: { ['hc-tokens']?: string[] } | undefined) => { 8 | if (state !== undefined && state['hc-tokens'] !== undefined) { 9 | return state['hc-tokens'].length; 10 | } 11 | return 0; 12 | }); 13 | 14 | const openHomePage = () => { 15 | chrome.tabs.create({ url: 'https://www.hcaptcha.com/privacy-pass' }); 16 | }; 17 | 18 | return ( 19 | 20 | hCaptcha 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/popup/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import badge from '@public/images/gold-badge.svg'; 3 | import packageJson from '@root/package.json'; 4 | import styles from './styles.module.scss'; 5 | 6 | export function Header(): JSX.Element { 7 | return ( 8 |
9 | 10 |
11 |
Privacy Pass
12 |
Version {packageJson.version}
13 |
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/popup/components/Header/styles.module.scss: -------------------------------------------------------------------------------- 1 | $_badge-height: 55px; 2 | $_header-height: 100px; 3 | $_big-font-size: 23px; 4 | $_small-font-size: 16px; 5 | $_margin: 10px; 6 | 7 | .badge { 8 | height: $_badge-height; 9 | margin: $_margin; 10 | } 11 | 12 | .version { 13 | margin-top: $_margin; 14 | font-size: $_small-font-size; 15 | } 16 | 17 | .header { 18 | display: flex; 19 | justify-content: center; 20 | align-items: center; 21 | height: $_header-height; 22 | } 23 | 24 | .detail { 25 | margin: $_margin; 26 | } 27 | 28 | .title { 29 | font-size: $_big-font-size; 30 | } 31 | -------------------------------------------------------------------------------- /src/popup/components/PassButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import styles from './styles.module.scss'; 4 | 5 | export function PassButton(props: Props): JSX.Element { 6 | const [mouseover, setMouseover] = useState(false); 7 | 8 | function onEnter() { 9 | setMouseover(true); 10 | } 11 | 12 | function onLeave() { 13 | setMouseover(false); 14 | } 15 | 16 | const element = mouseover ? 'Get more passes!' : props.children; 17 | 18 | return ( 19 |
25 |
{element}
26 |
{props.value}
27 |
28 | ); 29 | } 30 | 31 | interface Props { 32 | value: number; 33 | children: string; 34 | onClick?: () => void; 35 | } 36 | -------------------------------------------------------------------------------- /src/popup/components/PassButton/styles.module.scss: -------------------------------------------------------------------------------- 1 | @use '@popup/styles/buttons'; 2 | @use '@popup/styles/colors'; 3 | 4 | $_font-size: 22px; 5 | $_font-weight: 200; 6 | 7 | .button { 8 | @include buttons.button; 9 | 10 | background-color: colors.$light-grey; 11 | font-size: $_font-size; 12 | font-weight: $_font-weight; 13 | } 14 | 15 | .content { 16 | display: inline; 17 | } 18 | 19 | .value { 20 | float: right; 21 | } 22 | -------------------------------------------------------------------------------- /src/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import '@popup/styles/body.scss'; 2 | 3 | import { App } from '@popup/components/App'; 4 | import React from 'react'; 5 | import ReactDOM from 'react-dom'; 6 | import { Provider } from 'react-redux'; 7 | import { store } from './store'; 8 | 9 | ReactDOM.render( 10 | 11 | 12 | , 13 | document.getElementById('root'), 14 | ); 15 | 16 | // TODO Using Message passing is dirty. It's better to use chrome.storage for sharing 17 | // common data between the popup and the background script. 18 | chrome.runtime.sendMessage({ key: 'cf-tokens' }, (response) => { 19 | if (response !== undefined && typeof response === 'string') { 20 | store.dispatch({ 21 | type: 'UPDATE_STATE', 22 | key: 'cf-tokens', 23 | value: response, 24 | }); 25 | } 26 | }); 27 | chrome.runtime.sendMessage({ key: 'hc-tokens' }, (response) => { 28 | if (response !== undefined && typeof response === 'string') { 29 | store.dispatch({ 30 | type: 'UPDATE_STATE', 31 | key: 'hc-tokens', 32 | value: response, 33 | }); 34 | } 35 | }); 36 | -------------------------------------------------------------------------------- /src/popup/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from 'redux'; 2 | 3 | interface UpdateStateAction { 4 | type: 'UPDATE_STATE'; 5 | key: string; 6 | value: string; 7 | } 8 | 9 | interface ClearTokensAction { 10 | type: 'CLEAR_TOKENS'; 11 | } 12 | 13 | type Action = UpdateStateAction | ClearTokensAction; 14 | 15 | const reducer = (state: Record | undefined, action: Action) => { 16 | switch (action.type) { 17 | case 'UPDATE_STATE': 18 | return { 19 | ...state, 20 | [action.key]: JSON.parse(action.value), 21 | }; 22 | case 'CLEAR_TOKENS': 23 | // TODO Using Message passing is dirty. It's better to use chrome.storage for sharing 24 | // common data between the popup and the background script. 25 | chrome.runtime.sendMessage({ clear: true }); 26 | return {}; 27 | } 28 | }; 29 | 30 | export const store = createStore(reducer, {}); 31 | -------------------------------------------------------------------------------- /src/popup/styles/_buttons.scss: -------------------------------------------------------------------------------- 1 | @use '@popup/styles/colors'; 2 | 3 | $_border-width: 1px; 4 | $_padding: 15px; 5 | $_font-size: 14px; 6 | 7 | @mixin button { 8 | border: $_border-width solid colors.$medium-grey; 9 | padding: $_padding; 10 | font-size: $_font-size; 11 | background-color: colors.$white; 12 | 13 | border-left: none; 14 | border-right: none; 15 | 16 | &:hover { 17 | color: colors.$white; 18 | cursor: pointer; 19 | background-color: colors.$dark-grey; 20 | } 21 | } 22 | 23 | @mixin container { 24 | border: $_border-width solid colors.$medium-grey; 25 | border-left: none; 26 | border-right: none; 27 | } 28 | -------------------------------------------------------------------------------- /src/popup/styles/_colors.scss: -------------------------------------------------------------------------------- 1 | $white: #ffffff; 2 | $dark-grey: #b1b1b1; 3 | $medium-grey: #dddddd; 4 | $light-grey: #f4f4f4; 5 | -------------------------------------------------------------------------------- /src/popup/styles/body.scss: -------------------------------------------------------------------------------- 1 | // Most browsers have the default margin of the body tag of 8 pixels. Clear this out. 2 | body { 3 | margin: 0; 4 | } 5 | -------------------------------------------------------------------------------- /src/popup/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "baseUrl": ".", 5 | "paths": { 6 | "@root/*": [ 7 | "../../*" 8 | ], 9 | "@popup/*": [ 10 | "./*" 11 | ], 12 | "@public/*": [ 13 | "../../public/*" 14 | ] 15 | } 16 | }, 17 | "extends": "../../tsconfig.json", 18 | "include": [ 19 | "." 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/popup/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.json'; 2 | declare module '*.scss'; 3 | declare module '*.svg'; 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "es2020", 5 | "declaration": true, 6 | "declarationMap": true, 7 | "sourceMap": true, 8 | "composite": true, 9 | "incremental": true, 10 | "removeComments": true, 11 | "isolatedModules": true, 12 | "strict": true, 13 | "strictNullChecks": true, 14 | "noEmitOnError": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "moduleResolution": "node", 20 | "esModuleInterop": true, 21 | "experimentalDecorators": true, 22 | "emitDecoratorMetadata": true, 23 | "allowSyntheticDefaultImports": true, 24 | "rootDir": ".", 25 | "outDir": "lib" 26 | }, 27 | "files": [], 28 | "include": [], 29 | "references": [ 30 | { 31 | "path": "./src/blindrsa/sjcl" 32 | }, 33 | { 34 | "path": "./src/blindrsa" 35 | }, 36 | { 37 | "path": "./src/background" 38 | }, 39 | { 40 | "path": "./src/popup" 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | import CopyWebpackPlugin from 'copy-webpack-plugin'; 2 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 3 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 4 | import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin'; 5 | import { fileURLToPath } from 'url'; 6 | import path from 'path'; 7 | import webpack from 'webpack'; 8 | 9 | 10 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 11 | 12 | const tsloader = { 13 | test: /\.tsx?$/, 14 | exclude: /node_modules/, 15 | loader: 'ts-loader', 16 | options: { 17 | projectReferences: true, 18 | }, 19 | }; 20 | 21 | const common = { 22 | output: { 23 | path: path.resolve('dist'), 24 | }, 25 | context: __dirname, 26 | mode: 'production', 27 | optimization: { 28 | minimize: false, 29 | }, 30 | plugins: [ 31 | new webpack.ProvidePlugin({ 32 | Buffer: ['buffer', 'Buffer'] 33 | }) 34 | ] 35 | }; 36 | 37 | const background = { 38 | ...common, 39 | entry: { 40 | background: path.resolve('src/background/index.ts'), 41 | }, 42 | externals: { crypto: 'null' }, 43 | module: { 44 | rules: [tsloader], 45 | }, 46 | resolve: { 47 | extensions: ['.tsx', '.ts', '.js'], 48 | fallback: { 49 | // 'buffer': buffer, 50 | // 'stream': streamBrowserify, 51 | }, 52 | }, 53 | }; 54 | 55 | const blindrsa = { 56 | ...common, 57 | entry: { 58 | blindrsa: path.resolve('src/blindrsa/index.ts'), 59 | }, 60 | externals: { crypto: 'null' }, 61 | module: { 62 | rules: [tsloader], 63 | }, 64 | resolve: { 65 | extensions: ['.tsx', '.ts', '.js'], 66 | fallback: { 67 | // 'buffer': buffer, 68 | // 'stream': streamBrowserify, 69 | }, 70 | }, 71 | }; 72 | 73 | const popup = { 74 | ...common, 75 | entry: { 76 | popup: path.resolve('src/popup/index.tsx'), 77 | }, 78 | module: { 79 | rules: [ 80 | tsloader, 81 | { 82 | test: /\.scss?$/, 83 | use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'], 84 | }, 85 | { test: /\.(png|jpe?g|gif|svg)$/, use: 'file-loader' }, 86 | ], 87 | }, 88 | resolve: { 89 | extensions: ['.tsx', '.ts', '.js', '.scss', '.json', '.svg', '.html'], 90 | plugins: [ 91 | new TsconfigPathsPlugin({ 92 | extensions: ['.tsx', '.ts', '.js', '.scss', '.json', '.svg', '.html'], 93 | configFile: 'src/popup/tsconfig.json', 94 | }), 95 | ], 96 | }, 97 | plugins: [ 98 | new CopyWebpackPlugin({ 99 | patterns: [{ from: 'public/icons', to: 'icons' }, { from: 'public/manifest.json' }], 100 | }), 101 | new HtmlWebpackPlugin({ 102 | chunks: ['popup'], 103 | filename: 'popup.html', 104 | template: 'public/popup.html', 105 | }), 106 | new MiniCssExtractPlugin(), 107 | ], 108 | }; 109 | 110 | // Mutiple targets for webpack: https://webpack.js.org/concepts/targets/#multiple-targets 111 | export default [blindrsa, background, popup]; 112 | --------------------------------------------------------------------------------