Skip to content

Commit ff141f8

Browse files
committed
feat: add message verifier class
1 parent 1f40119 commit ff141f8

File tree

4 files changed

+198
-0
lines changed

4 files changed

+198
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"./http": "./build/modules/http/main.js",
5757
"./http/url_builder_client": "./build/modules/http/url_builder_client.js",
5858
"./logger": "./build/modules/logger.js",
59+
"./message_verifier": "./build/src/message_verifier/message_verifier.js",
5960
"./repl": "./build/modules/repl.js",
6061
"./transformers": "./build/modules/transformers/main.js",
6162
"./package.json": "./package.json",

src/message_verifier/hmac.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* @adonisjs/core
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+
#key: Buffer
20+
21+
constructor(key: Buffer) {
22+
this.#key = key
23+
}
24+
25+
/**
26+
* Generate the hmac
27+
*/
28+
generate(value: string) {
29+
return base64.urlEncode(createHmac('sha256', this.#key).update(value).digest())
30+
}
31+
32+
/**
33+
* Compare raw value against an existing hmac
34+
*/
35+
compare(value: string, existingHmac: string) {
36+
return safeEqual(this.generate(value), existingHmac)
37+
}
38+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { createHash } from 'node:crypto'
2+
import base64 from '@poppinss/utils/base64'
3+
import { MessageBuilder } from '@poppinss/utils'
4+
import { RuntimeException } from '@poppinss/utils/exception'
5+
import { Hmac } from './hmac.ts'
6+
7+
/**
8+
* Message verifier is similar to the encryption. However, the actual payload
9+
* is not encrypted and just base64 encoded. This is helpful when you are
10+
* not concerned about the confidentiality of the data, but just want to
11+
* make sure that is not tampered with after encoding.
12+
*/
13+
export class MessageVerifier {
14+
/**
15+
* The key for signing and encrypting values. It is derived
16+
* from the user provided secret.
17+
*/
18+
#cryptoKey: Buffer
19+
20+
/**
21+
* Use `dot` as a separator for joining encrypted value, iv and the
22+
* hmac hash. The idea is borrowed from JWT's in which each part
23+
* of the payload is concatenated with a dot.
24+
*/
25+
#separator = '.'
26+
27+
constructor(secret: string) {
28+
this.#cryptoKey = createHash('sha256').update(secret).digest()
29+
}
30+
31+
/**
32+
* Sign a given piece of value using the app secret. A wide range of
33+
* data types are supported.
34+
*
35+
* - String
36+
* - Arrays
37+
* - Objects
38+
* - Booleans
39+
* - Numbers
40+
* - Dates
41+
*
42+
* You can optionally define a purpose for which the value was signed and
43+
* mentioning a different purpose/no purpose during unsign will fail.
44+
*/
45+
sign(payload: any, expiresIn?: string | number, purpose?: string) {
46+
if (payload === null || payload === undefined) {
47+
throw new RuntimeException(`Cannot sign "${payload}" value`)
48+
}
49+
50+
const encoded = base64.urlEncode(new MessageBuilder().build(payload, expiresIn, purpose))
51+
return `${encoded}${this.#separator}${new Hmac(this.#cryptoKey).generate(encoded)}`
52+
}
53+
54+
/**
55+
* Unsign a previously signed value with an optional purpose
56+
*/
57+
unsign<T extends any>(payload: string, purpose?: string): T | null {
58+
if (typeof payload !== 'string') {
59+
return null
60+
}
61+
62+
/**
63+
* Ensure value is in correct format
64+
*/
65+
const [encoded, hash] = payload.split(this.#separator)
66+
if (!encoded || !hash) {
67+
return null
68+
}
69+
70+
/**
71+
* Ensure value can be decoded
72+
*/
73+
const decoded = base64.urlDecode(encoded, 'utf8')
74+
if (!decoded) {
75+
return null
76+
}
77+
78+
const isValid = new Hmac(this.#cryptoKey).compare(encoded, hash)
79+
return isValid ? new MessageBuilder().verify(decoded, purpose) : null
80+
}
81+
}

tests/message_verifier.spec.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* @adonisjs/core
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/message_verifier.ts'
13+
14+
const SECRET = 'averylongradom32charactersstring'
15+
16+
test.group('MessageVerifier', () => {
17+
test('disallow signing null and undefined values', ({ assert }) => {
18+
const messageVerifier = new MessageVerifier(SECRET)
19+
20+
assert.throws(() => messageVerifier.sign(null), 'Cannot sign "null" value')
21+
assert.throws(() => messageVerifier.sign(undefined), 'Cannot sign "undefined" value')
22+
})
23+
24+
test('sign an object using a secret', ({ assert }) => {
25+
const messageVerifier = new MessageVerifier(SECRET)
26+
const signed = messageVerifier.sign({ username: 'virk' })
27+
28+
assert.equal(base64.urlDecode(signed.split('.')[0], 'utf8'), '{"message":{"username":"virk"}}')
29+
})
30+
31+
test('sign an object with purpose', ({ assert }) => {
32+
const messageVerifier = new MessageVerifier(SECRET)
33+
const signed = messageVerifier.sign({ username: 'virk' }, undefined, 'login')
34+
35+
assert.equal(
36+
base64.urlDecode(signed.split('.')[0], 'utf8'),
37+
'{"message":{"username":"virk"},"purpose":"login"}'
38+
)
39+
})
40+
41+
test('return null when unsigning non-string values', ({ assert }) => {
42+
const messageVerifier = new MessageVerifier(SECRET)
43+
44+
// @ts-expect-error
45+
assert.isNull(messageVerifier.unsign({}))
46+
// @ts-expect-error
47+
assert.isNull(messageVerifier.unsign(null))
48+
// @ts-expect-error
49+
assert.isNull(messageVerifier.unsign(22))
50+
})
51+
52+
test('unsign value', ({ assert }) => {
53+
const messageVerifier = new MessageVerifier(SECRET)
54+
const signed = messageVerifier.sign({ username: 'virk' })
55+
const unsigned = messageVerifier.unsign(signed)
56+
57+
assert.deepEqual(unsigned, { username: 'virk' })
58+
})
59+
60+
test('return null when unable to decode it', ({ assert }) => {
61+
const messageVerifier = new MessageVerifier(SECRET)
62+
63+
assert.isNull(messageVerifier.unsign('hello.world'))
64+
})
65+
66+
test('return null when hash separator is missing', ({ assert }) => {
67+
const messageVerifier = new MessageVerifier(SECRET)
68+
69+
assert.isNull(messageVerifier.unsign('helloworld'))
70+
})
71+
72+
test('return null when hash was touched', ({ assert }) => {
73+
const messageVerifier = new MessageVerifier(SECRET)
74+
const signed = messageVerifier.sign({ username: 'virk' })
75+
76+
assert.isNull(messageVerifier.unsign(signed.slice(0, -2)))
77+
})
78+
})

0 commit comments

Comments
 (0)