├── .npmrc ├── .prettierignore ├── eslint.config.js ├── tsconfig.json ├── .gitignore ├── factories ├── main.ts └── encryption.ts ├── index.ts ├── .github └── workflows │ ├── checks.yml │ ├── labels.yml │ └── release.yml ├── .editorconfig ├── src ├── types.ts ├── errors.ts ├── hmac.ts ├── message_verifier.ts └── encryption.ts ├── typedoc.json ├── bin └── test.ts ├── LICENSE.md ├── README.md ├── tests ├── message_verifier.spec.ts └── encryption.spec.ts └── package.json /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | README.md 3 | docs 4 | *.md 5 | config.json 6 | .eslintrc.json 7 | package.json 8 | *.html 9 | *.txt 10 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { configPkg } from '@adonisjs/eslint-config' 2 | export default configPkg({ 3 | ignores: ['coverage'], 4 | }) 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@adonisjs/tsconfig/tsconfig.package.json", 3 | "compilerOptions": { 4 | "rootDir": "./", 5 | "outDir": "./build" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .DS_STORE 4 | .nyc_output 5 | .idea 6 | .vscode/ 7 | *.sublime-project 8 | *.sublime-workspace 9 | *.log 10 | build 11 | dist 12 | yarn.lock 13 | shrinkwrap.yaml 14 | package-lock.json 15 | test/__app 16 | -------------------------------------------------------------------------------- /factories/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/encryption 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | export { EncryptionFactory } from './encryption.ts' 11 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/encryption 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | export * as errors from './src/errors.ts' 11 | export { Encryption } from './src/encryption.ts' 12 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: checks 2 | on: 3 | - push 4 | - pull_request 5 | - workflow_call 6 | 7 | jobs: 8 | test: 9 | uses: adonisjs/.github/.github/workflows/test.yml@next 10 | 11 | lint: 12 | uses: adonisjs/.github/.github/workflows/lint.yml@next 13 | 14 | typecheck: 15 | uses: adonisjs/.github/.github/workflows/typecheck.yml@next 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.json] 12 | insert_final_newline = ignore 13 | 14 | [**.min.js] 15 | indent_style = ignore 16 | insert_final_newline = ignore 17 | 18 | [MakeFile] 19 | indent_style = space 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/encryption 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { type Secret } from '@poppinss/utils' 11 | 12 | /** 13 | * Config accepted by the encryption 14 | */ 15 | export type EncryptionOptions = { 16 | algorithm?: 'aes-256-cbc' 17 | secret: string | Secret 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | name: Sync labels 2 | on: 3 | workflow_dispatch: 4 | permissions: 5 | issues: write 6 | jobs: 7 | labels: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: EndBug/label-sync@v2 12 | with: 13 | config-file: 'https://raw.githubusercontent.com/thetutlage/static/main/labels.yml' 14 | delete-other-labels: true 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "name": "@adonisjs/encryption", 4 | "out": "docs", 5 | "theme": "default", 6 | "includeVersion": true, 7 | "excludeExternals": true, 8 | "excludePrivate": true, 9 | "excludeProtected": true, 10 | "excludeInternal": true, 11 | "readme": "none", 12 | "gitRevision": "main", 13 | "entryPoints": ["./index.ts", "./src/types.ts", "./factories/main.ts"], 14 | "sort": ["source-order"] 15 | } 16 | -------------------------------------------------------------------------------- /factories/encryption.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/encryption 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { Encryption } from '../src/encryption.ts' 11 | import type { EncryptionOptions } from '../src/types.ts' 12 | 13 | /** 14 | * Encryption factory is used to generate encryption class instances for 15 | * testing 16 | */ 17 | export class EncryptionFactory { 18 | #options: EncryptionOptions = { 19 | secret: 'averylongrandomsecretkey', 20 | } 21 | 22 | /** 23 | * Merge encryption factory options 24 | */ 25 | merge(options: Partial) { 26 | Object.assign(this.#options, options) 27 | return this 28 | } 29 | 30 | /** 31 | * Create instance of encryption class 32 | */ 33 | create() { 34 | return new Encryption(this.#options) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/encryption 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { createError } from '@poppinss/utils/exception' 11 | 12 | /** 13 | * Error thrown when the application key is too short to be secure. 14 | * The application key must be at least 16 characters long to ensure 15 | * adequate security for encryption operations. 16 | */ 17 | export const E_INSECURE_APP_KEY = createError( 18 | 'The value of "app.appKey" should be atleast 16 characters long', 19 | 'E_INSECURE_APP_KEY' 20 | ) 21 | 22 | /** 23 | * Error thrown when the application key is missing from the configuration. 24 | * The application key is required for all encryption and decryption operations. 25 | */ 26 | export const E_MISSING_APP_KEY = createError( 27 | 'Missing "app.appKey". The key is required to encrypt values', 28 | 'E_MISSING_APP_KEY' 29 | ) 30 | -------------------------------------------------------------------------------- /bin/test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from '@japa/assert' 2 | import { processCLIArgs, configure, run } from '@japa/runner' 3 | 4 | /* 5 | |-------------------------------------------------------------------------- 6 | | Configure tests 7 | |-------------------------------------------------------------------------- 8 | | 9 | | The configure method accepts the configuration to configure the Japa 10 | | tests runner. 11 | | 12 | | The first method call "processCliArgs" process the command line arguments 13 | | and turns them into a config object. Using this method is not mandatory. 14 | | 15 | | Please consult japa.dev/runner-config for the config docs. 16 | */ 17 | processCLIArgs(process.argv.slice(2)) 18 | configure({ 19 | files: ['tests/**/*.spec.ts'], 20 | plugins: [assert()], 21 | }) 22 | 23 | /* 24 | |-------------------------------------------------------------------------- 25 | | Run tests 26 | |-------------------------------------------------------------------------- 27 | | 28 | | The following "run" method is required to execute all the tests. 29 | | 30 | */ 31 | run() 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright (c) 2023 AdonisJS Framework 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: workflow_dispatch 3 | permissions: 4 | contents: write 5 | id-token: write 6 | jobs: 7 | checks: 8 | uses: ./.github/workflows/checks.yml 9 | release: 10 | needs: checks 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 24 20 | 21 | - name: git config 22 | run: | 23 | git config user.name "${GITHUB_ACTOR}" 24 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" 25 | 26 | - name: Init npm config 27 | run: npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN 28 | env: 29 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | - run: npm install 31 | 32 | - run: npm run release -- --ci --preRelease=next 33 | env: 34 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 37 | -------------------------------------------------------------------------------- /src/hmac.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/encryption 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { createHmac } from 'node:crypto' 11 | import { safeEqual } from '@poppinss/utils' 12 | import base64 from '@poppinss/utils/base64' 13 | 14 | /** 15 | * A generic class for generating SHA-256 Hmac for verifying the value 16 | * integrity. 17 | */ 18 | export class Hmac { 19 | /** 20 | * The cryptographic key used for HMAC generation 21 | */ 22 | #key: Buffer 23 | 24 | /** 25 | * Creates a new HMAC instance with the provided cryptographic key 26 | * 27 | * @param key - The buffer containing the cryptographic key 28 | */ 29 | constructor(key: Buffer) { 30 | this.#key = key 31 | } 32 | 33 | /** 34 | * Generate the hmac 35 | * 36 | * @param value - The string value to generate HMAC for 37 | * @returns The base64 URL encoded HMAC hash 38 | */ 39 | generate(value: string): string { 40 | return base64.urlEncode(createHmac('sha256', this.#key).update(value).digest('hex')) 41 | } 42 | 43 | /** 44 | * Compare raw value against an existing hmac 45 | * 46 | * @param value - The original string value 47 | * @param existingHmac - The existing HMAC to compare against 48 | * @returns True if the HMACs match, false otherwise 49 | */ 50 | compare(value: string, existingHmac: string): boolean { 51 | return safeEqual(this.generate(value), existingHmac) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @adonisjs/encryption 2 | 3 |
4 | 5 | [![gh-workflow-image]][gh-workflow-url] [![npm-image]][npm-url] ![][typescript-image] [![license-image]][license-url] 6 | 7 | ## Introduction 8 | AdonisJS encryption packages is used to encrypt, sign and base64 encode values. The encryption is performed using `aes-256-cbc` algorithm. A unique `iv` is generated for each encryption call and therefore two encrypted output for the same value are different. 9 | 10 | ## Official Documentation 11 | The documentation is available on the [AdonisJS website](https://docs.adonisjs.com/guides/encryption) 12 | 13 | ## Contributing 14 | One of the primary goals of AdonisJS is to have a vibrant community of users and contributors who believes in the principles of the framework. 15 | 16 | We encourage you to read the [contribution guide](https://github.com/adonisjs/.github/blob/main/docs/CONTRIBUTING.md) before contributing to the framework. 17 | 18 | ## Code of Conduct 19 | In order to ensure that the AdonisJS community is welcoming to all, please review and abide by the [Code of Conduct](https://github.com/adonisjs/.github/blob/main/docs/CODE_OF_CONDUCT.md). 20 | 21 | ## License 22 | AdonisJS encryption is open-sourced software licensed under the [MIT license](LICENSE.md). 23 | 24 | [gh-workflow-image]: https://img.shields.io/github/actions/workflow/status/adonisjs/encryption/checks.yml?style=for-the-badge 25 | [gh-workflow-url]: https://github.com/adonisjs/encryption/actions/workflows/checks.yml "Github action" 26 | 27 | [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript 28 | [typescript-url]: "typescript" 29 | 30 | [npm-image]: https://img.shields.io/npm/v/@adonisjs/encryption.svg?style=for-the-badge&logo=npm 31 | [npm-url]: https://npmjs.org/package/@adonisjs/encryption "npm" 32 | 33 | [license-image]: https://img.shields.io/npm/l/@adonisjs/encryption?color=blueviolet&style=for-the-badge 34 | [license-url]: LICENSE.md "license" 35 | -------------------------------------------------------------------------------- /tests/message_verifier.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/encryption 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import base64 from '@poppinss/utils/base64' 12 | import { MessageVerifier } from '../src/message_verifier.ts' 13 | 14 | const SECRET = 'averylongradom32charactersstring' 15 | 16 | test.group('Message Verifier', () => { 17 | test('disallow signing null and undefined values', ({ assert }) => { 18 | const encryption = new MessageVerifier(SECRET) 19 | assert.throws(() => encryption.sign(null), 'Cannot sign "null" value') 20 | assert.throws(() => encryption.sign(undefined), 'Cannot sign "undefined" value') 21 | }) 22 | 23 | test('sign an object using a secret', ({ assert }) => { 24 | const encryption = new MessageVerifier(SECRET) 25 | const signed = encryption.sign({ username: 'virk' }) 26 | assert.equal(base64.urlDecode(signed.split('.')[0]), '{"message":{"username":"virk"}}') 27 | }) 28 | 29 | test('sign an object with purpose', ({ assert }) => { 30 | const encryption = new MessageVerifier(SECRET) 31 | const signed = encryption.sign({ username: 'virk' }, undefined, 'login') 32 | assert.equal( 33 | base64.urlDecode(signed.split('.')[0]), 34 | '{"message":{"username":"virk"},"purpose":"login"}' 35 | ) 36 | }) 37 | 38 | test('return null when unsigning non-string values', ({ assert }) => { 39 | const encryption = new MessageVerifier(SECRET) 40 | // @ts-expect-error 41 | assert.isNull(encryption.unsign({})) 42 | // @ts-expect-error 43 | assert.isNull(encryption.unsign(null)) 44 | // @ts-expect-error 45 | assert.isNull(encryption.unsign(22)) 46 | }) 47 | 48 | test('unsign value', ({ assert }) => { 49 | const encryption = new MessageVerifier(SECRET) 50 | const signed = encryption.sign({ username: 'virk' }) 51 | const unsigned = encryption.unsign(signed) 52 | assert.deepEqual(unsigned, { username: 'virk' }) 53 | }) 54 | 55 | test('return null when unable to decode it', ({ assert }) => { 56 | const encryption = new MessageVerifier(SECRET) 57 | assert.isNull(encryption.unsign('hello.world')) 58 | }) 59 | 60 | test('return null when hash separator is missing', ({ assert }) => { 61 | const encryption = new MessageVerifier(SECRET) 62 | assert.isNull(encryption.unsign('helloworld')) 63 | }) 64 | 65 | test('return null when hash was touched', ({ assert }) => { 66 | const encryption = new MessageVerifier(SECRET) 67 | const signed = encryption.sign({ username: 'virk' }) 68 | assert.isNull(encryption.unsign(signed.slice(0, -2))) 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adonisjs/encryption", 3 | "version": "7.0.0-next.1", 4 | "description": "Encryption provider for AdonisJs", 5 | "engines": { 6 | "node": ">=24.0.0" 7 | }, 8 | "type": "module", 9 | "files": [ 10 | "build", 11 | "!build/bin", 12 | "!build/tests" 13 | ], 14 | "main": "build/index.js", 15 | "exports": { 16 | ".": "./build/index.js", 17 | "./types": "./build/src/types.js", 18 | "./factories": "./build/factories/main.js" 19 | }, 20 | "scripts": { 21 | "pretest": "npm run lint", 22 | "test": "c8 npm run quick:test", 23 | "lint": "eslint .", 24 | "format": "prettier --write .", 25 | "typecheck": "tsc --noEmit", 26 | "precompile": "npm run lint && npm run clean", 27 | "clean": "del-cli build", 28 | "compile": "tsup-node && tsc --emitDeclarationOnly --declaration", 29 | "build": "npm run compile", 30 | "version": "npm run build", 31 | "prepublishOnly": "npm run build", 32 | "release": "release-it", 33 | "quick:test": "node --import=@poppinss/ts-exec --enable-source-maps bin/test.ts" 34 | }, 35 | "devDependencies": { 36 | "@adonisjs/eslint-config": "^3.0.0-next.1", 37 | "@adonisjs/prettier-config": "^1.4.5", 38 | "@adonisjs/tsconfig": "^2.0.0-next.0", 39 | "@japa/assert": "^4.1.1", 40 | "@japa/runner": "^4.4.0", 41 | "@poppinss/ts-exec": "^1.4.1", 42 | "@release-it/conventional-changelog": "^10.0.1", 43 | "@types/node": "^24.3.0", 44 | "c8": "^10.1.3", 45 | "del-cli": "^6.0.0", 46 | "eslint": "^9.34.0", 47 | "prettier": "^3.6.2", 48 | "release-it": "^19.0.4", 49 | "tsup": "^8.5.0", 50 | "typedoc": "^0.28.12", 51 | "typescript": "^5.9.2" 52 | }, 53 | "dependencies": { 54 | "@poppinss/utils": "^7.0.0-next.3" 55 | }, 56 | "homepage": "https://github.com/adonisjs/encryption#readme", 57 | "repository": { 58 | "type": "git", 59 | "url": "git+https://github.com/adonisjs/encryption.git" 60 | }, 61 | "bugs": { 62 | "url": "https://github.com/adonisjs/encryption/issues" 63 | }, 64 | "keywords": [ 65 | "encryption" 66 | ], 67 | "author": "Harminder Virk ", 68 | "license": "MIT", 69 | "publishConfig": { 70 | "access": "public", 71 | "provenance": true 72 | }, 73 | "tsup": { 74 | "entry": [ 75 | "./index.ts", 76 | "./src/types.ts", 77 | "./factories/main.ts" 78 | ], 79 | "outDir": "./build", 80 | "clean": true, 81 | "format": "esm", 82 | "dts": false, 83 | "sourcemap": false, 84 | "target": "esnext" 85 | }, 86 | "release-it": { 87 | "git": { 88 | "requireCleanWorkingDir": true, 89 | "requireUpstream": true, 90 | "commitMessage": "chore(release): ${version}", 91 | "tagAnnotation": "v${version}", 92 | "push": true, 93 | "tagName": "v${version}" 94 | }, 95 | "github": { 96 | "release": true 97 | }, 98 | "npm": { 99 | "publish": true, 100 | "skipChecks": true 101 | }, 102 | "plugins": { 103 | "@release-it/conventional-changelog": { 104 | "preset": { 105 | "name": "angular" 106 | } 107 | } 108 | } 109 | }, 110 | "c8": { 111 | "reporter": [ 112 | "text", 113 | "html" 114 | ], 115 | "exclude": [ 116 | "tests/**" 117 | ] 118 | }, 119 | "prettier": "@adonisjs/prettier-config" 120 | } 121 | -------------------------------------------------------------------------------- /src/message_verifier.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/encryption 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { createHash } from 'node:crypto' 11 | import base64 from '@poppinss/utils/base64' 12 | import { MessageBuilder } from '@poppinss/utils' 13 | import { RuntimeException } from '@poppinss/utils/exception' 14 | 15 | import { Hmac } from './hmac.ts' 16 | 17 | /** 18 | * Message verifier is similar to the encryption. However, the actual payload 19 | * is not encrypted and just base64 encoded. This is helpful when you are 20 | * not concerned about the confidentiality of the data, but just want to 21 | * make sure that is not tampered after encoding. 22 | */ 23 | export class MessageVerifier { 24 | /** 25 | * The key for signing and encrypting values. It is derived 26 | * from the user provided secret. 27 | */ 28 | #cryptoKey: Buffer 29 | 30 | /** 31 | * Use `dot` as a separator for joining encrypted value, iv and the 32 | * hmac hash. The idea is borrowed from JWT's in which each part 33 | * of the payload is concatenated with a dot. 34 | */ 35 | #separator = '.' 36 | 37 | /** 38 | * Creates a new MessageVerifier instance with the provided secret 39 | * 40 | * @param secret - The secret key used for signing operations 41 | */ 42 | constructor(secret: string) { 43 | this.#cryptoKey = createHash('sha256').update(secret).digest() 44 | } 45 | 46 | /** 47 | * Sign a given piece of value using the app secret. A wide range of 48 | * data types are supported. 49 | * 50 | * - String 51 | * - Arrays 52 | * - Objects 53 | * - Booleans 54 | * - Numbers 55 | * - Dates 56 | * 57 | * You can optionally define a purpose for which the value was signed and 58 | * mentioning a different purpose/no purpose during unsign will fail. 59 | * 60 | * @param payload - The data to be signed 61 | * @param expiresIn - Optional expiration time 62 | * @param purpose - Optional purpose for which the value is signed 63 | * @returns The signed payload as a string 64 | */ 65 | sign(payload: any, expiresIn?: string | number, purpose?: string): string { 66 | if (payload === null || payload === undefined) { 67 | throw new RuntimeException(`Cannot sign "${payload}" value`) 68 | } 69 | 70 | const encoded = base64.urlEncode(new MessageBuilder().build(payload, expiresIn, purpose)) 71 | return `${encoded}${this.#separator}${new Hmac(this.#cryptoKey).generate(encoded)}` 72 | } 73 | 74 | /** 75 | * Unsign a previously signed value with an optional purpose 76 | * 77 | * @param payload - The signed payload string to verify 78 | * @param purpose - Optional purpose that the value was signed for 79 | * @returns The original data if valid, null if invalid or verification fails 80 | */ 81 | unsign(payload: string, purpose?: string): T | null { 82 | if (typeof payload !== 'string') { 83 | return null 84 | } 85 | 86 | /** 87 | * Ensure value is in correct format 88 | */ 89 | const [encoded, hash] = payload.split(this.#separator) 90 | if (!encoded || !hash) { 91 | return null 92 | } 93 | 94 | /** 95 | * Ensure value can be decoded 96 | */ 97 | const decoded = base64.urlDecode(encoded, undefined, false) 98 | if (!decoded) { 99 | return null 100 | } 101 | 102 | const isValid = new Hmac(this.#cryptoKey).compare(encoded, hash) 103 | return isValid ? new MessageBuilder().verify(decoded, purpose) : null 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /tests/encryption.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/encryption 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { Secret } from '@poppinss/utils' 12 | import { Encryption } from '../src/encryption.ts' 13 | 14 | const SECRET = 'averylongradom32charactersstring' 15 | 16 | test.group('Encryption | encrypt', () => { 17 | test('fail when secret is missing', ({ assert }) => { 18 | assert.throws( 19 | // @ts-expect-error 20 | () => new Encryption({ secret: null }), 21 | 'Missing "app.appKey". The key is required to encrypt values' 22 | ) 23 | assert.throws( 24 | // @ts-expect-error 25 | () => new Encryption({ secret: new Secret(null) }), 26 | 'Missing "app.appKey". The key is required to encrypt values' 27 | ) 28 | }) 29 | 30 | test('fail when secret is not bigger than 16chars', ({ assert }) => { 31 | assert.throws( 32 | () => new Encryption({ secret: 'helloworld' }), 33 | 'The value of "app.appKey" should be atleast 16 characters long' 34 | ) 35 | 36 | assert.throws( 37 | () => new Encryption({ secret: new Secret('helloworld') }), 38 | 'The value of "app.appKey" should be atleast 16 characters long' 39 | ) 40 | }) 41 | 42 | test('encrypt value', ({ assert }) => { 43 | const encryption = new Encryption({ secret: SECRET }) 44 | assert.notEqual(encryption.encrypt('hello-world'), 'hello-world') 45 | assert.equal(encryption.decrypt(encryption.encrypt('hello-world')), 'hello-world') 46 | }) 47 | 48 | test('define encryption secret as a secret value', ({ assert }) => { 49 | const encryption = new Encryption({ secret: new Secret(SECRET) }) 50 | assert.notEqual(encryption.encrypt('hello-world'), 'hello-world') 51 | assert.equal(encryption.decrypt(encryption.encrypt('hello-world')), 'hello-world') 52 | }) 53 | 54 | test('encrypt an object', ({ assert }) => { 55 | const encryption = new Encryption({ secret: SECRET }) 56 | const encrypted = encryption.encrypt({ username: 'virk' }) 57 | assert.exists(encrypted) 58 | }) 59 | 60 | test('ensure iv is random for each encryption call', ({ assert }) => { 61 | const encryption = new Encryption({ secret: SECRET }) 62 | assert.notEqual( 63 | encryption.encrypt({ username: 'virk' }), 64 | encryption.encrypt({ username: 'virk' }) 65 | ) 66 | }) 67 | }) 68 | 69 | test.group('Encryption | decrypt', () => { 70 | test('return null when decrypting non-string values', ({ assert }) => { 71 | const encryption = new Encryption({ secret: SECRET }) 72 | assert.isNull(encryption.decrypt(null)) 73 | }) 74 | 75 | test('decrypt encrypted value', ({ assert }) => { 76 | const encryption = new Encryption({ secret: SECRET }) 77 | const encrypted = encryption.encrypt({ username: 'virk' }) 78 | assert.deepEqual(encryption.decrypt(encrypted), { username: 'virk' }) 79 | }) 80 | 81 | test('define decryption secret as a secret value', ({ assert }) => { 82 | const encryption = new Encryption({ secret: new Secret(SECRET) }) 83 | const encrypted = encryption.encrypt({ username: 'virk' }) 84 | assert.deepEqual(encryption.decrypt(encrypted), { username: 'virk' }) 85 | }) 86 | 87 | test('return null when value is in invalid format', ({ assert }) => { 88 | const encryption = new Encryption({ secret: SECRET }) 89 | assert.isNull(encryption.decrypt('foo')) 90 | }) 91 | 92 | test('return null when unable to decode encrypted value', ({ assert }) => { 93 | const encryption = new Encryption({ secret: SECRET }) 94 | assert.isNull(encryption.decrypt('foo.bar.baz')) 95 | }) 96 | 97 | test('return null when hash is tampered', ({ assert }) => { 98 | const encryption = new Encryption({ secret: SECRET }) 99 | const encrypted = encryption.encrypt({ username: 'virk' }) 100 | assert.isNull(encryption.decrypt(encrypted.slice(0, -2))) 101 | }) 102 | 103 | test('return null when encrypted value is tampered', ({ assert }) => { 104 | const encryption = new Encryption({ secret: SECRET }) 105 | const encrypted = encryption.encrypt({ username: 'virk' }) 106 | assert.isNull(encryption.decrypt(encrypted.slice(2))) 107 | }) 108 | 109 | test('return null when iv value is tampered', ({ assert }) => { 110 | const encryption = new Encryption({ secret: SECRET }) 111 | const encrypted = encryption.encrypt({ username: 'virk' }) 112 | 113 | const ivIndex = encrypted.indexOf('--') + 2 114 | const part1 = encrypted.slice(0, ivIndex) 115 | const part2 = encrypted.slice(ivIndex).slice(2) 116 | 117 | assert.isNull(encryption.decrypt(`${part1}${part2}`)) 118 | }) 119 | 120 | test('return null when purpose is missing during decrypt', ({ assert }) => { 121 | const encryption = new Encryption({ secret: SECRET }) 122 | const encrypted = encryption.encrypt({ username: 'virk' }, undefined, 'login') 123 | assert.isNull(encryption.decrypt(encrypted)) 124 | }) 125 | 126 | test('return null when purpose is defined only during decrypt', ({ assert }) => { 127 | const encryption = new Encryption({ secret: SECRET }) 128 | const encrypted = encryption.encrypt({ username: 'virk' }) 129 | assert.isNull(encryption.decrypt(encrypted, 'login')) 130 | }) 131 | 132 | test('return null when purpose are not same', ({ assert }) => { 133 | const encryption = new Encryption({ secret: SECRET }) 134 | const encrypted = encryption.encrypt({ username: 'virk' }, undefined, 'register') 135 | assert.isNull(encryption.decrypt(encrypted, 'login')) 136 | }) 137 | 138 | test('decrypt when purpose are same', ({ assert }) => { 139 | const encryption = new Encryption({ secret: SECRET }) 140 | const encrypted = encryption.encrypt({ username: 'virk' }, undefined, 'register') 141 | assert.deepEqual(encryption.decrypt(encrypted, 'register'), { username: 'virk' }) 142 | }) 143 | 144 | test('get new instance of encryptor with different key', ({ assert }) => { 145 | const encryption = new Encryption({ secret: SECRET }) 146 | const customEncryptor = encryption.child({ secret: 'another secret key' }) 147 | assert.isNull(encryption.decrypt(customEncryptor.encrypt('hello-world'))) 148 | }) 149 | }) 150 | -------------------------------------------------------------------------------- /src/encryption.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/encryption 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import string from '@poppinss/utils/string' 11 | import base64 from '@poppinss/utils/base64' 12 | import { MessageBuilder } from '@poppinss/utils' 13 | import { createHash, createCipheriv, createDecipheriv } from 'node:crypto' 14 | 15 | import { Hmac } from './hmac.ts' 16 | import * as errors from './errors.ts' 17 | import type { EncryptionOptions } from './types.ts' 18 | import { MessageVerifier } from './message_verifier.ts' 19 | 20 | /** 21 | * The encryption class allows encrypting and decrypting values using `aes-256-cbc` or `aes-128-cbc` 22 | * algorithms. The encrypted value uses a unique iv for every encryption and this ensures semantic 23 | * security (read more https://en.wikipedia.org/wiki/Semantic_security). 24 | */ 25 | export class Encryption { 26 | /** 27 | * Configuration options for the encryption instance 28 | */ 29 | #options: Required 30 | 31 | /** 32 | * The key for signing and encrypting values. It is derived 33 | * from the user provided secret. 34 | */ 35 | #cryptoKey: Buffer 36 | 37 | /** 38 | * Use `dot` as a separator for joining encrypted value, iv and the 39 | * hmac hash. The idea is borrowed from JWTs. 40 | */ 41 | #separator = '.' 42 | 43 | /** 44 | * Reference to the instance of message verifier for signing 45 | * and verifying values. 46 | */ 47 | verifier: MessageVerifier 48 | 49 | /** 50 | * Reference to base64 object for base64 encoding/decoding values 51 | */ 52 | base64: typeof base64 = base64 53 | 54 | /** 55 | * The algorithm in use 56 | * 57 | * @returns The encryption algorithm being used 58 | */ 59 | get algorithm(): 'aes-256-cbc' { 60 | return this.#options.algorithm 61 | } 62 | 63 | /** 64 | * Creates a new Encryption instance with the provided options 65 | * 66 | * @param options - Configuration options for encryption 67 | */ 68 | constructor(options: EncryptionOptions) { 69 | const secretValue = 70 | options.secret && typeof options.secret === 'object' && 'release' in options.secret 71 | ? options.secret.release() 72 | : options.secret 73 | 74 | this.#options = { algorithm: 'aes-256-cbc', ...options } 75 | this.#validateSecret(secretValue) 76 | this.#cryptoKey = createHash('sha256').update(secretValue).digest() 77 | this.verifier = new MessageVerifier(secretValue) 78 | } 79 | 80 | /** 81 | * Validates the app secret 82 | * 83 | * @param secret - The secret to validate 84 | */ 85 | #validateSecret(secret?: string): void { 86 | if (typeof secret !== 'string') { 87 | throw new errors.E_MISSING_APP_KEY() 88 | } 89 | 90 | if (secret.length < 16) { 91 | throw new errors.E_INSECURE_APP_KEY() 92 | } 93 | } 94 | 95 | /** 96 | * Encrypt a given piece of value using the app secret. A wide range of 97 | * data types are supported. 98 | * 99 | * - String 100 | * - Arrays 101 | * - Objects 102 | * - Booleans 103 | * - Numbers 104 | * - Dates 105 | * 106 | * You can optionally define a purpose for which the value was encrypted and 107 | * mentioning a different purpose/no purpose during decrypt will fail. 108 | * 109 | * @param payload - The data to be encrypted 110 | * @param expiresIn - Optional expiration time 111 | * @param purpose - Optional purpose for which the value is encrypted 112 | * @returns The encrypted payload as a string 113 | */ 114 | encrypt(payload: any, expiresIn?: string | number, purpose?: string): string { 115 | /** 116 | * Using a random string as the iv for generating unpredictable values 117 | */ 118 | const iv = string.random(16) 119 | 120 | /** 121 | * Creating chiper 122 | */ 123 | const cipher = createCipheriv(this.algorithm, this.#cryptoKey, iv) 124 | 125 | /** 126 | * Encoding value to a string so that we can set it on the cipher 127 | */ 128 | const encodedValue = new MessageBuilder().build(payload, expiresIn, purpose) 129 | 130 | /** 131 | * Set final to the cipher instance and encrypt it 132 | */ 133 | const encrypted = Buffer.concat([cipher.update(encodedValue, 'utf-8'), cipher.final()]) 134 | 135 | /** 136 | * Concatenate `encrypted value` and `iv` by urlEncoding them. The concatenation is required 137 | * to generate the HMAC, so that HMAC checks for integrity of both the `encrypted value` 138 | * and the `iv`. 139 | */ 140 | const result = `${this.base64.urlEncode(encrypted)}${this.#separator}${this.base64.urlEncode( 141 | iv 142 | )}` 143 | 144 | /** 145 | * Returns the result + hmac 146 | */ 147 | return `${result}${this.#separator}${new Hmac(this.#cryptoKey).generate(result)}` 148 | } 149 | 150 | /** 151 | * Decrypt value and verify it against a purpose 152 | * 153 | * @param value - The encrypted value to decrypt 154 | * @param purpose - Optional purpose that the value was encrypted for 155 | * @returns The decrypted data if valid, null if decryption fails 156 | */ 157 | decrypt(value: unknown, purpose?: string): T | null { 158 | if (typeof value !== 'string') { 159 | return null 160 | } 161 | 162 | /** 163 | * Make sure the encrypted value is in correct format. ie 164 | * [encrypted value].[iv].[hash] 165 | */ 166 | const [encryptedEncoded, ivEncoded, hash] = value.split(this.#separator) 167 | if (!encryptedEncoded || !ivEncoded || !hash) { 168 | return null 169 | } 170 | 171 | /** 172 | * Make sure we are able to urlDecode the encrypted value 173 | */ 174 | const encrypted = this.base64.urlDecode(encryptedEncoded, 'base64') 175 | if (!encrypted) { 176 | return null 177 | } 178 | 179 | /** 180 | * Make sure we are able to urlDecode the iv 181 | */ 182 | const iv = this.base64.urlDecode(ivEncoded) 183 | if (!iv) { 184 | return null 185 | } 186 | 187 | /** 188 | * Make sure the hash is correct, it means the first 2 parts of the 189 | * string are not tampered. 190 | */ 191 | const isValidHmac = new Hmac(this.#cryptoKey).compare( 192 | `${encryptedEncoded}${this.#separator}${ivEncoded}`, 193 | hash 194 | ) 195 | 196 | if (!isValidHmac) { 197 | return null 198 | } 199 | 200 | /** 201 | * The Decipher can raise exceptions with malformed input, so we wrap it 202 | * to avoid leaking sensitive information 203 | */ 204 | try { 205 | const decipher = createDecipheriv(this.algorithm, this.#cryptoKey, iv) 206 | const decrypted = decipher.update(encrypted, 'base64', 'utf8') + decipher.final('utf8') 207 | return new MessageBuilder().verify(decrypted, purpose) 208 | } catch { 209 | return null 210 | } 211 | } 212 | 213 | /** 214 | * Create a children instance with different secret key 215 | * 216 | * @param options - Optional configuration options to override 217 | * @returns A new Encryption instance with the merged options 218 | */ 219 | child(options?: EncryptionOptions): Encryption { 220 | return new Encryption({ ...this.#options, ...options }) 221 | } 222 | } 223 | --------------------------------------------------------------------------------