├── .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 | [](https://circleci.com/gh/blockstack/jsontokens-js/tree/master)
4 | [](https://www.npmjs.com/package/jsontokens)
5 | [](https://www.npmjs.com/package/jsontokens)
6 | [](https://www.npmjs.com/package/jsontokens)
7 | [](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 |
--------------------------------------------------------------------------------