├── .eslintrc ├── .github ├── husky │ └── commit-msg └── workflows │ ├── pull_request.yml │ └── release.yml ├── .gitignore ├── .vscode └── launch.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── index.html ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── base64Url.ts ├── cryptoClients │ ├── index.ts │ ├── secp256k1.ts │ └── sha256.ts ├── decode.ts ├── ecdsaSigFormatter.ts ├── errors.ts ├── index.ts ├── signer.ts ├── test │ ├── .eslintrc │ ├── browserifyApp.ts │ ├── cryptoClientTests.ts │ └── mainTests.ts └── verifier.ts ├── tsconfig.build.json ├── tsconfig.json └── webpack.config.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@stacks/eslint-config", 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "project": "./tsconfig.json", 6 | "sourceType": "module" 7 | }, 8 | "plugins": [ 9 | "@typescript-eslint" 10 | ], 11 | "env": { 12 | "browser": true, 13 | "es6": true, 14 | "node": true 15 | }, 16 | "rules": { 17 | "@typescript-eslint/no-unsafe-assignment": 0, 18 | "@typescript-eslint/no-unsafe-member-access": 0, 19 | "@typescript-eslint/no-var-requires": 0, 20 | "@typescript-eslint/no-unsafe-call": 0 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit 5 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: pull request 2 | 3 | on: [pull_request, workflow_dispatch] 4 | 5 | jobs: 6 | pre_run: 7 | name: Cancel previous runs 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Cancel Previous Runs 11 | uses: styfle/cancel-workflow-action@ad6cb1b847ffb509a69b745b6ee2f1d14dfe14b8 12 | with: 13 | access_token: ${{ github.token }} 14 | 15 | code_checks: 16 | name: Code checks 17 | runs-on: ubuntu-latest 18 | needs: pre_run 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Set Node Version 22 | uses: actions/setup-node@v2 23 | with: 24 | node-version: 16.14.2 25 | - name: Install deps 26 | run: npm ci 27 | - name: Code Checks 28 | run: npm run prepublishOnly 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | paths-ignore: 9 | - "**.json" 10 | - "**.md" 11 | 12 | workflow_dispatch: 13 | 14 | jobs: 15 | pre_run: 16 | name: Cancel previous runs 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Cancel Previous Runs 20 | uses: styfle/cancel-workflow-action@ad6cb1b847ffb509a69b745b6ee2f1d14dfe14b8 21 | with: 22 | access_token: ${{ github.token }} 23 | 24 | release: 25 | name: Release 26 | runs-on: ubuntu-latest 27 | 28 | if: "github.event_name == 'push' && contains(github.event.head_commit.message, 'chore(release): publish')" 29 | needs: 30 | - pre_run 31 | 32 | steps: 33 | - uses: actions/checkout@v2 34 | with: 35 | token: ${{ secrets.GH_TOKEN }} 36 | 37 | - name: Semantic Release 38 | uses: cycjimmy/semantic-release-action@v3 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 41 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 42 | SEMANTIC_RELEASE_PACKAGE: ${{ github.event.repository.name }} 43 | with: 44 | extra_plugins: | 45 | @semantic-release/commit-analyzer 46 | @semantic-release/changelog 47 | @semantic-release/exec 48 | @semantic-release/git 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | # Custom 30 | lib 31 | lib/*.js 32 | lib/*/*.js 33 | unused 34 | .DS_Store 35 | .nyc_output/* 36 | dist 37 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Jest", 11 | "program": "${workspaceFolder}/node_modules/.bin/jest", 12 | "args": [ 13 | "--runInBand", 14 | "--coverage=false", 15 | "--no-cache", 16 | "--testTimeout=0", 17 | "--config", 18 | "${workspaceRoot}/jest.config.js" 19 | ] 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to the project will be documented in this file. 3 | 4 | ## [4.0.1](https://github.com/stacks-network/jsontokens-js/compare/v4.0.0...v4.0.1) (2022-08-27) 5 | 6 | 7 | ### Bug Fixes 8 | 9 | * add base64url without buffer ([7dc8d53](https://github.com/stacks-network/jsontokens-js/commit/7dc8d531398b8ec01d0dcf2494edf8c825da1143)) 10 | 11 | # [4.0.0](https://github.com/stacks-network/jsontokens-js/compare/v3.1.1...v4.0.0) (2022-08-25) 12 | 13 | 14 | * feat!: remove buffer ([08856ac](https://github.com/stacks-network/jsontokens-js/commit/08856ac6c159943a101b690b7d9863f8ad06490d)) 15 | 16 | 17 | ### BREAKING CHANGES 18 | 19 | * Removes the `buffer` dependency and switches to the more modern Uint8Array 20 | 21 | ## [3.1.1](https://github.com/stacks-network/jsontokens-js/compare/v3.1.0...v3.1.1) (2022-06-01) 22 | 23 | 24 | ### Bug Fixes 25 | 26 | * allow compressed private keys ([a7cfc6a](https://github.com/stacks-network/jsontokens-js/commit/a7cfc6ae833e661bfee51f6baf7490b3c41b14f5)) 27 | 28 | # [3.1.0](https://github.com/stacks-network/jsontokens-js/compare/v3.0.0...v3.1.0) (2022-05-31) 29 | 30 | 31 | ### Features 32 | 33 | * replace crypto dependencies ([50bc8eb](https://github.com/stacks-network/jsontokens-js/commit/50bc8eba918e23adaaf2794d75d07f6b8635cffc)) 34 | 35 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) 36 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 37 | 38 | ## [3.0.0] 39 | ### Changed 40 | - Added async functions that use Web Crypto API used for hashing, if available. Otherwise uses the Node.js `crypto` module. 41 | 42 | ## [2.0.3] 43 | ### Changed 44 | - No longer exporting buggy `@types/bn.js` package. Lib consumers no longer require enabling 45 | synthetic default imports. 46 | 47 | ## [2.0.2] 48 | ### Changed 49 | - Fixed bug with type packages listed in `devDependencies` instead of `dependencies`. 50 | 51 | ## [2.0.1] 52 | ### Added 53 | - Added types to [TokenInterface](https://github.com/blockstack/jsontokens-js/issues/39). 54 | 55 | ## [2.0.0] 56 | ### Changed 57 | - Ported to Typescript. 58 | 59 | ## [1.0.0] 60 | ### Changed 61 | - We now have an .eslintrc definition and code that passes that linting spec. 62 | 63 | ## [0.8.0] 64 | ### Added 65 | - You can now add custom header fields to the JWS header by passing 66 | an object to the `TokenSigner.sign()` method's `customHeader` parameter. 67 | 68 | ### Changed 69 | - Use `Buffer.from` instead of deprecated `new Buffer()` 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Halfmoon Labs, Inc. 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSON Tokens JS 2 | 3 | [![CircleCI](https://img.shields.io/circleci/project/blockstack/jsontokens-js/master.svg)](https://circleci.com/gh/blockstack/jsontokens-js/tree/master) 4 | [![npm](https://img.shields.io/npm/l/jsontokens.svg)](https://www.npmjs.com/package/jsontokens) 5 | [![npm](https://img.shields.io/npm/v/jsontokens.svg)](https://www.npmjs.com/package/jsontokens) 6 | [![npm](https://img.shields.io/npm/dm/jsontokens.svg)](https://www.npmjs.com/package/jsontokens) 7 | [![Slack](https://img.shields.io/badge/join-slack-e32072.svg?style=flat)](http://slack.blockstack.org/) 8 | 9 | node.js library for signing, decoding, and verifying JSON Web Tokens (JWTs) with the ES256K signature scheme (which uses the secp256k elliptic curve). This is currently the only supported signing and verification scheme for this library. 10 | 11 | ### Installation 12 | 13 | ``` 14 | npm install jsontokens 15 | ``` 16 | 17 | ### Signing Tokens 18 | 19 | ```js 20 | import { TokenSigner } from 'jsontokens' 21 | 22 | const rawPrivateKey = '278a5de700e29faae8e40e366ec5012b5ec63d36ec77e8a2417154cc1d25383f' 23 | const tokenPayload = {"iat": 1440713414.85} 24 | const token = new TokenSigner('ES256K', rawPrivateKey).sign(tokenPayload) 25 | ``` 26 | 27 | ### Creating Unsecured Tokens 28 | 29 | ```js 30 | import { createUnsecuredToken } from 'jsontokens' 31 | 32 | const unsecuredToken = createUnsecuredToken(tokenPayload) 33 | ``` 34 | 35 | ### Decoding Tokens 36 | 37 | ```js 38 | import { decodeToken } = from 'jsontokens' 39 | const tokenData = decodeToken(token) 40 | ``` 41 | 42 | ### Verifying Tokens 43 | 44 | The TokenVerifier class will validate that a token is correctly signed. It does not perform checks on the claims in the payload (e.g., the `exp` field)--- checking the expiration field, etc., is left as a requirement for callers. 45 | 46 | ```js 47 | import { TokenVerifier } from 'jsontokens' 48 | const rawPublicKey = '03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479' 49 | const verified = new TokenVerifier('ES256K', rawPublicKey).verify(token) 50 | ``` 51 | 52 | ### Example Tokens 53 | 54 | ```text 55 | eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk 56 | ``` 57 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Browserify Test 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | coverageDirectory: './coverage/', 5 | collectCoverage: true, 6 | testMatch: ['**/test/**/*.ts'], 7 | testPathIgnorePatterns: ['/node_modules/', 'browserifyApp.ts'] 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jsontokens", 3 | "version": "4.0.1", 4 | "description": "node.js library for encoding, decoding, and verifying JSON Web Tokens (JWTs)", 5 | "main": "lib/index.js", 6 | "unpkg": "dist/jsontokens.js", 7 | "jsdelivr": "dist/jsontokens.js", 8 | "browser": { 9 | "crypto": false 10 | }, 11 | "prettier": "@stacks/prettier-config", 12 | "scripts": { 13 | "webpack": "rimraf lib dist && webpack --mode=production", 14 | "build": "rimraf lib && tsc -b tsconfig.build.json", 15 | "prettier": "prettier --write ./src/**/*.ts", 16 | "lint": "eslint --ext .ts ./src", 17 | "test": "jest ./src/test/", 18 | "codecovUpload": "codecov", 19 | "prepublishOnly": "npm run lint && npm run test && npm run webpack && npm run build", 20 | "prepare": "husky install .github/husky" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/stacks-network/jsontokens-js.git" 25 | }, 26 | "keywords": [ 27 | "jwt", 28 | "json", 29 | "web", 30 | "token", 31 | "encode", 32 | "decode", 33 | "verify", 34 | "ecdsa", 35 | "secp256k1", 36 | "ec", 37 | "elliptic", 38 | "curve", 39 | "signature", 40 | "sign" 41 | ], 42 | "author": "Blockstack PBC", 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/stacks-network/jsontokens-js/issues" 46 | }, 47 | "homepage": "https://github.com/stacks-network/jsontokens-js#readme", 48 | "dependencies": { 49 | "@noble/hashes": "^1.1.2", 50 | "@noble/secp256k1": "^1.6.3", 51 | "base64-js": "^1.5.1" 52 | }, 53 | "devDependencies": { 54 | "@babel/core": "^7.17.10", 55 | "@babel/preset-env": "^7.17.10", 56 | "@commitlint/cli": "^16.2.4", 57 | "@commitlint/config-conventional": "^16.2.4", 58 | "@peculiar/webcrypto": "^1.0.21", 59 | "@stacks/eslint-config": "^1.2.0", 60 | "@stacks/prettier-config": "^0.0.10", 61 | "@types/jest": "^27.5.0", 62 | "@types/node": "^12.12.7", 63 | "@typescript-eslint/eslint-plugin": "^5.22.0", 64 | "@typescript-eslint/parser": "^5.22.0", 65 | "babel-loader": "^8.2.5", 66 | "codecov": "^3.8.3", 67 | "cross-env": "^6.0.3", 68 | "eslint": "^8.15.0", 69 | "eslint-import-resolver-typescript": "^2.7.1", 70 | "eslint-plugin-jest": "^26.1.5", 71 | "eslint-plugin-prettier": "^4.0.0", 72 | "husky": "^8.0.1", 73 | "jest": "^28.1.0", 74 | "prettier": "^2.6.2", 75 | "rimraf": "^3.0.0", 76 | "source-map-support": "^0.5.16", 77 | "ts-jest": "^28.0.2", 78 | "ts-loader": "^9.3.0", 79 | "ts-node": "^10.7.0", 80 | "typescript": "^4.6.4", 81 | "webpack": "^5.72.0", 82 | "webpack-cli": "^4.9.2" 83 | }, 84 | "files": [ 85 | "dist", 86 | "lib" 87 | ], 88 | "commitlint": { 89 | "extends": [ 90 | "@commitlint/config-conventional" 91 | ] 92 | }, 93 | "release": { 94 | "branches": "master", 95 | "plugins": [ 96 | "@semantic-release/commit-analyzer", 97 | "@semantic-release/release-notes-generator", 98 | [ 99 | "@semantic-release/exec", 100 | { 101 | "prepareCmd": "npm ci" 102 | } 103 | ], 104 | [ 105 | "@semantic-release/npm", 106 | { 107 | "npmPublish": true 108 | } 109 | ], 110 | [ 111 | "@semantic-release/changelog", 112 | { 113 | "changelogTitle": "# Changelog\nAll notable changes to the project will be documented in this file." 114 | } 115 | ], 116 | [ 117 | "@semantic-release/git", 118 | { 119 | "message": "chore: release ${nextRelease.version}", 120 | "assets": [ 121 | "*.{json,md}" 122 | ] 123 | } 124 | ] 125 | ] 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/base64Url.ts: -------------------------------------------------------------------------------- 1 | import { fromByteArray, toByteArray } from 'base64-js'; 2 | 3 | export function pad(base64: string): string { 4 | return `${base64}${'='.repeat(4 - (base64.length % 4 || 4))}`; 5 | } 6 | 7 | export function escape(base64: string): string { 8 | return base64.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); 9 | } 10 | 11 | export function unescape(base64Url: string): string { 12 | return pad(base64Url).replace(/-/g, '+').replace(/_/g, '/'); 13 | } 14 | 15 | export function encode(base64: string): string { 16 | return escape(fromByteArray(new TextEncoder().encode(base64))); 17 | } 18 | 19 | export function decode(base64Url: string): string { 20 | return new TextDecoder().decode(toByteArray(pad(unescape(base64Url)))); 21 | } 22 | -------------------------------------------------------------------------------- /src/cryptoClients/index.ts: -------------------------------------------------------------------------------- 1 | import { SECP256K1Client } from './secp256k1'; 2 | 3 | const cryptoClients: { 4 | [index: string]: typeof SECP256K1Client; 5 | ES256K: typeof SECP256K1Client; 6 | } = { 7 | ES256K: SECP256K1Client, 8 | }; 9 | 10 | export { SECP256K1Client, cryptoClients }; 11 | -------------------------------------------------------------------------------- /src/cryptoClients/secp256k1.ts: -------------------------------------------------------------------------------- 1 | import { hmac } from '@noble/hashes/hmac'; 2 | import { sha256 } from '@noble/hashes/sha256'; 3 | import * as secp from '@noble/secp256k1'; 4 | import { derToJose, joseToDer } from '../ecdsaSigFormatter'; 5 | import { MissingParametersError } from '../errors'; 6 | import { bytesToHex } from '@noble/hashes/utils'; 7 | 8 | // required to use noble secp https://github.com/paulmillr/noble-secp256k1 9 | secp.utils.hmacSha256Sync = (key: Uint8Array, ...msgs: Uint8Array[]) => { 10 | const h = hmac.create(sha256, key); 11 | msgs.forEach(msg => h.update(msg)); 12 | return h.digest(); 13 | }; 14 | 15 | export class SECP256K1Client { 16 | static algorithmName = 'ES256K'; 17 | 18 | static derivePublicKey(privateKey: string, compressed = true): string { 19 | if (privateKey.length === 66) { 20 | privateKey = privateKey.slice(0, 64); 21 | } 22 | if (privateKey.length < 64) { 23 | // backward compatibly accept too short private keys 24 | privateKey = privateKey.padStart(64, '0'); 25 | } 26 | return bytesToHex(secp.getPublicKey(privateKey, compressed)); 27 | } 28 | 29 | static signHash(signingInputHash: string | Uint8Array, privateKey: string, format = 'jose') { 30 | // make sure the required parameters are provided 31 | if (!signingInputHash || !privateKey) { 32 | throw new MissingParametersError('a signing input hash and private key are all required'); 33 | } 34 | 35 | const derSignature = secp.signSync(signingInputHash, privateKey.slice(0, 64), { 36 | der: true, 37 | canonical: false, 38 | }); 39 | 40 | if (format === 'der') return bytesToHex(derSignature); 41 | if (format === 'jose') return derToJose(derSignature, 'ES256'); 42 | 43 | throw Error('Invalid signature format'); 44 | } 45 | 46 | static loadSignature(joseSignature: string | Uint8Array) { 47 | // create and return the DER-formatted signature bytes 48 | return joseToDer(joseSignature, 'ES256'); 49 | } 50 | 51 | static verifyHash( 52 | signingInputHash: Uint8Array, 53 | derSignatureBytes: string | Uint8Array, 54 | publicKey: string | Uint8Array 55 | ) { 56 | // make sure the required parameters are provided 57 | if (!signingInputHash || !derSignatureBytes || !publicKey) { 58 | throw new MissingParametersError( 59 | 'a signing input hash, der signature, and public key are all required' 60 | ); 61 | } 62 | 63 | return secp.verify(derSignatureBytes, signingInputHash, publicKey, { strict: false }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/cryptoClients/sha256.ts: -------------------------------------------------------------------------------- 1 | import { sha256 } from '@noble/hashes/sha256'; 2 | 3 | export function hashSha256(input: Uint8Array | string): Uint8Array { 4 | return sha256(input); 5 | } 6 | 7 | export async function hashSha256Async(input: Uint8Array | string): Promise { 8 | try { 9 | const isSubtleCryptoAvailable = 10 | typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined'; 11 | if (isSubtleCryptoAvailable) { 12 | // Use the W3C Web Crypto API if available (running in a web browser). 13 | const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : input; 14 | const hash = await crypto.subtle.digest('SHA-256', bytes); 15 | return new Uint8Array(hash); 16 | } else { 17 | // Otherwise try loading the Node.js `crypto` module (running in Node.js, or an older browser with a polyfill). 18 | const nodeCrypto = require('crypto') as typeof import('crypto'); 19 | if (!nodeCrypto.createHash) { 20 | throw new Error('`crypto` module does not contain `createHash`'); 21 | } 22 | return Promise.resolve(nodeCrypto.createHash('sha256').update(input).digest()); 23 | } 24 | } catch (error) { 25 | console.log(error); 26 | console.log( 27 | 'Crypto lib not found. Neither the global `crypto.subtle` Web Crypto API, ' + 28 | 'nor the or the Node.js `require("crypto").createHash` module is available. ' + 29 | 'Falling back to JS implementation.' 30 | ); 31 | return Promise.resolve(hashSha256(input)); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/decode.ts: -------------------------------------------------------------------------------- 1 | import * as base64url from './base64Url'; 2 | 3 | export interface TokenInterface { 4 | header: { 5 | [key: string]: Json; 6 | alg?: string; 7 | typ?: string; 8 | }; 9 | payload: 10 | | { 11 | [key: string]: Json; 12 | iss?: string; 13 | jti?: string; 14 | iat?: string | number; 15 | exp?: string | number; 16 | } 17 | | string; 18 | signature: string; 19 | } 20 | 21 | export type Json = string | number | boolean | null | { [property: string]: Json } | Json[]; 22 | 23 | export function decodeToken(token: string | TokenInterface): TokenInterface { 24 | if (typeof token === 'string') { 25 | // decompose the token into parts 26 | const tokenParts = token.split('.'); 27 | const header = JSON.parse(base64url.decode(tokenParts[0])); 28 | const payload = JSON.parse(base64url.decode(tokenParts[1])); 29 | const signature = tokenParts[2]; 30 | 31 | // return the token object 32 | return { 33 | header: header, 34 | payload: payload, 35 | signature: signature, 36 | }; 37 | } else if (typeof token === 'object') { 38 | if (typeof token.payload !== 'string') { 39 | throw new Error('Expected token payload to be a base64 or json string'); 40 | } 41 | let payload = token.payload; 42 | if (token.payload[0] !== '{') { 43 | payload = base64url.decode(payload); 44 | } 45 | 46 | const allHeaders: any = []; 47 | (token.header as any).map((headerValue: string) => { 48 | const header = JSON.parse(base64url.decode(headerValue)); 49 | allHeaders.push(header); 50 | }); 51 | 52 | return { 53 | header: allHeaders, 54 | payload: JSON.parse(payload), 55 | signature: token.signature, 56 | }; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/ecdsaSigFormatter.ts: -------------------------------------------------------------------------------- 1 | // NOTICE 2 | // Copyright 2015 D2L Corporation 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | // The following code is adapted from https://github.com/Brightspace/node-ecdsa-sig-formatter 17 | 18 | import { fromByteArray, toByteArray } from 'base64-js'; 19 | import { escape, pad } from './base64Url'; 20 | 21 | function getParamSize(keySize: number): number { 22 | return ((keySize / 8) | 0) + (keySize % 8 === 0 ? 0 : 1); 23 | } 24 | 25 | export type Alg = 'ES256' | 'ES384' | 'ES512'; 26 | 27 | const paramBytesForAlg = { 28 | ES256: getParamSize(256), 29 | ES384: getParamSize(384), 30 | ES512: getParamSize(521), 31 | } as Record; 32 | 33 | function getParamBytesForAlg(alg: Alg): number { 34 | const paramBytes = paramBytesForAlg[alg]; 35 | if (paramBytes) { 36 | return paramBytes; 37 | } 38 | 39 | throw new Error(`Unknown algorithm "${alg}"`); 40 | } 41 | 42 | const MAX_OCTET = 0x80; 43 | const CLASS_UNIVERSAL = 0; 44 | const PRIMITIVE_BIT = 0x20; 45 | const TAG_SEQ = 0x10; 46 | const TAG_INT = 0x02; 47 | const ENCODED_TAG_SEQ = TAG_SEQ | PRIMITIVE_BIT | (CLASS_UNIVERSAL << 6); 48 | const ENCODED_TAG_INT = TAG_INT | (CLASS_UNIVERSAL << 6); 49 | 50 | function signatureAsBytes(signature: string | Uint8Array) { 51 | if (signature instanceof Uint8Array) { 52 | return signature; 53 | } else if ('string' === typeof signature) { 54 | return toByteArray(pad(signature)); 55 | } 56 | 57 | throw new TypeError('ECDSA signature must be a Base64 string or a Uint8Array'); 58 | } 59 | 60 | export function derToJose(signature: string | Uint8Array, alg: Alg) { 61 | const signatureBytes = signatureAsBytes(signature); 62 | const paramBytes = getParamBytesForAlg(alg); 63 | 64 | // the DER encoded param should at most be the param size, plus a padding 65 | // zero, since due to being a signed integer 66 | const maxEncodedParamLength = paramBytes + 1; 67 | 68 | const inputLength = signatureBytes.length; 69 | 70 | let offset = 0; 71 | if (signatureBytes[offset++] !== ENCODED_TAG_SEQ) { 72 | throw new Error('Could not find expected "seq"'); 73 | } 74 | 75 | let seqLength = signatureBytes[offset++]; 76 | if (seqLength === (MAX_OCTET | 1)) { 77 | seqLength = signatureBytes[offset++]; 78 | } 79 | 80 | if (inputLength - offset < seqLength) { 81 | throw new Error( 82 | `"seq" specified length of "${seqLength}", only "${inputLength - offset}" remaining` 83 | ); 84 | } 85 | 86 | if (signatureBytes[offset++] !== ENCODED_TAG_INT) { 87 | throw new Error('Could not find expected "int" for "r"'); 88 | } 89 | 90 | const rLength = signatureBytes[offset++]; 91 | 92 | if (inputLength - offset - 2 < rLength) { 93 | throw new Error( 94 | `"r" specified length of "${rLength}", only "${inputLength - offset - 2}" available` 95 | ); 96 | } 97 | 98 | if (maxEncodedParamLength < rLength) { 99 | throw new Error( 100 | `"r" specified length of "${rLength}", max of "${maxEncodedParamLength}" is acceptable` 101 | ); 102 | } 103 | 104 | const rOffset = offset; 105 | offset += rLength; 106 | 107 | if (signatureBytes[offset++] !== ENCODED_TAG_INT) { 108 | throw new Error('Could not find expected "int" for "s"'); 109 | } 110 | 111 | const sLength = signatureBytes[offset++]; 112 | 113 | if (inputLength - offset !== sLength) { 114 | throw new Error(`"s" specified length of "${sLength}", expected "${inputLength - offset}"`); 115 | } 116 | 117 | if (maxEncodedParamLength < sLength) { 118 | throw new Error( 119 | `"s" specified length of "${sLength}", max of "${maxEncodedParamLength}" is acceptable` 120 | ); 121 | } 122 | 123 | const sOffset = offset; 124 | offset += sLength; 125 | 126 | if (offset !== inputLength) { 127 | throw new Error(`Expected to consume entire array, but "${inputLength - offset}" bytes remain`); 128 | } 129 | 130 | const rPadding = paramBytes - rLength; 131 | const sPadding = paramBytes - sLength; 132 | 133 | const dst = new Uint8Array(rPadding + rLength + sPadding + sLength); 134 | 135 | for (offset = 0; offset < rPadding; ++offset) { 136 | dst[offset] = 0; 137 | } 138 | dst.set(signatureBytes.subarray(rOffset + Math.max(-rPadding, 0), rOffset + rLength), offset); 139 | 140 | offset = paramBytes; 141 | 142 | for (const o = offset; offset < o + sPadding; ++offset) { 143 | dst[offset] = 0; 144 | } 145 | dst.set(signatureBytes.subarray(sOffset + Math.max(-sPadding, 0), sOffset + sLength), offset); 146 | 147 | return escape(fromByteArray(dst)); 148 | } 149 | 150 | function countPadding(buf: Uint8Array, start: number, stop: number) { 151 | let padding = 0; 152 | while (start + padding < stop && buf[start + padding] === 0) { 153 | ++padding; 154 | } 155 | 156 | const needsSign = buf[start + padding] >= MAX_OCTET; 157 | if (needsSign) { 158 | --padding; 159 | } 160 | 161 | return padding; 162 | } 163 | 164 | export function joseToDer(signature: string | Uint8Array, alg: Alg) { 165 | signature = signatureAsBytes(signature); 166 | const paramBytes = getParamBytesForAlg(alg); 167 | 168 | const signatureBytes = signature.length; 169 | if (signatureBytes !== paramBytes * 2) { 170 | throw new TypeError( 171 | `"${alg}" signatures must be "${paramBytes * 2}" bytes, saw "${signatureBytes}"` 172 | ); 173 | } 174 | 175 | const rPadding = countPadding(signature, 0, paramBytes); 176 | const sPadding = countPadding(signature, paramBytes, signature.length); 177 | const rLength = paramBytes - rPadding; 178 | const sLength = paramBytes - sPadding; 179 | 180 | const rsBytes = 1 + 1 + rLength + 1 + 1 + sLength; 181 | 182 | const shortLength = rsBytes < MAX_OCTET; 183 | 184 | const dst = new Uint8Array((shortLength ? 2 : 3) + rsBytes); 185 | 186 | let offset = 0; 187 | dst[offset++] = ENCODED_TAG_SEQ; 188 | if (shortLength) { 189 | // Bit 8 has value "0" 190 | // bits 7-1 give the length. 191 | dst[offset++] = rsBytes; 192 | } else { 193 | // Bit 8 of first octet has value "1" 194 | // bits 7-1 give the number of additional length octets. 195 | dst[offset++] = MAX_OCTET | 1; 196 | // length, base 256 197 | dst[offset++] = rsBytes & 0xff; 198 | } 199 | dst[offset++] = ENCODED_TAG_INT; 200 | dst[offset++] = rLength; 201 | if (rPadding < 0) { 202 | dst[offset++] = 0; 203 | dst.set(signature.subarray(0, paramBytes), offset); 204 | offset += paramBytes; 205 | } else { 206 | dst.set(signature.subarray(rPadding, paramBytes), offset); 207 | offset += paramBytes - rPadding; 208 | } 209 | dst[offset++] = ENCODED_TAG_INT; 210 | dst[offset++] = sLength; 211 | if (sPadding < 0) { 212 | dst[offset++] = 0; 213 | dst.set(signature.subarray(paramBytes), offset); 214 | } else { 215 | dst.set(signature.subarray(paramBytes + sPadding), offset); 216 | } 217 | 218 | return dst; 219 | } 220 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | export class MissingParametersError extends Error { 2 | constructor(message: string) { 3 | super(); 4 | this.name = 'MissingParametersError'; 5 | this.message = message || ''; 6 | } 7 | } 8 | 9 | export class InvalidTokenError extends Error { 10 | constructor(message: string) { 11 | super(); 12 | this.name = 'InvalidTokenError'; 13 | this.message = message || ''; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './signer'; 2 | export * from './verifier'; 3 | export * from './decode'; 4 | export * from './errors'; 5 | export * from './cryptoClients'; 6 | -------------------------------------------------------------------------------- /src/signer.ts: -------------------------------------------------------------------------------- 1 | import * as base64url from './base64Url'; 2 | import { cryptoClients, SECP256K1Client } from './cryptoClients'; 3 | import { MissingParametersError } from './errors'; 4 | import { Json } from './decode'; 5 | import { hashSha256, hashSha256Async } from './cryptoClients/sha256'; 6 | 7 | function createSigningInput(payload: Json, header: Json) { 8 | const tokenParts = []; 9 | 10 | // add in the header 11 | const encodedHeader = base64url.encode(JSON.stringify(header)); 12 | tokenParts.push(encodedHeader); 13 | 14 | // add in the payload 15 | const encodedPayload = base64url.encode(JSON.stringify(payload)); 16 | tokenParts.push(encodedPayload); 17 | 18 | // prepare the message 19 | const signingInput = tokenParts.join('.'); 20 | 21 | // return the signing input 22 | return signingInput; 23 | } 24 | 25 | export function createUnsecuredToken(payload: Json) { 26 | const header = { typ: 'JWT', alg: 'none' }; 27 | return createSigningInput(payload, header) + '.'; 28 | } 29 | 30 | export interface SignedToken { 31 | header: string[]; 32 | payload: string; 33 | signature: string[]; 34 | } 35 | 36 | export class TokenSigner { 37 | tokenType: string; 38 | cryptoClient: typeof SECP256K1Client; 39 | rawPrivateKey: string; 40 | 41 | constructor(signingAlgorithm: string, rawPrivateKey: string) { 42 | if (!(signingAlgorithm && rawPrivateKey)) { 43 | throw new MissingParametersError('a signing algorithm and private key are required'); 44 | } 45 | if (typeof signingAlgorithm !== 'string') { 46 | throw new Error('signing algorithm parameter must be a string'); 47 | } 48 | signingAlgorithm = signingAlgorithm.toUpperCase(); 49 | if (!cryptoClients.hasOwnProperty(signingAlgorithm)) { 50 | throw new Error('invalid signing algorithm'); 51 | } 52 | this.tokenType = 'JWT'; 53 | this.cryptoClient = cryptoClients[signingAlgorithm]; 54 | this.rawPrivateKey = rawPrivateKey; 55 | } 56 | 57 | header(header = {}) { 58 | const defaultHeader = { typ: this.tokenType, alg: this.cryptoClient.algorithmName }; 59 | return Object.assign({}, defaultHeader, header); 60 | } 61 | 62 | sign(payload: Json): string; 63 | sign(payload: Json, expanded: true, customHeader?: Json): SignedToken; 64 | sign(payload: Json, expanded: false, customHeader?: Json): string; 65 | sign(payload: Json, expanded = false, customHeader: Json = {}): SignedToken | string { 66 | // generate the token header 67 | const header = this.header(customHeader); 68 | 69 | // prepare the message to be signed 70 | const signingInput = createSigningInput(payload, header); 71 | const signingInputHash = hashSha256(signingInput); 72 | return this.createWithSignedHash(payload, expanded, header, signingInput, signingInputHash); 73 | } 74 | 75 | signAsync(payload: Json): Promise; 76 | signAsync(payload: Json, expanded: true, customHeader?: Json): Promise; 77 | signAsync(payload: Json, expanded: false, customHeader?: Json): Promise; 78 | async signAsync(payload: Json, expanded = false, customHeader: Json = {}) { 79 | // generate the token header 80 | const header = this.header(customHeader); 81 | 82 | // prepare the message to be signed 83 | const signingInput = createSigningInput(payload, header); 84 | const signingInputHash = await hashSha256Async(signingInput); 85 | return this.createWithSignedHash(payload, expanded, header, signingInput, signingInputHash); 86 | } 87 | 88 | createWithSignedHash( 89 | payload: Json, 90 | expanded: boolean, 91 | header: { typ: string; alg: string }, 92 | signingInput: string, 93 | signingInputHash: Uint8Array 94 | ): SignedToken | string { 95 | // sign the message and add in the signature 96 | const signature = this.cryptoClient.signHash(signingInputHash, this.rawPrivateKey); 97 | 98 | if (expanded) { 99 | const signedToken: SignedToken = { 100 | header: [base64url.encode(JSON.stringify(header))], 101 | payload: JSON.stringify(payload), 102 | signature: [signature], 103 | }; 104 | return signedToken; 105 | } else { 106 | return [signingInput, signature].join('.'); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "../../.eslintrc" 4 | ], 5 | "plugins": [ 6 | "jest" 7 | ], 8 | "env": { 9 | "jest/globals": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/test/browserifyApp.ts: -------------------------------------------------------------------------------- 1 | export { TokenSigner, TokenVerifier, decodeToken, MissingParametersError } from '../index'; 2 | 3 | import { SECP256K1Client } from '../index'; 4 | 5 | export { SECP256K1Client }; 6 | 7 | const hash = '278a5de700e29faae8e40e366ec5012b5ec63d36ec77e8a2417154cc1d25383f'; 8 | const rawPrivateKey = '278a5de700e29faae8e40e366ec5012b5ec63d36ec77e8a2417154cc1d25383f'; 9 | 10 | console.log('hash:'); 11 | console.log(hash); 12 | 13 | console.log('raw private key:'); 14 | console.log(rawPrivateKey); 15 | 16 | const signature = SECP256K1Client.signHash(hash, rawPrivateKey); 17 | 18 | console.log('signature:'); 19 | console.log(signature); 20 | -------------------------------------------------------------------------------- /src/test/cryptoClientTests.ts: -------------------------------------------------------------------------------- 1 | import { SECP256K1Client as secp256k1 } from '../index'; 2 | import { hashSha256Async } from '../cryptoClients/sha256'; 3 | 4 | describe('SECP256k1 tests', () => { 5 | runSECP256k1Tests(); 6 | }); 7 | 8 | function runSECP256k1Tests() { 9 | const privateKey = '278a5de700e29faae8e40e366ec5012b5ec63d36ec77e8a2417154cc1d25383f'; 10 | const privateKey2 = '278a5de700e29faae8e40e366ec5012b5ec63d36ec77e8a2417154cc1d25383f01'; 11 | const privateKey3 = '494651c7602fa047590386dbf48ad47ecd2a25ae4f0f39334e57f5bc62771f'; 12 | const publicKey = '03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479'; 13 | const publicKey3 = '02ccaa8fb748f1b1d260178092b8eb96be96097fb437a247ed03dbaf13fa5a5a35'; 14 | const uncompresedPublicKey = 15 | '04fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea535847946393f8145252eea68afe67e287b3ed9b31685ba6c3b00060a73b9b1242d68f7'; 16 | 17 | test('derivePublicKey 1', () => { 18 | const derivedPublicKey = secp256k1.derivePublicKey(privateKey); 19 | expect(derivedPublicKey).toBeTruthy(); 20 | expect(derivedPublicKey).toBe(publicKey); 21 | }); 22 | 23 | test('derivePublicKey 2', () => { 24 | const derivedPublicKey2 = secp256k1.derivePublicKey(privateKey2); 25 | expect(derivedPublicKey2).toBeTruthy(); 26 | expect(derivedPublicKey2).toBe(publicKey); 27 | }); 28 | 29 | test('derivePublicKey 3', () => { 30 | const derivedPublicKey3 = secp256k1.derivePublicKey(privateKey3); 31 | expect(derivedPublicKey3).toBeTruthy(); 32 | expect(derivedPublicKey3).toBe(publicKey3); 33 | }); 34 | 35 | test('derivePublicKey uncompressed', () => { 36 | const derivedUncompressedPublicKey = secp256k1.derivePublicKey(privateKey, false); 37 | expect(derivedUncompressedPublicKey).toBeTruthy(); 38 | expect(derivedUncompressedPublicKey).toBe(uncompresedPublicKey); 39 | }); 40 | 41 | test('createHash + signHash', async () => { 42 | const message = 'Hello, world!'; 43 | const referenceSignature = 44 | '3046022100997b6210d959e67ad9cee01589d01daf0fe77ce0f002d040d769171c33504860022100e35a03d2354074d7e49d0499568e331be39af901a543d1731ea1ff8f423f21ab'; 45 | 46 | const hash = await hashSha256Async(message); 47 | const signature = secp256k1.signHash(hash, privateKey, 'der'); 48 | 49 | expect(signature).toBeTruthy(); 50 | expect(typeof signature).toBe('string'); 51 | expect(signature).toBe(referenceSignature); 52 | }); 53 | 54 | test('signHash returns an equal signature for uncompressed/compressed keys', async () => { 55 | const message = 'Hello, world!'; 56 | const hash = await hashSha256Async(message); 57 | const signatureCompressed = secp256k1.signHash(hash, privateKey2, 'der'); 58 | const signatureUncompressed = secp256k1.signHash(hash, privateKey.slice(0, 64), 'der'); 59 | 60 | expect(signatureCompressed).toBe(signatureUncompressed); 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /src/test/mainTests.ts: -------------------------------------------------------------------------------- 1 | import * as base64url from '../base64Url'; 2 | 3 | import { 4 | TokenSigner, 5 | TokenVerifier, 6 | decodeToken, 7 | createUnsecuredToken, 8 | MissingParametersError, 9 | } from '../index'; 10 | 11 | import * as webcrypto from '@peculiar/webcrypto'; 12 | 13 | describe('main tests - node.js crypto', () => { 14 | let origGlobalCrypto: { defined: boolean; value: any }; 15 | beforeAll(() => { 16 | origGlobalCrypto = { 17 | defined: 'crypto' in global, 18 | value: (global as any)['crypto'], 19 | }; 20 | delete (global as any)['crypto']; 21 | (global as any)['crypto'] = new webcrypto.Crypto(); 22 | }); 23 | afterAll(() => { 24 | if (origGlobalCrypto.defined) { 25 | (global as any)['crypto'] = origGlobalCrypto.value; 26 | } else { 27 | delete (global as any)['crypto']; 28 | } 29 | }); 30 | runMainTests(); 31 | }); 32 | 33 | describe('main tests - sha.js crypto', () => { 34 | let origCreateHash: typeof import('crypto').createHash; 35 | beforeAll(() => { 36 | const nodeCrypto = require('crypto') as typeof import('crypto'); 37 | origCreateHash = nodeCrypto.createHash; 38 | delete nodeCrypto.createHash; 39 | }); 40 | afterAll(() => { 41 | const nodeCrypto = require('crypto') as typeof import('crypto'); 42 | nodeCrypto.createHash = origCreateHash; 43 | }); 44 | runMainTests(); 45 | }); 46 | 47 | describe('main tests - web crypto', () => { 48 | beforeAll(() => { 49 | (global as any)['crypto'] = new webcrypto.Crypto(); 50 | }); 51 | afterAll(() => { 52 | delete (global as any)['crypto']; 53 | }); 54 | runMainTests(); 55 | }); 56 | 57 | function runMainTests() { 58 | const rawPrivateKey = '278a5de700e29faae8e40e366ec5012b5ec63d36ec77e8a2417154cc1d25383f'; 59 | const rawPublicKey = '03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479'; 60 | const sampleToken = 61 | 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpc3N1ZWRBdCI6IjE0NDA3MTM0MTQuODUiLCJjaGFsbGVuZ2UiOiI3Y2Q5ZWQ1ZS1iYjBlLTQ5ZWEtYTMyMy1mMjhiZGUzYTA1NDkiLCJpc3N1ZXIiOnsicHVibGljS2V5IjoiMDNmZGQ1N2FkZWMzZDQzOGVhMjM3ZmU0NmIzM2VlMWUwMTZlZGE2YjU4NWMzZTI3ZWE2NjY4NmMyZWE1MzU4NDc5IiwiY2hhaW5QYXRoIjoiYmQ2Mjg4NWVjM2YwZTM4MzgwNDMxMTVmNGNlMjVlZWRkMjJjYzg2NzExODAzZmIwYzE5NjAxZWVlZjE4NWUzOSIsInB1YmxpY0tleWNoYWluIjoieHB1YjY2MU15TXdBcVJiY0ZRVnJRcjRRNGtQamFQNEpqV2FmMzlmQlZLalBkSzZvR0JheUU0NkdBbUt6bzVVRFBRZExTTTlEdWZaaVA4ZWF1eTU2WE51SGljQnlTdlpwN0o1d3N5UVZwaTJheHpaIiwiYmxvY2tjaGFpbmlkIjoicnlhbiJ9fQ.DUf6Rnw6FBKv4Q3y95RX7rR6HG_L1Va96ThcIYTycOf1j_bf9WleLsOyiZ-35Qfw7FgDnW7Utvz4sNjdWOSnhQ'; 62 | const sampleDecodedToken = { 63 | header: { typ: 'JWT', alg: 'ES256K' }, 64 | payload: { 65 | issuedAt: '1440713414.85', 66 | challenge: '7cd9ed5e-bb0e-49ea-a323-f28bde3a0549', 67 | issuer: { 68 | publicKey: '03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479', 69 | chainPath: 'bd62885ec3f0e3838043115f4ce25eedd22cc86711803fb0c19601eeef185e39', 70 | publicKeychain: 71 | 'xpub661MyMwAqRbcFQVrQr4Q4kPjaP4JjWaf39fBVKjPdK6oGBayE46GAmKzo5UDPQdLSM9DufZiP8eauy56XNuHicBySvZp7J5wsyQVpi2axzZ', 72 | blockchainid: 'ryan', 73 | }, 74 | }, 75 | signature: 76 | 'oO7ROPKq3T3X0azAXzHsf6ub6CYy5nUUFDoy8MS22B3TlYisqsBrRtzWIQcSYiFXLytrXwAdt6vjehj3OFioDQ', 77 | }; 78 | 79 | test('TokenSigner', async () => { 80 | const tokenSigner = new TokenSigner('ES256K', rawPrivateKey); 81 | expect(tokenSigner).toBeTruthy(); 82 | 83 | const token = await tokenSigner.signAsync(sampleDecodedToken.payload, false); 84 | const token1 = tokenSigner.sign(sampleDecodedToken.payload, false); 85 | expect(token).toStrictEqual(token1); 86 | expect(token).toBeTruthy(); 87 | expect(typeof token).toBe('string'); 88 | expect(token.split('.').length).toBe(3); 89 | 90 | const decodedToken = decodeToken(token); 91 | expect(JSON.stringify(decodedToken.header)).toBe(JSON.stringify(sampleDecodedToken.header)); 92 | expect(JSON.stringify(decodedToken.payload)).toBe(JSON.stringify(sampleDecodedToken.payload)); 93 | expect(() => new TokenSigner('ES256K', undefined)).toThrowError(MissingParametersError); 94 | }); 95 | 96 | test('TokenSigner custom header', async () => { 97 | const tokenSigner = new TokenSigner('ES256K', rawPrivateKey); 98 | expect(tokenSigner).toBeTruthy(); 99 | 100 | const token = await tokenSigner.signAsync(sampleDecodedToken.payload, false, { 101 | test: 'TestHeader', 102 | }); 103 | const token1 = tokenSigner.sign(sampleDecodedToken.payload, false, { test: 'TestHeader' }); 104 | expect(token).toStrictEqual(token1); 105 | expect(token).toBeTruthy(); 106 | expect(typeof token).toBe('string'); 107 | expect(token.split('.').length).toBe(3); 108 | 109 | const decodedToken = decodeToken(token); 110 | expect(JSON.stringify(decodedToken.header)).toBe( 111 | JSON.stringify(Object.assign({}, sampleDecodedToken.header, { test: 'TestHeader' })) 112 | ); 113 | 114 | expect(JSON.stringify(decodedToken.payload)).toBe(JSON.stringify(sampleDecodedToken.payload)); 115 | expect(() => new TokenSigner('ES256K', undefined)).toThrowError(MissingParametersError); 116 | }); 117 | 118 | test('createUnsecuredToken', () => { 119 | const unsecuredToken = createUnsecuredToken(sampleDecodedToken.payload); 120 | expect(unsecuredToken).toBeTruthy(); 121 | expect(unsecuredToken).toBe( 122 | base64url.encode(JSON.stringify({ typ: 'JWT', alg: 'none' })) + 123 | '.' + 124 | sampleToken.split('.')[1] + 125 | '.' 126 | ); 127 | 128 | const decodedToken = decodeToken(unsecuredToken); 129 | expect(decodedToken).toBeTruthy(); 130 | }); 131 | 132 | test('TokenVerifier', async () => { 133 | const tokenVerifier = new TokenVerifier('ES256K', rawPublicKey); 134 | expect(tokenVerifier).toBeTruthy(); 135 | 136 | const verified = await tokenVerifier.verifyAsync(sampleToken); 137 | const verified1 = tokenVerifier.verify(sampleToken); 138 | expect(verified).toStrictEqual(verified1); 139 | expect(verified).toBe(true); 140 | 141 | const tokenSigner = new TokenSigner('ES256K', rawPrivateKey); 142 | const newToken = await tokenSigner.signAsync(sampleDecodedToken.payload, false); 143 | const newToken1 = tokenSigner.sign(sampleDecodedToken.payload, false); 144 | expect(newToken).toStrictEqual(newToken1); 145 | expect(newToken).toBeTruthy(); 146 | 147 | const newTokenVerified = await tokenVerifier.verifyAsync(newToken); 148 | const newTokenVerified1 = tokenVerifier.verify(newToken); 149 | expect(newTokenVerified).toStrictEqual(newTokenVerified1); 150 | expect(newTokenVerified).toBe(true); 151 | }); 152 | 153 | test('decodeToken', () => { 154 | const decodedToken = decodeToken(sampleToken); 155 | expect(decodeToken).toBeTruthy(); 156 | expect(JSON.stringify(decodedToken.payload)).toBe(JSON.stringify(sampleDecodedToken.payload)); 157 | }); 158 | 159 | test('expandedToken', async () => { 160 | const tokenSigner = new TokenSigner('ES256K', rawPrivateKey); 161 | const tokenVerifier = new TokenVerifier('ES256K', rawPublicKey); 162 | 163 | const token = await tokenSigner.signAsync(sampleDecodedToken.payload, true); 164 | const token1 = tokenSigner.sign(sampleDecodedToken.payload, true); 165 | expect(token).toStrictEqual(token1); 166 | expect(token).toBeTruthy(); 167 | expect(typeof token).toBe('object'); 168 | 169 | const verified = await tokenVerifier.verifyAsync(token); 170 | const verified1 = tokenVerifier.verify(token); 171 | expect(verified).toStrictEqual(verified1); 172 | expect(verified).toBe(true); 173 | 174 | const signedToken = await tokenSigner.signAsync(sampleDecodedToken.payload, true); 175 | const signedToken1 = tokenSigner.sign(sampleDecodedToken.payload, true); 176 | expect(signedToken).toStrictEqual(signedToken1); 177 | expect(signedToken).toBeTruthy(); 178 | }); 179 | } 180 | -------------------------------------------------------------------------------- /src/verifier.ts: -------------------------------------------------------------------------------- 1 | import * as base64url from './base64Url'; 2 | import { cryptoClients, SECP256K1Client } from './cryptoClients'; 3 | import { MissingParametersError } from './errors'; 4 | import { SignedToken } from './signer'; 5 | import { hashSha256Async, hashSha256 } from './cryptoClients/sha256'; 6 | 7 | export class TokenVerifier { 8 | tokenType: string; 9 | cryptoClient: typeof SECP256K1Client; 10 | rawPublicKey: string; 11 | 12 | constructor(signingAlgorithm: string, rawPublicKey: string) { 13 | if (!(signingAlgorithm && rawPublicKey)) { 14 | throw new MissingParametersError('a signing algorithm and public key are required'); 15 | } 16 | if (typeof signingAlgorithm !== 'string') { 17 | throw 'signing algorithm parameter must be a string'; 18 | } 19 | signingAlgorithm = signingAlgorithm.toUpperCase(); 20 | if (!cryptoClients.hasOwnProperty(signingAlgorithm)) { 21 | throw 'invalid signing algorithm'; 22 | } 23 | this.tokenType = 'JWT'; 24 | this.cryptoClient = cryptoClients[signingAlgorithm]; 25 | this.rawPublicKey = rawPublicKey; 26 | } 27 | 28 | verify(token: string | SignedToken): boolean { 29 | if (typeof token === 'string') { 30 | return this.verifyCompact(token, false); 31 | } else if (typeof token === 'object') { 32 | return this.verifyExpanded(token, false); 33 | } else { 34 | return false; 35 | } 36 | } 37 | 38 | verifyAsync(token: string | SignedToken): Promise { 39 | if (typeof token === 'string') { 40 | return this.verifyCompact(token, true); 41 | } else if (typeof token === 'object') { 42 | return this.verifyExpanded(token, true); 43 | } else { 44 | return Promise.resolve(false); 45 | } 46 | } 47 | 48 | verifyCompact(token: string, async: false): boolean; 49 | verifyCompact(token: string, async: true): Promise; 50 | verifyCompact(token: string, async: boolean): boolean | Promise { 51 | // decompose the token into parts 52 | const tokenParts = token.split('.'); 53 | 54 | // calculate the signing input hash 55 | const signingInput = tokenParts[0] + '.' + tokenParts[1]; 56 | 57 | const performVerify = (signingInputHash: Uint8Array) => { 58 | // extract the signature as a DER array 59 | const derSignatureBytes = this.cryptoClient.loadSignature(tokenParts[2]); 60 | 61 | // verify the signed hash 62 | return this.cryptoClient.verifyHash(signingInputHash, derSignatureBytes, this.rawPublicKey); 63 | }; 64 | 65 | if (async) { 66 | return hashSha256Async(signingInput).then(signingInputHash => 67 | performVerify(signingInputHash) 68 | ); 69 | } else { 70 | const signingInputHash = hashSha256(signingInput); 71 | return performVerify(signingInputHash); 72 | } 73 | } 74 | 75 | verifyExpanded(token: SignedToken, async: false): boolean; 76 | verifyExpanded(token: SignedToken, async: true): Promise; 77 | verifyExpanded(token: SignedToken, async: boolean): boolean | Promise { 78 | const signingInput = [token['header'].join('.'), base64url.encode(token['payload'])].join('.'); 79 | let verified = true; 80 | 81 | const performVerify = (signingInputHash: Uint8Array) => { 82 | token['signature'].map((signature: string) => { 83 | const derSignatureBytes = this.cryptoClient.loadSignature(signature); 84 | const signatureVerified = this.cryptoClient.verifyHash( 85 | signingInputHash, 86 | derSignatureBytes, 87 | this.rawPublicKey 88 | ); 89 | if (!signatureVerified) { 90 | verified = false; 91 | } 92 | }); 93 | return verified; 94 | }; 95 | 96 | if (async) { 97 | return hashSha256Async(signingInput).then(signingInputHash => 98 | performVerify(signingInputHash) 99 | ); 100 | } else { 101 | const signingInputHash = hashSha256(signingInput); 102 | return performVerify(signingInputHash); 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "src/**/*" 5 | ], 6 | "exclude": [ 7 | "src/test/**/*.ts" 8 | ] 9 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "declaration": true, 7 | "noImplicitAny": true, 8 | "noImplicitThis": true, 9 | "outDir": "lib", 10 | "sourceMap": true, 11 | "sourceRoot": "." 12 | }, 13 | "include": [ 14 | "src/**/*" 15 | ] 16 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | 3 | module.exports = { 4 | entry: './src/index.ts', 5 | module: { 6 | rules: [ 7 | { 8 | test: /\.ts?$/, 9 | exclude: /node_modules/, 10 | use: { loader: 'ts-loader' }, 11 | }, 12 | { 13 | test: /\.js$/, 14 | use: { 15 | loader: 'babel-loader', 16 | options: { presets: ['@babel/preset-env'] }, 17 | }, 18 | }, 19 | ], 20 | }, 21 | resolve: { extensions: ['.ts', '.js'] }, 22 | output: { 23 | filename: 'jsontokens.js', 24 | path: require('path').resolve(__dirname, 'dist'), 25 | library: 'jsontokens', 26 | libraryTarget: 'umd', 27 | globalObject: 'this', 28 | }, 29 | }; 30 | --------------------------------------------------------------------------------