├── .env.example ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .npmignore ├── tests └── browser.env.js ├── tsconfig.json ├── LICENSE ├── package.json ├── README.md └── src ├── index.ts └── index.test.ts /.env.example: -------------------------------------------------------------------------------- 1 | # Place the documentation for required environment variables here. 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [franky47] 2 | liberapay: francoisbest 3 | custom: ['https://paypal.me/francoisbest?locale.x=fr_FR'] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | .env 5 | yarn-error.log 6 | # Docker containers with persistance 7 | .volumes/ 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | **/*.test.ts 2 | tsconfig.json 3 | nodemon.json 4 | .env* 5 | .volumes/ 6 | yarn-error.log 7 | coverage/ 8 | tests/ 9 | .github/ 10 | .dependabot/ 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | time: "09:00" 8 | timezone: Europe/Paris 9 | assignees: 10 | - franky47 11 | ignore: 12 | - dependency-name: "husky" 13 | versions: ["^5.x"] 14 | - package-ecosystem: github-actions 15 | directory: / 16 | schedule: 17 | interval: monthly 18 | assignees: 19 | - franky47 20 | -------------------------------------------------------------------------------- /tests/browser.env.js: -------------------------------------------------------------------------------- 1 | const Environment = require('jest-environment-jsdom') 2 | 3 | /** 4 | * JSDOM does not include TextEncoder / TextDecoder by default 5 | */ 6 | module.exports = class CustomTestEnvironment extends Environment { 7 | async setup() { 8 | await super.setup() 9 | if (typeof this.global.TextEncoder === 'undefined') { 10 | const { TextEncoder } = require('util') 11 | this.global.TextEncoder = TextEncoder 12 | } 13 | if (typeof this.global.TextDecoder === 'undefined') { 14 | const { TextDecoder } = require('util') 15 | this.global.TextDecoder = TextDecoder 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "es2018", 5 | "module": "commonjs", 6 | "lib": ["DOM"], 7 | "declaration": true, 8 | "outDir": "./dist", 9 | "rootDir": "./src", 10 | "paths": { "*": ["types/*"] }, 11 | 12 | // Strict Type-Checking Options 13 | "strict": true, 14 | "noImplicitAny": true, 15 | "strictNullChecks": true, 16 | "strictFunctionTypes": true, 17 | "strictPropertyInitialization": true, 18 | "noImplicitThis": true, 19 | "alwaysStrict": true, 20 | 21 | // Additional Checks 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noImplicitReturns": true, 25 | "noFallthroughCasesInSwitch": true, 26 | 27 | "esModuleInterop": true, 28 | 29 | // Experimental Options 30 | "experimentalDecorators": true, 31 | "emitDecoratorMetadata": true 32 | }, 33 | "exclude": ["./src/**/*.test.ts", "./dist", "./node_modules"] 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 François Best 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Delivery 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | cd: 10 | name: Continuous Delivery 11 | runs-on: ubuntu-latest 12 | steps: 13 | - id: yarn-cache 14 | name: Get Yarn cache path 15 | run: echo "::set-output name=dir::$(yarn cache dir)" 16 | - uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f 17 | - uses: actions/setup-node@56899e050abffc08c2b3b61f3ec6a79a9dc3223d 18 | with: 19 | node-version: 14.x 20 | - uses: actions/cache@26968a09c0ea4f3e233fdddbafd1166051a095f6 21 | name: Load Yarn cache 22 | with: 23 | path: ${{ steps.yarn-cache.outputs.dir }} 24 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 25 | restore-keys: | 26 | ${{ runner.os }}-yarn- 27 | - run: yarn install --ignore-scripts 28 | name: Install dependencies 29 | - run: yarn build 30 | name: Build package 31 | 32 | # Continuous Delivery Pipeline -- 33 | 34 | - uses: docker://ghcr.io/codfish/semantic-release-action@sha256:16ab6c16b1bff6bebdbcc6cfc07dfafff49d23c6818490500b8edb3babfff29e 35 | name: Semantic Release 36 | id: semantic 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 40 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - next 7 | - feature/* 8 | - dependabot/* 9 | pull_request: 10 | types: [opened, edited, reopened, synchronize] 11 | 12 | jobs: 13 | ci: 14 | name: Continuous Integration 15 | runs-on: ubuntu-latest 16 | steps: 17 | - id: yarn-cache 18 | name: Get Yarn cache path 19 | run: echo "::set-output name=dir::$(yarn cache dir)" 20 | - uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f 21 | - uses: actions/setup-node@56899e050abffc08c2b3b61f3ec6a79a9dc3223d 22 | with: 23 | node-version: 14.x 24 | - uses: actions/cache@26968a09c0ea4f3e233fdddbafd1166051a095f6 25 | name: Load Yarn cache 26 | with: 27 | path: ${{ steps.yarn-cache.outputs.dir }} 28 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 29 | restore-keys: | 30 | ${{ runner.os }}-yarn- 31 | - run: yarn install --ignore-scripts 32 | name: Install dependencies 33 | - run: yarn ci 34 | name: Run integration tests 35 | - uses: coverallsapp/github-action@8cbef1dea373ebce56de0a14c68d6267baa10b44 36 | name: Report code coverage 37 | with: 38 | github-token: ${{ secrets.GITHUB_TOKEN }} 39 | - uses: 47ng/actions-slack-notify@main 40 | name: Notify on Slack 41 | if: always() 42 | with: 43 | status: ${{ job.status }} 44 | env: 45 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@47ng/simple-e2ee", 3 | "version": "0.0.0-semantically-released", 4 | "description": "Simple E2EE for web apps", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "license": "MIT", 8 | "author": { 9 | "name": "François Best", 10 | "email": "contact@francoisbest.com", 11 | "url": "https://francoisbest.com" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/47ng/simple-e2ee" 16 | }, 17 | "keywords": [ 18 | "e2ee", 19 | "tweetnacl" 20 | ], 21 | "publishConfig": { 22 | "access": "public" 23 | }, 24 | "scripts": { 25 | "test": "jest --coverage", 26 | "test:watch": "jest --watch", 27 | "build:clean": "rm -rf ./dist", 28 | "build:ts": "tsc", 29 | "build": "run-s build:clean build:ts", 30 | "ci": "run-s build test" 31 | }, 32 | "dependencies": { 33 | "@47ng/codec": "^1.0.0", 34 | "tweetnacl": "^1.0.3" 35 | }, 36 | "devDependencies": { 37 | "@commitlint/config-conventional": "^17.4.2", 38 | "@types/jest": "^26.0.15", 39 | "@types/node": "^14.14.5", 40 | "commitlint": "^12.0.1", 41 | "husky": "4.x", 42 | "jest": "^26.6.1", 43 | "npm-run-all": "^4.1.5", 44 | "ts-jest": "^26.4.3", 45 | "ts-node": "^9.0.0", 46 | "typescript": "^4.0.5" 47 | }, 48 | "jest": { 49 | "verbose": true, 50 | "preset": "ts-jest/presets/js-with-ts", 51 | "testEnvironment": "./tests/browser.env.js" 52 | }, 53 | "prettier": { 54 | "arrowParens": "avoid", 55 | "semi": false, 56 | "singleQuote": true, 57 | "tabWidth": 2, 58 | "trailingComma": "none", 59 | "useTabs": false 60 | }, 61 | "husky": { 62 | "hooks": { 63 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 64 | } 65 | }, 66 | "commitlint": { 67 | "extends": [ 68 | "@commitlint/config-conventional" 69 | ], 70 | "rules": { 71 | "type-enum": [ 72 | 2, 73 | "always", 74 | [ 75 | "build", 76 | "chore", 77 | "ci", 78 | "clean", 79 | "doc", 80 | "feat", 81 | "fix", 82 | "perf", 83 | "ref", 84 | "revert", 85 | "style", 86 | "test" 87 | ] 88 | ], 89 | "subject-case": [ 90 | 0, 91 | "always", 92 | "sentence-case" 93 | ], 94 | "body-leading-blank": [ 95 | 2, 96 | "always", 97 | true 98 | ] 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `@47ng/simple-e2ee` 2 | 3 | [![NPM](https://img.shields.io/npm/v/@47ng/simple-e2ee?color=red)](https://www.npmjs.com/package/@47ng/simple-e2ee) 4 | [![MIT License](https://img.shields.io/github/license/47ng/simple-e2ee.svg?color=blue)](https://github.com/47ng/simple-e2ee/blob/next/LICENSE) 5 | [![Continuous Integration](https://github.com/47ng/simple-e2ee/workflows/Continuous%20Integration/badge.svg?branch=next)](https://github.com/47ng/simple-e2ee/actions) 6 | [![Coverage Status](https://coveralls.io/repos/github/47ng/simple-e2ee/badge.svg?branch=next)](https://coveralls.io/github/47ng/simple-e2ee?branch=next) 7 | [![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=47ng/simple-e2ee)](https://dependabot.com) 8 | 9 | Simple end-to-end encryption for web apps. Inspired from [Excalidraw](https://excalidraw.com) and [Firefox Send](https://github.com/mozilla/send) (RIP). 10 | 11 | ## How it works 12 | 13 | You encrypt your data in the browser, send the encrypted data 14 | to a server, and use the hash part of the URL (which never 15 | hits the server) to share the key with someone else. 16 | 17 | On the other side, the recipient obtains the key from the URL, 18 | the payload from the server and decrypts the data. 19 | 20 | This library does not handle any of the server upload/download 21 | side, it's up to you. It only deals with encryption, decryption 22 | and moving keys in and out of URLs. 23 | 24 | ## Installation 25 | 26 | ```shell 27 | $ yarn add @47ng/simple-e2ee 28 | # or 29 | $ npm i @47ng/simple-e2ee 30 | ``` 31 | 32 | ## Usage 33 | 34 | ```ts 35 | import { 36 | encrypt, 37 | decrypt, 38 | applyKeyToURL, 39 | getKeyFromURL, 40 | generateKey 41 | } from '@47ng/simple-e2ee' 42 | 43 | // Before sending data to the server, encrypt it: 44 | const { payload, key } = encrypt( 45 | "whatever you want, as long as it's JSON-serialisable" 46 | ) 47 | 48 | // Upload `payload` to the server 49 | // Stick the key onto the current URL: 50 | const shareURL = applyKeyToURL(key) 51 | 52 | // Optionally, apply the key to another URL 53 | // (example, with an ID returned from the server): 54 | const shareURL = applyKeyToURL(key, `https://example.com/share/${id}`) 55 | 56 | // On the other side, get the key from the current URL, 57 | // and the payload from the server: 58 | const key = getKeyFromURL() 59 | const message = decrypt(payload, key) 60 | 61 | // Optionally, obtain the key from any URL: 62 | const key = getKeyFromURL( 63 | `https://example.com/share/foo#KatLceVEOM2znzX_FGPKu6Zz1adWkhlq9b2R9WRjUsM=` 64 | ) 65 | 66 | // Encrypt related pieces of content with the same key: 67 | const payload = encrypt('some more data', key) 68 | 69 | // Generate a key for later use: 70 | const key = generateKey() 71 | ``` 72 | 73 | ## License 74 | 75 | [MIT](https://github.com/47ng/simple-e2ee/blob/master/LICENSE) - Made with ❤️ by [François Best](https://francoisbest.com). 76 | 77 | Using this package at work ? [Sponsor me](https://github.com/sponsors/franky47) to help with support and maintenance. 78 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import b64 from '@47ng/codec/dist/b64' 2 | import utf8 from '@47ng/codec/dist/utf8' 3 | import nacl from 'tweetnacl' 4 | 5 | /** 6 | * Generate a NaCl secretbox symmetric encryption key. 7 | * 8 | * @returns A base64-encoded key string. Keep it safe! 9 | */ 10 | export function generateKey() { 11 | return b64 12 | .encode(nacl.randomBytes(nacl.secretbox.keyLength)) 13 | .replace(/=/g, '') 14 | } 15 | 16 | /** 17 | * Encrypt data with a new key 18 | * 19 | * This will generate a key for you on the fly and pass it in the returned 20 | * object. 21 | * 22 | * @param message The data to encrypt (anything JSON-serializable) 23 | */ 24 | export function encrypt( 25 | message: T 26 | ): { 27 | payload: string 28 | key: string 29 | } 30 | 31 | /** 32 | * Encrypt data with a provided key 33 | * 34 | * **Security note**: only use this overload if you need to encrypt 35 | * multiple pieces of data with the same key. 36 | * 37 | * Keys can be generated with `generateKey`, or one can be created for you from 38 | * calling `encrypt` with a single data argument. 39 | * 40 | * @param message The data to encrypt (anything JSON-serializable) 41 | * @param providedKey The key to use (re-use a key to encrypt multiple messages) 42 | */ 43 | export function encrypt(message: T, providedKey: string): string 44 | 45 | export function encrypt(message: T, providedKey?: string) { 46 | const key = providedKey 47 | ? b64.decode(providedKey) 48 | : nacl.randomBytes(nacl.secretbox.keyLength) 49 | const nonce = nacl.randomBytes(nacl.secretbox.nonceLength) 50 | const payload = utf8.encode(JSON.stringify(message)) 51 | const ciphertext = nacl.secretbox(payload, nonce, key) 52 | const output = [ 53 | '1', 54 | b64.encode(nonce), 55 | b64.encode(ciphertext).replace(/=/g, '') 56 | ].join('.') 57 | if (providedKey) { 58 | return output 59 | } 60 | return { 61 | payload: output, 62 | key: b64.encode(key).replace(/=/g, '') 63 | } 64 | } 65 | 66 | // v1: Accept trailing padding characters `=`, but don't generate them: 67 | export const PAYLOAD_REGEX_V1 = /^1\.([a-zA-Z0-9-_]{32})\.([a-zA-Z0-9-_]{24,})={0,2}$/ 68 | 69 | export function decrypt(payload: string, key: string): T { 70 | const match = payload.match(PAYLOAD_REGEX_V1) 71 | if (!match) { 72 | throw new Error('Invalid payload format') 73 | } 74 | const nonce = b64.decode(match[1]) 75 | const ciphertext = b64.decode(match[2]) 76 | const message = nacl.secretbox.open(ciphertext, nonce, b64.decode(key)) 77 | if (!message) { 78 | throw new Error('Could not decrypt payload (wrong key)') 79 | } 80 | const json = utf8.decode(message) 81 | return JSON.parse(json) 82 | } 83 | 84 | export function applyKeyToURL(key: string, baseURL?: string) { 85 | const url = new URL(baseURL ?? window.location.toString()) 86 | if (!!url.hash) { 87 | throw new Error( 88 | 'URL already has a hash part, this will conflict with the E2EE key' 89 | ) 90 | } 91 | url.hash = key 92 | return url.toString() 93 | } 94 | 95 | export function getKeyFromURL(baseURL?: string) { 96 | const url = baseURL ? new URL(baseURL) : window.location 97 | const hash = url.hash.replace(/^#/, '') 98 | if (hash === '') { 99 | throw new Error('Could not retrieve key: no hash in URL') 100 | } 101 | return hash 102 | } 103 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | applyKeyToURL, 3 | decrypt, 4 | encrypt, 5 | getKeyFromURL, 6 | PAYLOAD_REGEX_V1 7 | } from './index' 8 | 9 | test('encrypt generates a key and a payload', () => { 10 | expect(encrypt('').payload).toMatch(PAYLOAD_REGEX_V1) 11 | expect(encrypt({}).payload).toMatch(PAYLOAD_REGEX_V1) 12 | expect(encrypt([]).payload).toMatch(PAYLOAD_REGEX_V1) 13 | expect(encrypt(null).payload).toMatch(PAYLOAD_REGEX_V1) 14 | expect(encrypt(42).payload).toMatch(PAYLOAD_REGEX_V1) 15 | expect(encrypt({ hello: 'world' }).payload).toMatch(PAYLOAD_REGEX_V1) 16 | }) 17 | 18 | test('Encrypt => Decrypt', () => { 19 | function testEncryptionDecryption(input: T) { 20 | const { payload, key } = encrypt(input) 21 | expect(decrypt(payload, key)).toEqual(input) 22 | } 23 | 24 | testEncryptionDecryption('') 25 | testEncryptionDecryption({}) 26 | testEncryptionDecryption([]) 27 | testEncryptionDecryption(null) 28 | testEncryptionDecryption(42) 29 | testEncryptionDecryption({ hello: 'world' }) 30 | }) 31 | 32 | test('Known vectors', () => { 33 | expect( 34 | decrypt( 35 | '1.TWgAHXRv8cKAjBsYoUB94xP6h3rF4WuT._MYXkn0uSIU0BS9BrBpQxWQZ_NfF1Qt9p7vuLV0C7A', 36 | 'uI8_9QBb3ANmUr2ftZLPeTuJclX_CnG54dA5AbPF99c' 37 | ) 38 | ).toEqual('Hello, World!') 39 | }) 40 | 41 | test('Known vectors accept base64 padding', () => { 42 | expect( 43 | decrypt( 44 | '1.TWgAHXRv8cKAjBsYoUB94xP6h3rF4WuT._MYXkn0uSIU0BS9BrBpQxWQZ_NfF1Qt9p7vuLV0C7A==', 45 | 'uI8_9QBb3ANmUr2ftZLPeTuJclX_CnG54dA5AbPF99c=' 46 | ) 47 | ).toEqual('Hello, World!') 48 | }) 49 | 50 | test('Failure - Invalid payload', () => { 51 | const run = () => decrypt('not a payload', 'not a key') 52 | expect(run).toThrowError('Invalid payload format') 53 | }) 54 | 55 | test('Failure - Invalid key', () => { 56 | const run = () => 57 | decrypt( 58 | '1.G3Yvm4G3X97h2-ky8ORVIlVMBbHNVFd_.cNFG6eisWeBkC-Rer6I4J7kZ', 59 | 'not a key' 60 | ) 61 | expect(run).toThrowError('bad key size') 62 | }) 63 | 64 | test('Failure - Wrong key', () => { 65 | const run = () => 66 | decrypt( 67 | '1.G3Yvm4G3X97h2-ky8ORVIlVMBbHNVFd_.cNFG6eisWeBkC-Rer6I4J7kZ', 68 | 'KatLceVEOM2znzX_FGPKu6Zz1adWkhlq9b2R9WRjUsM=' 69 | ) 70 | expect(run).toThrowError('Could not decrypt payload (wrong key)') 71 | }) 72 | 73 | // -- 74 | 75 | test('Inject key in current URL', () => { 76 | const url = applyKeyToURL('KatLceVEOM2znzX_FGPKu6Zz1adWkhlq9b2R9WRjUsM=') 77 | expect(url).toEqual( 78 | 'http://localhost/#KatLceVEOM2znzX_FGPKu6Zz1adWkhlq9b2R9WRjUsM=' 79 | ) 80 | }) 81 | 82 | test('Inject key in URL', () => { 83 | const url = applyKeyToURL( 84 | 'KatLceVEOM2znzX_FGPKu6Zz1adWkhlq9b2R9WRjUsM=', 85 | 'https://example.com' 86 | ) 87 | expect(url).toEqual( 88 | 'https://example.com/#KatLceVEOM2znzX_FGPKu6Zz1adWkhlq9b2R9WRjUsM=' 89 | ) 90 | }) 91 | 92 | test('Inject key in URL that already has a hash', () => { 93 | const run = () => 94 | applyKeyToURL( 95 | 'KatLceVEOM2znzX_FGPKu6Zz1adWkhlq9b2R9WRjUsM=', 96 | 'https://example.com#already-hashed' 97 | ) 98 | expect(run).toThrow() 99 | }) 100 | 101 | test('Get key from URL', () => { 102 | const key = getKeyFromURL( 103 | 'https://example.com#KatLceVEOM2znzX_FGPKu6Zz1adWkhlq9b2R9WRjUsM=' 104 | ) 105 | expect(key).toEqual('KatLceVEOM2znzX_FGPKu6Zz1adWkhlq9b2R9WRjUsM=') 106 | }) 107 | 108 | test('Get key from URL without a hash', () => { 109 | const run = () => getKeyFromURL() 110 | expect(run).toThrow('Could not retrieve key: no hash in URL') 111 | }) 112 | 113 | test('Get key from current URL', () => { 114 | window.location.hash = 'KatLceVEOM2znzX_FGPKu6Zz1adWkhlq9b2R9WRjUsM=' 115 | const key = getKeyFromURL() 116 | expect(key).toEqual('KatLceVEOM2znzX_FGPKu6Zz1adWkhlq9b2R9WRjUsM=') 117 | window.location.hash = '' 118 | }) 119 | --------------------------------------------------------------------------------