Skip to content

Commit 193bdd8

Browse files
committed
feat(encryption): add legacy driver for backward compatibility
1 parent f6f4b4d commit 193bdd8

File tree

5 files changed

+502
-1
lines changed

5 files changed

+502
-1
lines changed

modules/encryption/define_config.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type AES256CBCDriverConfig,
1616
type AES256GCMDriverConfig,
1717
type ChaCha20Poly1305DriverConfig,
18+
type LegacyDriverConfig,
1819
} from '../../types/encryption.ts'
1920
import { type EncryptionConfig } from '../../types/encryption.ts'
2021
import { InvalidArgumentsException } from '../../src/exceptions.ts'
@@ -196,6 +197,26 @@ export const drivers: {
196197
* ```
197198
*/
198199
aes256gcm: (config: AES256GCMDriverConfig) => ConfigProvider<EncryptionConfig>
200+
201+
/**
202+
* Creates a Legacy encryption driver configuration.
203+
*
204+
* The Legacy driver maintains compatibility with the old AdonisJS v6
205+
* encryption format. It uses AES-256-CBC with HMAC SHA-256.
206+
*
207+
* Use this driver to decrypt values encrypted with older versions
208+
* of AdonisJS or when migrating to newer encryption drivers.
209+
*
210+
* @param config - The Legacy driver configuration
211+
*
212+
* @example
213+
* ```ts
214+
* drivers.legacy({
215+
* keys: [env.get('APP_KEY')]
216+
* })
217+
* ```
218+
*/
219+
legacy: (config: LegacyDriverConfig) => ConfigProvider<EncryptionConfig>
199220
} = {
200221
chacha20: (config) => {
201222
return configProvider.create(async () => {
@@ -229,4 +250,15 @@ export const drivers: {
229250
}
230251
})
231252
},
253+
254+
legacy: (config) => {
255+
return configProvider.create(async () => {
256+
const { Legacy } = await import('./drivers/legacy.ts')
257+
debug('configuring legacy encryption driver')
258+
return {
259+
driver: (key) => new Legacy({ key }),
260+
keys: config.keys.filter((key) => !!key),
261+
}
262+
})
263+
},
232264
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
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 { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
11+
import { MessageBuilder, type Secret } from '@poppinss/utils'
12+
import { BaseDriver, Hmac, base64UrlDecode, base64UrlEncode } from '@boringnode/encryption'
13+
import type {
14+
EncryptionDriverContract,
15+
CypherText,
16+
EncryptOptions,
17+
} from '@boringnode/encryption/types'
18+
import { errors } from '@boringnode/encryption'
19+
20+
/**
21+
* Configuration for the Legacy encryption driver.
22+
*
23+
* The Legacy driver maintains compatibility with the old AdonisJS v6
24+
* encryption format using AES-256-CBC with HMAC SHA-256.
25+
*/
26+
export interface LegacyConfig {
27+
key: string | Secret<string>
28+
}
29+
30+
/**
31+
* Configuration for the Legacy encryption driver factory.
32+
*
33+
* Used when configuring the driver through the defineConfig function.
34+
*/
35+
export interface LegacyDriverConfig {
36+
keys: (string | Secret<string>)[]
37+
}
38+
39+
/**
40+
* Factory function to create a Legacy encryption configuration.
41+
*
42+
* @example
43+
* ```ts
44+
* drivers.legacy({
45+
* keys: [env.get('APP_KEY')]
46+
* })
47+
* ```
48+
*/
49+
export function legacy(config: LegacyDriverConfig) {
50+
return {
51+
driver: (key: string | Secret<string>) => new Legacy({ key }),
52+
keys: config.keys,
53+
}
54+
}
55+
56+
/**
57+
* Legacy encryption driver for AdonisJS.
58+
*
59+
* This driver maintains compatibility with the old AdonisJS v6 encryption
60+
* format. It uses:
61+
* - AES-256-CBC for encryption
62+
* - HMAC SHA-256 for integrity verification
63+
* - MessageBuilder from @poppinss/utils for encoding values
64+
*
65+
* Encrypted format: `[encrypted_base64url].[iv_base64url].[hmac]`
66+
*
67+
* @example
68+
* ```ts
69+
* const driver = new Legacy({ key: 'your-32-character-secret-key!!' })
70+
*
71+
* const encrypted = driver.encrypt('sensitive data')
72+
* const decrypted = driver.decrypt(encrypted)
73+
* ```
74+
*/
75+
export class Legacy extends BaseDriver implements EncryptionDriverContract {
76+
constructor(config: LegacyConfig) {
77+
super(config)
78+
79+
/**
80+
* The key length must be at least 16 characters
81+
*/
82+
if (this.cryptoKey.length < 16) {
83+
throw new errors.E_INSECURE_ENCRYPTER_KEY()
84+
}
85+
}
86+
87+
/**
88+
* Encrypt a given piece of value using the app secret. A wide range of
89+
* data types are supported.
90+
*
91+
* - String
92+
* - Arrays
93+
* - Objects
94+
* - Booleans
95+
* - Numbers
96+
* - Dates
97+
*
98+
* You can optionally define a purpose for which the value was encrypted and
99+
* mentioning a different purpose/no purpose during decrypt will fail.
100+
*/
101+
encrypt(payload: any, options?: EncryptOptions): CypherText
102+
encrypt(payload: any, expiresIn?: string | number, purpose?: string): CypherText
103+
encrypt(
104+
payload: any,
105+
expiresInOrOptions?: string | number | EncryptOptions,
106+
purpose?: string
107+
): CypherText {
108+
let expiresIn: string | number | undefined
109+
let actualPurpose: string | undefined
110+
111+
if (typeof expiresInOrOptions === 'object' && expiresInOrOptions !== null) {
112+
expiresIn = expiresInOrOptions.expiresIn
113+
actualPurpose = expiresInOrOptions.purpose
114+
} else {
115+
expiresIn = expiresInOrOptions
116+
actualPurpose = purpose
117+
}
118+
119+
const iv = randomBytes(16)
120+
121+
/**
122+
* Use the first 32 bytes of the key for AES-256
123+
*/
124+
const encryptionKey = this.cryptoKey.subarray(0, 32)
125+
126+
const cipher = createCipheriv('aes-256-cbc', encryptionKey, iv)
127+
const plainText = new MessageBuilder().build(payload, expiresIn, actualPurpose)
128+
const cipherText = Buffer.concat([cipher.update(plainText), cipher.final()])
129+
130+
const macPayload = `${base64UrlEncode(cipherText)}${this.separator}${base64UrlEncode(iv)}`
131+
const hmac = new Hmac(this.cryptoKey).generate(macPayload)
132+
133+
return this.computeReturns([macPayload, hmac])
134+
}
135+
136+
/**
137+
* Decrypt value and verify it against a purpose
138+
*/
139+
decrypt<T extends any>(value: string, purpose?: string): T | null {
140+
if (typeof value !== 'string') {
141+
return null
142+
}
143+
144+
const [cipherEncoded, ivEncoded, macEncoded] = value.split(this.separator)
145+
146+
if (!cipherEncoded || !ivEncoded || !macEncoded) {
147+
return null
148+
}
149+
150+
const cipherText = base64UrlDecode(cipherEncoded)
151+
if (!cipherText) {
152+
return null
153+
}
154+
155+
const iv = base64UrlDecode(ivEncoded)
156+
if (!iv) {
157+
return null
158+
}
159+
160+
/**
161+
* Verify the HMAC
162+
*/
163+
const isValidHmac = new Hmac(this.cryptoKey).compare(
164+
`${cipherEncoded}${this.separator}${ivEncoded}`,
165+
macEncoded
166+
)
167+
168+
if (!isValidHmac) {
169+
return null
170+
}
171+
172+
try {
173+
/**
174+
* Use the first 32 bytes of the key for AES-256
175+
*/
176+
const encryptionKey = this.cryptoKey.subarray(0, 32)
177+
178+
const decipher = createDecipheriv('aes-256-cbc', encryptionKey, iv)
179+
const plainTextBuffer = Buffer.concat([decipher.update(cipherText), decipher.final()])
180+
181+
return new MessageBuilder().verify<T>(plainTextBuffer, purpose)
182+
} catch {
183+
return null
184+
}
185+
}
186+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@
133133
"@adonisjs/http-transformers": "^2.1.0",
134134
"@adonisjs/logger": "^7.1.0-next.3",
135135
"@adonisjs/repl": "^5.0.0-next.1",
136-
"@boringnode/encryption": "^0.2.4",
136+
"@boringnode/encryption": "^0.2.5",
137137
"@poppinss/colors": "^4.1.6",
138138
"@poppinss/dumper": "^0.7.0",
139139
"@poppinss/macroable": "^1.1.0",

0 commit comments

Comments
 (0)