Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 38 additions & 0 deletions src/message_verifier/hmac.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
90 changes: 90 additions & 0 deletions src/message_verifier/message_verifier.ts
Original file line number Diff line number Diff line change
@@ -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<T extends any>(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
}
}
78 changes: 78 additions & 0 deletions tests/message_verifier.spec.ts
Original file line number Diff line number Diff line change
@@ -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)))
})
})
Loading