From 10c71ceff1910134732644a3c232ee9513d48932 Mon Sep 17 00:00:00 2001 From: Romain Lanz Date: Wed, 19 Nov 2025 08:55:03 +0100 Subject: [PATCH] feat: add message verifier class --- package.json | 1 + src/message_verifier/hmac.ts | 38 ++++++++++ src/message_verifier/message_verifier.ts | 90 ++++++++++++++++++++++++ tests/message_verifier.spec.ts | 78 ++++++++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 src/message_verifier/hmac.ts create mode 100644 src/message_verifier/message_verifier.ts create mode 100644 tests/message_verifier.spec.ts diff --git a/package.json b/package.json index d84779e2..53639fc8 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "./http": "./build/modules/http/main.js", "./http/url_builder_client": "./build/modules/http/url_builder_client.js", "./logger": "./build/modules/logger.js", + "./message_verifier": "./build/src/message_verifier/message_verifier.js", "./repl": "./build/modules/repl.js", "./transformers": "./build/modules/transformers/main.js", "./package.json": "./package.json", diff --git a/src/message_verifier/hmac.ts b/src/message_verifier/hmac.ts new file mode 100644 index 00000000..685f3fcd --- /dev/null +++ b/src/message_verifier/hmac.ts @@ -0,0 +1,38 @@ +/* + * @adonisjs/core + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { createHmac } from 'node:crypto' +import { safeEqual } from '@poppinss/utils' +import base64 from '@poppinss/utils/base64' + +/** + * A generic class for generating SHA-256 Hmac for verifying the value + * integrity. + */ +export class Hmac { + #key: Buffer + + constructor(key: Buffer) { + this.#key = key + } + + /** + * Generate the hmac + */ + generate(value: string) { + return base64.urlEncode(createHmac('sha256', this.#key).update(value).digest()) + } + + /** + * Compare raw value against an existing hmac + */ + compare(value: string, existingHmac: string) { + return safeEqual(this.generate(value), existingHmac) + } +} diff --git a/src/message_verifier/message_verifier.ts b/src/message_verifier/message_verifier.ts new file mode 100644 index 00000000..0441b8ac --- /dev/null +++ b/src/message_verifier/message_verifier.ts @@ -0,0 +1,90 @@ +/* + * @adonisjs/core + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { createHash } from 'node:crypto' +import base64 from '@poppinss/utils/base64' +import { MessageBuilder } from '@poppinss/utils' +import { RuntimeException } from '@poppinss/utils/exception' +import { Hmac } from './hmac.ts' + +/** + * Message verifier is similar to the encryption. However, the actual payload + * is not encrypted and just base64 encoded. This is helpful when you are + * not concerned about the confidentiality of the data, but just want to + * make sure that is not tampered with after encoding. + */ +export class MessageVerifier { + /** + * The key for signing and encrypting values. It is derived + * from the user provided secret. + */ + #cryptoKey: Buffer + + /** + * Use `dot` as a separator for joining encrypted value, iv and the + * hmac hash. The idea is borrowed from JWT's in which each part + * of the payload is concatenated with a dot. + */ + #separator = '.' + + constructor(secret: string) { + this.#cryptoKey = createHash('sha256').update(secret).digest() + } + + /** + * Sign a given piece of value using the app secret. A wide range of + * data types are supported. + * + * - String + * - Arrays + * - Objects + * - Booleans + * - Numbers + * - Dates + * + * You can optionally define a purpose for which the value was signed and + * mentioning a different purpose/no purpose during unsign will fail. + */ + sign(payload: any, expiresIn?: string | number, purpose?: string) { + if (payload === null || payload === undefined) { + throw new RuntimeException(`Cannot sign "${payload}" value`) + } + + const encoded = base64.urlEncode(new MessageBuilder().build(payload, expiresIn, purpose)) + return `${encoded}${this.#separator}${new Hmac(this.#cryptoKey).generate(encoded)}` + } + + /** + * Unsign a previously signed value with an optional purpose + */ + unsign(payload: string, purpose?: string): T | null { + if (typeof payload !== 'string') { + return null + } + + /** + * Ensure value is in correct format + */ + const [encoded, hash] = payload.split(this.#separator) + if (!encoded || !hash) { + return null + } + + /** + * Ensure value can be decoded + */ + const decoded = base64.urlDecode(encoded, 'utf8') + if (!decoded) { + return null + } + + const isValid = new Hmac(this.#cryptoKey).compare(encoded, hash) + return isValid ? new MessageBuilder().verify(decoded, purpose) : null + } +} diff --git a/tests/message_verifier.spec.ts b/tests/message_verifier.spec.ts new file mode 100644 index 00000000..ae86b622 --- /dev/null +++ b/tests/message_verifier.spec.ts @@ -0,0 +1,78 @@ +/* + * @adonisjs/core + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import base64 from '@poppinss/utils/base64' +import { MessageVerifier } from '../src/message_verifier/message_verifier.ts' + +const SECRET = 'averylongradom32charactersstring' + +test.group('MessageVerifier', () => { + test('disallow signing null and undefined values', ({ assert }) => { + const messageVerifier = new MessageVerifier(SECRET) + + assert.throws(() => messageVerifier.sign(null), 'Cannot sign "null" value') + assert.throws(() => messageVerifier.sign(undefined), 'Cannot sign "undefined" value') + }) + + test('sign an object using a secret', ({ assert }) => { + const messageVerifier = new MessageVerifier(SECRET) + const signed = messageVerifier.sign({ username: 'virk' }) + + assert.equal(base64.urlDecode(signed.split('.')[0], 'utf8'), '{"message":{"username":"virk"}}') + }) + + test('sign an object with purpose', ({ assert }) => { + const messageVerifier = new MessageVerifier(SECRET) + const signed = messageVerifier.sign({ username: 'virk' }, undefined, 'login') + + assert.equal( + base64.urlDecode(signed.split('.')[0], 'utf8'), + '{"message":{"username":"virk"},"purpose":"login"}' + ) + }) + + test('return null when unsigning non-string values', ({ assert }) => { + const messageVerifier = new MessageVerifier(SECRET) + + // @ts-expect-error + assert.isNull(messageVerifier.unsign({})) + // @ts-expect-error + assert.isNull(messageVerifier.unsign(null)) + // @ts-expect-error + assert.isNull(messageVerifier.unsign(22)) + }) + + test('unsign value', ({ assert }) => { + const messageVerifier = new MessageVerifier(SECRET) + const signed = messageVerifier.sign({ username: 'virk' }) + const unsigned = messageVerifier.unsign(signed) + + assert.deepEqual(unsigned, { username: 'virk' }) + }) + + test('return null when unable to decode it', ({ assert }) => { + const messageVerifier = new MessageVerifier(SECRET) + + assert.isNull(messageVerifier.unsign('hello.world')) + }) + + test('return null when hash separator is missing', ({ assert }) => { + const messageVerifier = new MessageVerifier(SECRET) + + assert.isNull(messageVerifier.unsign('helloworld')) + }) + + test('return null when hash was touched', ({ assert }) => { + const messageVerifier = new MessageVerifier(SECRET) + const signed = messageVerifier.sign({ username: 'virk' }) + + assert.isNull(messageVerifier.unsign(signed.slice(0, -2))) + }) +})