Skip to content

Commit 4f660f7

Browse files
committed
feat(encryption): add blind index API across drivers
1 parent e78c3f7 commit 4f660f7

11 files changed

Lines changed: 292 additions & 2 deletions

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ npm install @boringnode/encryption
2525
- **Deterministic Encryption**: AES-SIV driver for equality queries
2626
- **Purpose-Bound Encryption**: Ensure encrypted values are used for their intended purpose
2727
- **Expiration Support**: Set time-to-live on encrypted values
28+
- **Blind Indexes**: Deterministic indexes for equality queries
2829
- **Message Verification**: Sign data without encrypting (HMAC-based)
2930
- **Type-Safe**: Full TypeScript support with typed payloads
3031

@@ -181,6 +182,32 @@ const token = encryption.encrypt({ userId: 1 }, '7d')
181182
encryption.decrypt(expiredToken) // => null
182183
```
183184

185+
## Blind Indexes
186+
187+
Blind indexes are deterministic hashes used for equality queries:
188+
189+
```typescript
190+
const index = encryption.blindIndex('foo@example.com', 'users.email')
191+
```
192+
193+
When rotating keys, query using all blind indexes:
194+
195+
```typescript
196+
const indexes = encryption.blindIndexes('foo@example.com', 'users.email')
197+
// Use SQL: WHERE email_index IN (...)
198+
```
199+
200+
Rules:
201+
202+
- `purpose` is required and should identify the field/context (`users.email`, `users.ssn`, ...).
203+
- Matching is exact-bytes (no implicit normalization).
204+
- Prefer normalized primitive values for blind indexes (`string`/`number`/`boolean`/ISO date).
205+
- For structured objects, normalize/canonicalize before indexing (for example, map object -> stable string yourself).
206+
207+
```typescript
208+
const emailIndex = encryption.blindIndex(email.trim().toLowerCase(), 'users.email')
209+
```
210+
184211
## Message Verifier
185212

186213
When you need to ensure data integrity without hiding the content, use the `MessageVerifier`. The payload is base64-encoded (not encrypted) and signed with HMAC.

src/drivers/base_driver.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
* @copyright Boring Node
66
*/
77

8-
import { createHash } from 'node:crypto'
8+
import { createHash, createHmac, hkdfSync } from 'node:crypto'
9+
import { MessageBuilder, type Secret } from '@poppinss/utils'
910
import * as errors from '../exceptions.ts'
10-
import type { Secret } from '@poppinss/utils'
11+
import { base64UrlEncode } from '../base64.ts'
1112
import type { BaseConfig, CypherText, EncryptOptions } from '../types/main.ts'
1213

1314
export abstract class BaseDriver {
@@ -16,6 +17,7 @@ export abstract class BaseDriver {
1617
* from the user provided secret.
1718
*/
1819
cryptoKey: Buffer
20+
#blindIndexKey: Buffer
1921

2022
/**
2123
* Use `dot` as a separator for joining encrypted value, iv and the
@@ -26,6 +28,17 @@ export abstract class BaseDriver {
2628
protected constructor(config: BaseConfig) {
2729
const key = this.#validateAndGetSecret(config.key)
2830
this.cryptoKey = createHash('sha256').update(key).digest()
31+
32+
const rawBlindIndexKey = hkdfSync(
33+
'sha256',
34+
this.cryptoKey,
35+
Buffer.alloc(0),
36+
Buffer.from(`blind-index:${config.id || 'default'}`),
37+
32
38+
)
39+
this.#blindIndexKey = Buffer.isBuffer(rawBlindIndexKey)
40+
? rawBlindIndexKey
41+
: Buffer.from(rawBlindIndexKey)
2942
}
3043

3144
/**
@@ -48,6 +61,26 @@ export abstract class BaseDriver {
4861
return values.join(this.separator) as CypherText
4962
}
5063

64+
blindIndex(payload: any, purpose: string): string {
65+
if (typeof purpose !== 'string' || purpose.trim().length === 0) {
66+
throw new errors.E_BLIND_INDEX_PURPOSE_REQUIRED()
67+
}
68+
69+
const rawPayload = new MessageBuilder().build(payload)
70+
const payloadBuffer = Buffer.isBuffer(rawPayload) ? rawPayload : Buffer.from(rawPayload)
71+
const indexPayload = Buffer.concat([
72+
Buffer.from(purpose),
73+
Buffer.from(this.separator),
74+
payloadBuffer,
75+
])
76+
77+
return base64UrlEncode(createHmac('sha256', this.#blindIndexKey).update(indexPayload).digest())
78+
}
79+
80+
blindIndexes(payload: any, purpose: string): string[] {
81+
return [this.blindIndex(payload, purpose)]
82+
}
83+
5184
/**
5285
* Encrypt a given piece of value using the app secret. A wide range of
5386
* data types are supported.

src/encryption.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,22 @@ export class Encryption {
6363
return null
6464
}
6565

66+
blindIndex(payload: any, purpose: string): string {
67+
return this.#drivers[0].blindIndex(payload, purpose)
68+
}
69+
70+
blindIndexes(payload: any, purpose: string): string[] {
71+
const indexes = new Set<string>()
72+
73+
for (const driver of this.#drivers) {
74+
for (const index of driver.blindIndexes(payload, purpose)) {
75+
indexes.add(index)
76+
}
77+
}
78+
79+
return [...indexes]
80+
}
81+
6682
/**
6783
* Get the message verifier instance
6884
*/

src/encryption_manager.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,12 @@ export class EncryptionManager<KnownEncrypters extends Record<string, Encryption
9999
decrypt<T extends any>(value: string, purpose?: string): T | null {
100100
return this.use().decrypt(value, purpose)
101101
}
102+
103+
blindIndex(payload: any, purpose: string): string {
104+
return this.use().blindIndex(payload, purpose)
105+
}
106+
107+
blindIndexes(payload: any, purpose: string): string[] {
108+
return this.use().blindIndexes(payload, purpose)
109+
}
102110
}

src/exceptions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,8 @@ export const E_DETERMINISTIC_DRIVER_EXPIRES_IN_NOT_SUPPORTED = createError(
2626
'Deterministic encryption does not support expiresIn',
2727
'E_DETERMINISTIC_DRIVER_EXPIRES_IN_NOT_SUPPORTED'
2828
)
29+
30+
export const E_BLIND_INDEX_PURPOSE_REQUIRED = createError(
31+
'Blind index requires a non-empty purpose',
32+
'E_BLIND_INDEX_PURPOSE_REQUIRED'
33+
)

src/types/main.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@ export interface EncryptionDriverContract {
4242
* Decrypt value and verify it against a purpose
4343
*/
4444
decrypt<T extends any>(value: string, purpose?: string): T | null
45+
46+
/**
47+
* Compute a deterministic blind index for equality lookups.
48+
*/
49+
blindIndex(payload: any, purpose: string): string
50+
51+
/**
52+
* Compute blind indexes for all keys used by the driver.
53+
*/
54+
blindIndexes(payload: any, purpose: string): string[]
4555
}
4656

4757
/**
@@ -53,6 +63,7 @@ export type ManagerDriverFactory = () => EncryptionDriverContract
5363

5464
export interface BaseConfig {
5565
key: string | Secret<string>
66+
id?: string
5667
}
5768

5869
export interface LegacyConfig extends BaseConfig {}

tests/drivers/aes_256_cbc.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,37 @@ test.group('AES-256-CBC', () => {
169169

170170
assert.deepEqual(driver.decrypt(encrypted, 'register'), { username: 'lanz' })
171171
})
172+
173+
test('create deterministic blind index for the same value and purpose', ({ assert }) => {
174+
const driver = new AES256CBC({ id: 'lanz', key: SECRET })
175+
const one = driver.blindIndex('foo@example.com', 'users.email')
176+
const two = driver.blindIndex('foo@example.com', 'users.email')
177+
178+
assert.equal(one, two)
179+
})
180+
181+
test('return different blind index when purpose changes', ({ assert }) => {
182+
const driver = new AES256CBC({ id: 'lanz', key: SECRET })
183+
const one = driver.blindIndex('foo@example.com', 'users.email')
184+
const two = driver.blindIndex('foo@example.com', 'users.login')
185+
186+
assert.notEqual(one, two)
187+
})
188+
189+
test('return blind indexes list', ({ assert }) => {
190+
const driver = new AES256CBC({ id: 'lanz', key: SECRET })
191+
const indexes = driver.blindIndexes('foo@example.com', 'users.email')
192+
193+
assert.lengthOf(indexes, 1)
194+
assert.equal(indexes[0], driver.blindIndex('foo@example.com', 'users.email'))
195+
})
196+
197+
test('fail when blind index purpose is missing', ({ assert }) => {
198+
const driver = new AES256CBC({ id: 'lanz', key: SECRET })
199+
200+
assert.throws(
201+
() => driver.blindIndex('foo@example.com', ''),
202+
'Blind index requires a non-empty purpose'
203+
)
204+
})
172205
})

tests/drivers/aes_256_gcm.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,4 +176,37 @@ test.group('AES-256-GCM', () => {
176176

177177
assert.deepEqual(driver.decrypt(encrypted, 'register'), { username: 'lanz' })
178178
})
179+
180+
test('create deterministic blind index for the same value and purpose', ({ assert }) => {
181+
const driver = new AES256GCM({ id: 'lanz', key: SECRET })
182+
const one = driver.blindIndex('foo@example.com', 'users.email')
183+
const two = driver.blindIndex('foo@example.com', 'users.email')
184+
185+
assert.equal(one, two)
186+
})
187+
188+
test('return different blind index when purpose changes', ({ assert }) => {
189+
const driver = new AES256GCM({ id: 'lanz', key: SECRET })
190+
const one = driver.blindIndex('foo@example.com', 'users.email')
191+
const two = driver.blindIndex('foo@example.com', 'users.login')
192+
193+
assert.notEqual(one, two)
194+
})
195+
196+
test('return blind indexes list', ({ assert }) => {
197+
const driver = new AES256GCM({ id: 'lanz', key: SECRET })
198+
const indexes = driver.blindIndexes('foo@example.com', 'users.email')
199+
200+
assert.lengthOf(indexes, 1)
201+
assert.equal(indexes[0], driver.blindIndex('foo@example.com', 'users.email'))
202+
})
203+
204+
test('fail when blind index purpose is missing', ({ assert }) => {
205+
const driver = new AES256GCM({ id: 'lanz', key: SECRET })
206+
207+
assert.throws(
208+
() => driver.blindIndex('foo@example.com', ''),
209+
'Blind index requires a non-empty purpose'
210+
)
211+
})
179212
})

tests/drivers/chacha20_poly1305.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,37 @@ test.group('ChaCha20-Poly1305', () => {
169169

170170
assert.deepEqual(driver.decrypt(encrypted, 'register'), { username: 'lanz' })
171171
})
172+
173+
test('create deterministic blind index for the same value and purpose', ({ assert }) => {
174+
const driver = new ChaCha20Poly1305({ id: 'lanz', key: SECRET })
175+
const one = driver.blindIndex('foo@example.com', 'users.email')
176+
const two = driver.blindIndex('foo@example.com', 'users.email')
177+
178+
assert.equal(one, two)
179+
})
180+
181+
test('return different blind index when purpose changes', ({ assert }) => {
182+
const driver = new ChaCha20Poly1305({ id: 'lanz', key: SECRET })
183+
const one = driver.blindIndex('foo@example.com', 'users.email')
184+
const two = driver.blindIndex('foo@example.com', 'users.login')
185+
186+
assert.notEqual(one, two)
187+
})
188+
189+
test('return blind indexes list', ({ assert }) => {
190+
const driver = new ChaCha20Poly1305({ id: 'lanz', key: SECRET })
191+
const indexes = driver.blindIndexes('foo@example.com', 'users.email')
192+
193+
assert.lengthOf(indexes, 1)
194+
assert.equal(indexes[0], driver.blindIndex('foo@example.com', 'users.email'))
195+
})
196+
197+
test('fail when blind index purpose is missing', ({ assert }) => {
198+
const driver = new ChaCha20Poly1305({ id: 'lanz', key: SECRET })
199+
200+
assert.throws(
201+
() => driver.blindIndex('foo@example.com', ''),
202+
'Blind index requires a non-empty purpose'
203+
)
204+
})
172205
})

tests/encryption.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,54 @@ test.group('Encryption', () => {
155155
const encrypted = encryption.encrypt({ username: 'virk' }, { expiresIn: '1h', purpose: 'test' })
156156
assert.deepEqual(encryption.decrypt(encrypted, 'test'), { username: 'virk' })
157157
})
158+
159+
test('compute blind index using first key', ({ assert }) => {
160+
const encryption = new Encryption({
161+
driver: (key) => new ChaCha20Poly1305({ id: 'test', key }),
162+
keys: [SECRET, SECRET_2],
163+
})
164+
165+
const blindIndex = encryption.blindIndex('foo@example.com', 'users.email')
166+
167+
const singleKeyEncryption = new Encryption({
168+
driver: (key) => new ChaCha20Poly1305({ id: 'test', key }),
169+
keys: [SECRET],
170+
})
171+
172+
assert.equal(blindIndex, singleKeyEncryption.blindIndex('foo@example.com', 'users.email'))
173+
})
174+
175+
test('compute blind indexes for all keys', ({ assert }) => {
176+
const encryption = new Encryption({
177+
driver: (key) => new ChaCha20Poly1305({ id: 'test', key }),
178+
keys: [SECRET, SECRET_2],
179+
})
180+
181+
const indexes = encryption.blindIndexes('foo@example.com', 'users.email')
182+
assert.lengthOf(indexes, 2)
183+
184+
const singleKeyEncryption1 = new Encryption({
185+
driver: (key) => new ChaCha20Poly1305({ id: 'test', key }),
186+
keys: [SECRET],
187+
})
188+
const singleKeyEncryption2 = new Encryption({
189+
driver: (key) => new ChaCha20Poly1305({ id: 'test', key }),
190+
keys: [SECRET_2],
191+
})
192+
193+
assert.include(indexes, singleKeyEncryption1.blindIndex('foo@example.com', 'users.email'))
194+
assert.include(indexes, singleKeyEncryption2.blindIndex('foo@example.com', 'users.email'))
195+
})
196+
197+
test('fail when blind index purpose is missing', ({ assert }) => {
198+
const encryption = new Encryption({
199+
driver: (key) => new ChaCha20Poly1305({ id: 'test', key }),
200+
keys: [SECRET],
201+
})
202+
203+
assert.throws(
204+
() => encryption.blindIndex('foo@example.com', ''),
205+
'Blind index requires a non-empty purpose'
206+
)
207+
})
158208
})

0 commit comments

Comments
 (0)