Skip to content

Commit e78c3f7

Browse files
committed
feat(drivers): add deterministic AES-SIV driver
1 parent 6379d42 commit e78c3f7

5 files changed

Lines changed: 607 additions & 2 deletions

File tree

README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ npm install @boringnode/encryption
2020

2121
## Features
2222

23-
- **Multiple Algorithms**: ChaCha20-Poly1305, AES-256-GCM, AES-256-CBC
23+
- **Multiple Algorithms**: ChaCha20-Poly1305, AES-256-GCM, AES-256-CBC, AES-SIV
2424
- **Key Rotation**: Encrypt with new keys, decrypt with old ones
25+
- **Deterministic Encryption**: AES-SIV driver for equality queries
2526
- **Purpose-Bound Encryption**: Ensure encrypted values are used for their intended purpose
2627
- **Expiration Support**: Set time-to-live on encrypted values
2728
- **Message Verification**: Sign data without encrypting (HMAC-based)
@@ -107,6 +108,24 @@ const config = aes256cbc({
107108
})
108109
```
109110

111+
### AES-SIV (deterministic)
112+
113+
Deterministic encryption for direct equality lookups on encrypted columns.
114+
115+
```typescript
116+
import { aessiv } from '@boringnode/encryption/drivers/aes_siv'
117+
118+
const config = aessiv({
119+
id: 'app',
120+
key: 'your-32-character-secret-key-here',
121+
})
122+
```
123+
124+
Notes:
125+
126+
- `expiresIn` is not supported with deterministic encryption.
127+
- Key rotation is not automatic for deterministic ciphertexts. Use an explicit migration/backfill strategy.
128+
110129
## Key Rotation
111130

112131
The library supports multiple keys for seamless key rotation. The first key is used for encryption, while all keys are tried during decryption.

src/drivers/aes_siv.ts

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
/*
2+
* @boringnode/encryption
3+
*
4+
* @license MIT
5+
* @copyright Boring Node
6+
*/
7+
8+
import { createCipheriv, createDecipheriv, hkdfSync } from 'node:crypto'
9+
import { MessageBuilder, type Secret } from '@poppinss/utils'
10+
import { BaseDriver } from './base_driver.ts'
11+
import { base64UrlDecode, base64UrlEncode } from '../base64.ts'
12+
import * as errors from '../exceptions.ts'
13+
import { safeEqual } from '../safe_equal.ts'
14+
import type {
15+
AESSIVConfig,
16+
CypherText,
17+
EncryptionConfig,
18+
EncryptionDriverContract,
19+
EncryptOptions,
20+
} from '../types/main.ts'
21+
22+
export interface AESSIVDriverConfig {
23+
id: string
24+
key: string | Secret<string>
25+
}
26+
27+
export function aessiv(config: AESSIVDriverConfig) {
28+
return {
29+
driver: (key) => new AESSIV({ id: config.id, key }),
30+
keys: [config.key],
31+
} satisfies EncryptionConfig
32+
}
33+
34+
export class AESSIV extends BaseDriver implements EncryptionDriverContract {
35+
#config: AESSIVConfig
36+
static readonly #BLOCK_SIZE = 16
37+
static readonly #ZERO_BLOCK = Buffer.alloc(AESSIV.#BLOCK_SIZE, 0)
38+
39+
constructor(config: AESSIVConfig) {
40+
super(config)
41+
42+
this.#config = config
43+
44+
if (typeof config.id !== 'string') {
45+
throw new errors.E_MISSING_ENCRYPTER_ID()
46+
}
47+
}
48+
49+
encrypt(payload: any, options?: EncryptOptions): CypherText
50+
encrypt(payload: any, expiresIn?: string | number, purpose?: string): CypherText
51+
encrypt(
52+
payload: any,
53+
expiresInOrOptions?: string | number | EncryptOptions,
54+
purpose?: string
55+
): CypherText {
56+
let expiresIn: string | number | undefined
57+
let actualPurpose: string | undefined
58+
59+
if (typeof expiresInOrOptions === 'object' && expiresInOrOptions !== null) {
60+
expiresIn = expiresInOrOptions.expiresIn
61+
actualPurpose = expiresInOrOptions.purpose
62+
} else {
63+
expiresIn = expiresInOrOptions
64+
actualPurpose = purpose
65+
}
66+
67+
if (expiresIn !== undefined) {
68+
throw new errors.E_DETERMINISTIC_DRIVER_EXPIRES_IN_NOT_SUPPORTED()
69+
}
70+
71+
const { macKey, encryptionKey } = this.#deriveKeys()
72+
const plainTextValue = new MessageBuilder().build(payload)
73+
const plainText = Buffer.isBuffer(plainTextValue) ? plainTextValue : Buffer.from(plainTextValue)
74+
const associatedData = actualPurpose ? [Buffer.from(actualPurpose)] : []
75+
76+
const { syntheticIv, cipherText } = AESSIV.encryptRaw(
77+
macKey,
78+
encryptionKey,
79+
plainText,
80+
associatedData
81+
)
82+
83+
return this.computeReturns([
84+
this.#config.id,
85+
base64UrlEncode(cipherText),
86+
base64UrlEncode(syntheticIv),
87+
])
88+
}
89+
90+
decrypt<T extends any>(value: string, purpose?: string): T | null {
91+
if (typeof value !== 'string') {
92+
return null
93+
}
94+
95+
const parts = value.split(this.separator)
96+
if (parts.length !== 3) {
97+
return null
98+
}
99+
100+
const [id, cipherEncoded, syntheticIvEncoded] = parts
101+
if (!id || !cipherEncoded || !syntheticIvEncoded) {
102+
return null
103+
}
104+
105+
if (id !== this.#config.id) {
106+
return null
107+
}
108+
109+
const cipherText = base64UrlDecode(cipherEncoded)
110+
if (!cipherText) {
111+
return null
112+
}
113+
114+
const syntheticIv = base64UrlDecode(syntheticIvEncoded)
115+
if (!syntheticIv || syntheticIv.length !== AESSIV.#BLOCK_SIZE) {
116+
return null
117+
}
118+
119+
try {
120+
const { macKey, encryptionKey } = this.#deriveKeys()
121+
const associatedData = purpose ? [Buffer.from(purpose)] : []
122+
const plainText = AESSIV.decryptRaw(
123+
macKey,
124+
encryptionKey,
125+
syntheticIv,
126+
cipherText,
127+
associatedData
128+
)
129+
if (!plainText) return null
130+
131+
return new MessageBuilder().verify(plainText)
132+
} catch {
133+
return null
134+
}
135+
}
136+
137+
#deriveKeys() {
138+
const rawDerivedKey = hkdfSync(
139+
'sha256',
140+
this.cryptoKey,
141+
Buffer.alloc(0),
142+
Buffer.from(`aes-siv:${this.#config.id}`),
143+
64
144+
)
145+
146+
const derivedKey = Buffer.isBuffer(rawDerivedKey) ? rawDerivedKey : Buffer.from(rawDerivedKey)
147+
148+
return {
149+
macKey: derivedKey.subarray(0, 32),
150+
encryptionKey: derivedKey.subarray(32),
151+
}
152+
}
153+
154+
/**
155+
* Low-level AES-SIV primitive. Exposed as static for RFC 5297 conformance tests.
156+
*/
157+
static encryptRaw(
158+
macKey: Buffer,
159+
encryptionKey: Buffer,
160+
plainText: Buffer,
161+
associatedData: Buffer[] = []
162+
): {
163+
syntheticIv: Buffer
164+
cipherText: Buffer
165+
} {
166+
AESSIV.#ensureSupportedKeyLength(macKey, encryptionKey)
167+
168+
const syntheticIv = AESSIV.#s2v(macKey, associatedData, plainText)
169+
const iv = AESSIV.#toCtrIv(syntheticIv)
170+
const cipher = createCipheriv(AESSIV.#getCtrAlgorithm(encryptionKey), encryptionKey, iv)
171+
const cipherText = Buffer.concat([cipher.update(plainText), cipher.final()])
172+
173+
return { syntheticIv, cipherText }
174+
}
175+
176+
static decryptRaw(
177+
macKey: Buffer,
178+
encryptionKey: Buffer,
179+
syntheticIv: Buffer,
180+
cipherText: Buffer,
181+
associatedData: Buffer[] = []
182+
): Buffer | null {
183+
AESSIV.#ensureSupportedKeyLength(macKey, encryptionKey)
184+
185+
const iv = AESSIV.#toCtrIv(syntheticIv)
186+
const decipher = createDecipheriv(AESSIV.#getCtrAlgorithm(encryptionKey), encryptionKey, iv)
187+
const plainText = Buffer.concat([decipher.update(cipherText), decipher.final()])
188+
const expectedSyntheticIv = AESSIV.#s2v(macKey, associatedData, plainText)
189+
190+
if (!safeEqual(expectedSyntheticIv, syntheticIv)) {
191+
return null
192+
}
193+
194+
return plainText
195+
}
196+
static #toCtrIv(syntheticIv: Buffer): Buffer {
197+
const iv = Buffer.from(syntheticIv)
198+
iv[8] &= 0x7f
199+
iv[12] &= 0x7f
200+
return iv
201+
}
202+
203+
static #s2v(macKey: Buffer, associatedData: Buffer[], plainText: Buffer): Buffer {
204+
let d = AESSIV.#cmac(macKey, AESSIV.#ZERO_BLOCK)
205+
206+
for (const item of associatedData) {
207+
d = AESSIV.#xorBuffers(AESSIV.#dbl(d), AESSIV.#cmac(macKey, item))
208+
}
209+
210+
const t =
211+
plainText.length >= AESSIV.#BLOCK_SIZE
212+
? AESSIV.#xorEnd(plainText, d)
213+
: AESSIV.#xorBuffers(AESSIV.#dbl(d), AESSIV.#pad(plainText))
214+
215+
return AESSIV.#cmac(macKey, t)
216+
}
217+
218+
static #cmac(key: Buffer, message: Buffer): Buffer {
219+
const l = AESSIV.#aesEcbEncryptBlock(key, AESSIV.#ZERO_BLOCK)
220+
const k1 = AESSIV.#dbl(l)
221+
const k2 = AESSIV.#dbl(k1)
222+
223+
let blockCount = Math.ceil(message.length / AESSIV.#BLOCK_SIZE)
224+
blockCount = blockCount === 0 ? 1 : blockCount
225+
226+
const isLastBlockComplete = message.length !== 0 && message.length % AESSIV.#BLOCK_SIZE === 0
227+
let lastBlock: Buffer
228+
if (isLastBlockComplete) {
229+
const lastStart = (blockCount - 1) * AESSIV.#BLOCK_SIZE
230+
lastBlock = AESSIV.#xorBuffers(
231+
message.subarray(lastStart, lastStart + AESSIV.#BLOCK_SIZE),
232+
k1
233+
)
234+
} else {
235+
const lastStart = (blockCount - 1) * AESSIV.#BLOCK_SIZE
236+
lastBlock = AESSIV.#xorBuffers(AESSIV.#pad(message.subarray(lastStart)), k2)
237+
}
238+
239+
let x: Buffer<ArrayBufferLike> = Buffer.alloc(AESSIV.#BLOCK_SIZE, 0)
240+
for (let index = 0; index < blockCount - 1; index++) {
241+
const start = index * AESSIV.#BLOCK_SIZE
242+
const block = message.subarray(start, start + AESSIV.#BLOCK_SIZE)
243+
x = AESSIV.#aesEcbEncryptBlock(key, AESSIV.#xorBuffers(x, block))
244+
}
245+
246+
return AESSIV.#aesEcbEncryptBlock(key, AESSIV.#xorBuffers(x, lastBlock))
247+
}
248+
249+
static #aesEcbEncryptBlock(key: Buffer, block: Buffer): Buffer {
250+
const cipher = createCipheriv(AESSIV.#getEcbAlgorithm(key), key, null)
251+
cipher.setAutoPadding(false)
252+
return Buffer.concat([cipher.update(block), cipher.final()])
253+
}
254+
255+
static #dbl(block: Buffer): Buffer {
256+
const output = Buffer.alloc(AESSIV.#BLOCK_SIZE)
257+
const msbSet = (block[0] & 0x80) !== 0
258+
259+
let carry = 0
260+
for (let index = AESSIV.#BLOCK_SIZE - 1; index >= 0; index--) {
261+
const value = block[index]
262+
output[index] = ((value << 1) & 0xff) | carry
263+
carry = (value & 0x80) !== 0 ? 1 : 0
264+
}
265+
266+
if (msbSet) {
267+
output[AESSIV.#BLOCK_SIZE - 1] ^= 0x87
268+
}
269+
270+
return output
271+
}
272+
273+
static #xorBuffers(left: Buffer, right: Buffer): Buffer {
274+
const output = Buffer.alloc(left.length)
275+
for (const [index, value] of left.entries()) {
276+
output[index] = value ^ right[index]
277+
}
278+
return output
279+
}
280+
281+
static #xorEnd(buffer: Buffer, block: Buffer): Buffer {
282+
const output = Buffer.from(buffer)
283+
const offset = output.length - AESSIV.#BLOCK_SIZE
284+
for (let index = 0; index < AESSIV.#BLOCK_SIZE; index++) {
285+
output[offset + index] ^= block[index]
286+
}
287+
return output
288+
}
289+
290+
static #pad(buffer: Buffer): Buffer {
291+
const output = Buffer.alloc(AESSIV.#BLOCK_SIZE, 0)
292+
buffer.copy(output)
293+
output[buffer.length] = 0x80
294+
return output
295+
}
296+
297+
static #getEcbAlgorithm(key: Buffer): 'aes-128-ecb' | 'aes-192-ecb' | 'aes-256-ecb' {
298+
switch (key.length) {
299+
case 16: {
300+
return 'aes-128-ecb'
301+
}
302+
case 24: {
303+
return 'aes-192-ecb'
304+
}
305+
case 32: {
306+
return 'aes-256-ecb'
307+
}
308+
default: {
309+
throw new TypeError('Invalid AES key length for SIV')
310+
}
311+
}
312+
}
313+
314+
static #getCtrAlgorithm(key: Buffer): 'aes-128-ctr' | 'aes-192-ctr' | 'aes-256-ctr' {
315+
switch (key.length) {
316+
case 16: {
317+
return 'aes-128-ctr'
318+
}
319+
case 24: {
320+
return 'aes-192-ctr'
321+
}
322+
case 32: {
323+
return 'aes-256-ctr'
324+
}
325+
default: {
326+
throw new TypeError('Invalid AES key length for SIV')
327+
}
328+
}
329+
}
330+
331+
static #ensureSupportedKeyLength(macKey: Buffer, encryptionKey: Buffer) {
332+
if (macKey.length !== encryptionKey.length) {
333+
throw new TypeError('SIV subkeys must have the same length')
334+
}
335+
336+
AESSIV.#getEcbAlgorithm(macKey)
337+
AESSIV.#getCtrAlgorithm(encryptionKey)
338+
}
339+
}

src/exceptions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,8 @@ export const E_MISSING_ENCRYPTER_ID = createError(
2121
'Missing id. The id is required to encrypt values',
2222
'E_MISSING_ENCRYPTER_ID'
2323
)
24+
25+
export const E_DETERMINISTIC_DRIVER_EXPIRES_IN_NOT_SUPPORTED = createError(
26+
'Deterministic encryption does not support expiresIn',
27+
'E_DETERMINISTIC_DRIVER_EXPIRES_IN_NOT_SUPPORTED'
28+
)

0 commit comments

Comments
 (0)