Skip to content

Commit 10c71ce

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

File tree

4 files changed

+207
-0
lines changed

4 files changed

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

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)