Skip to content

Commit 8041ceb

Browse files
committed
feat: add CryptoAdapter interface to allow custom encryption/decryption strategies for session data
1 parent b74d163 commit 8041ceb

File tree

9 files changed

+543
-73
lines changed

9 files changed

+543
-73
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- **Types:** `MongoStore` and option hooks are strongly typed to avoid `any` leaks.
1313
- **Fixed:** `store.clear()` now uses `deleteMany({})` instead of `collection.drop()`, preserving TTL indexes and treating `NamespaceNotFound` as success so clears are idempotent.
1414
- **Fixed:** Decryption failures in `get()` now short-circuit after the first callback, preventing double-callback regressions when the crypto secret is wrong.
15+
- **Added:** Pluggable `cryptoAdapter` interface with helpers `createWebCryptoAdapter` (AES-GCM via Web Crypto API) and `createKrupteinAdapter`; legacy `crypto` options are auto-wrapped and mutually exclusive with `cryptoAdapter` to avoid ambiguity.
1516
- **Added:** Optional `timestamps` flag to record `createdAt`/`updatedAt` on session documents for auditing while keeping the default schema unchanged.
1617

1718
## [5.1.0] - 2023-10-14

README.md

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -251,17 +251,33 @@ by doing this, setting `touchAfter: 24 * 3600` you are saying to the session be
251251

252252
## Transparent encryption/decryption of session data
253253

254-
When working with sensitive session data it is [recommended](https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/Session_Management_Cheat_Sheet.md) to use encryption
254+
When working with sensitive session data it is [recommended](https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/Session_Management_Cheat_Sheet.md) to use encryption.
255+
Use the new `cryptoAdapter` option to plug in your encryption strategy. The preferred helper uses the Web Crypto API (AES-GCM):
256+
257+
```ts
258+
import MongoStore, { createWebCryptoAdapter } from 'connect-mongo'
255259

256-
```js
257260
const store = MongoStore.create({
258261
mongoUrl: 'mongodb://localhost/test-app',
259-
crypto: {
260-
secret: 'squirrel'
261-
}
262+
cryptoAdapter: createWebCryptoAdapter({
263+
secret: process.env.SESSION_SECRET!,
264+
}),
262265
})
263266
```
264267

268+
If you need the legacy [kruptein](https://www.npmjs.com/package/kruptein) behavior, wrap it explicitly:
269+
270+
```ts
271+
import { createKrupteinAdapter } from 'connect-mongo'
272+
273+
const store = MongoStore.create({
274+
mongoUrl: 'mongodb://localhost/test-app',
275+
cryptoAdapter: createKrupteinAdapter({ secret: 'squirrel' }),
276+
})
277+
```
278+
279+
The legacy `crypto` option still works for backwards compatibility; it is automatically wrapped into a kruptein-based adapter. Supplying both `crypto` and `cryptoAdapter` throws an error so it is clear which path is used.
280+
265281
## Options
266282

267283
### Connection-related options (required)
@@ -291,11 +307,14 @@ One of the following options should be provided. If more than one option are pro
291307
|`unserialize`||Custom hook for unserializing sessions from MongoDB. This can be used in scenarios where you need to support different types of serializations (e.g., objects and JSON strings) or need to modify the session before using it in your app.|
292308
|`writeOperationOptions`||Options object to pass to every MongoDB write operation call that supports it (e.g. `update`, `remove`). Useful for adjusting the write concern. Only exception: If `autoRemove` is set to `'interval'`, the write concern from the `writeOperationOptions` object will get overwritten.|
293309
|`transformId`||Transform original `sessionId` in whatever you want to use as storage key.|
310+
|`cryptoAdapter`||Preferred hook for encrypting/decrypting session payloads. Accepts any object with async `encrypt`/`decrypt` functions; helpers `createWebCryptoAdapter` (AES-GCM via Web Crypto API) and `createKrupteinAdapter` are provided.|
294311
|`crypto`||Crypto related options. See below.|
295312

296313
If you enable `timestamps`, each session document will include `createdAt` (first insert) and `updatedAt` (every subsequent `set`/`touch`) fields. These fields are informational only and do not change TTL behavior.
297314

298-
### Crypto-related options
315+
### Crypto-related options (legacy)
316+
317+
Prefer `cryptoAdapter` for new integrations. The legacy `crypto` options are wrapped internally into a kruptein adapter to preserve backwards compatibility:
299318

300319
|Option|Default|Description|
301320
|------|:-----:|-----------|

ava.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ export default {
22
files: [
33
'src/**/*.{test,spec}.ts'
44
],
5-
failFast: true,
5+
failFast: false,
66
typescript: {
77
// map TS paths -> compiled JS paths
88
rewritePaths: {

docs/PLANS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
- Align session TTL math with express-session: prefer `cookie.maxAge`, then `cookie.expires`, then `ttl` in both `set()` and `touch()` so rolling sessions retain their intended lifetime.
77
- Avoid closing user-supplied MongoClient instances in `close()`; only shut down clients created by the store and always clear timers.
88
- [done 2025-11-25] Add optional createdAt/updatedAt timestamps on session documents, disabled by default.
9+
- [started 2025-11-30] Add CryptoAdapter interface for pluggable encryption (wrap legacy crypto option, prefer Web Crypto helper, document usage, add tests).
10+
- [done 2025-11-30] Rewrite decrypt failure callback test for cryptoAdapter (agent).
911

1012
- Tooling & CI
1113
- Rework integration helpers: replace the broken `check-cli`/`diff-integration-tests`, document a safe reset workflow, and migrate `test:integration` to mongodb-memory-server.

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,10 @@
136136
"all": true,
137137
"skipFull": false,
138138
"check-coverage": true,
139-
"branches": 75,
140-
"functions": 70,
141-
"lines": 80,
142-
"statements": 80
139+
"branches": 85,
140+
"functions": 85,
141+
"lines": 85,
142+
"statements": 85
143143
},
144144
"lint-staged": {
145145
"**/*.{ts,js}": [

src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
import MongoStore from './lib/MongoStore.js'
2+
export {
3+
createKrupteinAdapter,
4+
createWebCryptoAdapter,
5+
type CryptoAdapter,
6+
type CryptoOptions,
7+
type WebCryptoAdapterOptions,
8+
} from './lib/cryptoAdapters.js'
29

310
export default MongoStore
411
export { MongoStore }

src/lib/MongoStore.spec.ts

Lines changed: 223 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import test from 'ava'
22
import { SessionData } from 'express-session'
33
import { MongoClient } from 'mongodb'
44

5-
import MongoStore from './MongoStore.js'
5+
import MongoStore, {
6+
createWebCryptoAdapter,
7+
createKrupteinAdapter,
8+
type CryptoAdapter,
9+
} from './MongoStore.js'
610
import {
711
createStoreHelper,
812
makeData,
@@ -296,15 +300,26 @@ test.serial('clear preserves TTL index and is idempotent', async (t) => {
296300
})
297301

298302
test.serial('decrypt failure only calls callback once', async (t) => {
299-
;({ store, storePromise } = createStoreHelper({
300-
crypto: {
301-
secret: 'right-secret',
303+
let secret = 'right-secret'
304+
const adapter: CryptoAdapter = {
305+
async encrypt(plaintext) {
306+
return `${secret}:${plaintext}`
302307
},
303-
}))
308+
async decrypt(ciphertext) {
309+
const prefix = `${secret}:`
310+
if (!ciphertext.startsWith(prefix)) {
311+
throw new Error('bad secret')
312+
}
313+
return ciphertext.slice(prefix.length)
314+
},
315+
}
316+
317+
;({ store, storePromise } = createStoreHelper({ cryptoAdapter: adapter }))
304318
const sid = 'decrypt-failure'
305319
await storePromise.set(sid, makeData())
320+
306321
// Tamper with the secret so decryption fails
307-
;(store as any).options.crypto.secret = 'wrong-secret'
322+
secret = 'wrong-secret'
308323

309324
await new Promise<void>((resolve) => {
310325
let calls = 0
@@ -388,10 +403,10 @@ test.serial(
388403
}
389404
)
390405

391-
test.serial('test destory event', async (t) => {
406+
test.serial('test destroy event', async (t) => {
392407
;({ store, storePromise } = createStoreHelper())
393408
const orgSession = makeData()
394-
const sid = 'test-destory-event'
409+
const sid = 'test-destroy-event'
395410

396411
const waitForDestroy = new Promise<void>((resolve, reject) => {
397412
store.once('destroy', (sessionId: string) => {
@@ -618,6 +633,206 @@ test.serial(
618633
}
619634
)
620635

636+
test.serial('cryptoAdapter conflicts with legacy crypto option', (t) => {
637+
const adapter: CryptoAdapter = {
638+
encrypt: async (payload) => payload,
639+
decrypt: async (payload) => payload,
640+
}
641+
t.throws(
642+
() =>
643+
MongoStore.create({
644+
mongoUrl: 'mongodb://root:example@127.0.0.1:27017',
645+
crypto: { secret: 'secret' },
646+
cryptoAdapter: adapter,
647+
}),
648+
{ message: /legacy crypto option or cryptoAdapter/ }
649+
)
650+
})
651+
652+
test.serial('custom cryptoAdapter roundtrips session data', async (t) => {
653+
const adapter: CryptoAdapter = {
654+
encrypt: async (payload) => `enc:${payload}`,
655+
decrypt: async (payload) => payload.replace(/^enc:/, ''),
656+
}
657+
;({ store, storePromise } = createStoreHelper({
658+
cryptoAdapter: adapter,
659+
collectionName: 'custom-adapter',
660+
}))
661+
const sid = 'adapter-roundtrip'
662+
const original = makeData()
663+
await storePromise.set(sid, original)
664+
const session = await storePromise.get(sid)
665+
t.deepEqual(session, JSON.parse(JSON.stringify(original)))
666+
})
667+
668+
test.serial(
669+
'kruptein adapter helper merges defaults and works with only secret',
670+
async (t) => {
671+
;({ store, storePromise } = createStoreHelper({
672+
cryptoAdapter: createKrupteinAdapter({ secret: 'secret' }),
673+
collectionName: 'kruptein-adapter',
674+
}))
675+
const sid = 'kruptein-adapter'
676+
const original = makeData()
677+
await storePromise.set(sid, original)
678+
const session = await storePromise.get(sid)
679+
t.deepEqual(session, JSON.parse(JSON.stringify(original)))
680+
}
681+
)
682+
683+
test.serial('web crypto adapter encrypts and decrypts sessions', async (t) => {
684+
const adapter = createWebCryptoAdapter({ secret: 'sup3r-secr3t' })
685+
;({ store, storePromise } = createStoreHelper({
686+
cryptoAdapter: adapter,
687+
collectionName: 'webcrypto-adapter',
688+
}))
689+
const sid = 'webcrypto-session'
690+
const original = makeData()
691+
await storePromise.set(sid, original)
692+
const session = await storePromise.get(sid)
693+
t.deepEqual(session, JSON.parse(JSON.stringify(original)))
694+
})
695+
696+
test.serial('web crypto adapter supports base64url encoding', async (t) => {
697+
const adapter = createWebCryptoAdapter({
698+
secret: 'sup3r-secr3t',
699+
encoding: 'base64url',
700+
})
701+
;({ store, storePromise } = createStoreHelper({
702+
cryptoAdapter: adapter,
703+
collectionName: 'webcrypto-base64url',
704+
}))
705+
const sid = 'webcrypto-base64url'
706+
const original = makeData()
707+
await storePromise.set(sid, original)
708+
const session = await storePromise.get(sid)
709+
t.deepEqual(session, JSON.parse(JSON.stringify(original)))
710+
})
711+
712+
test.serial('web crypto adapter supports hex encoding', async (t) => {
713+
const adapter = createWebCryptoAdapter({
714+
secret: 'sup3r-secr3t',
715+
encoding: 'hex',
716+
})
717+
;({ store, storePromise } = createStoreHelper({
718+
cryptoAdapter: adapter,
719+
collectionName: 'webcrypto-hex',
720+
}))
721+
const sid = 'webcrypto-hex'
722+
const original = makeData()
723+
await storePromise.set(sid, original)
724+
const session = await storePromise.get(sid)
725+
t.deepEqual(session, JSON.parse(JSON.stringify(original)))
726+
})
727+
728+
test.serial(
729+
'web crypto adapter derives key with PBKDF2 salt/iterations overrides',
730+
async (t) => {
731+
const adapter = createWebCryptoAdapter({
732+
secret: 'sup3r-secr3t',
733+
encoding: 'base64url',
734+
salt: 'custom-salt',
735+
iterations: 100_000,
736+
})
737+
;({ store, storePromise } = createStoreHelper({
738+
cryptoAdapter: adapter,
739+
collectionName: 'webcrypto-pbkdf2',
740+
}))
741+
const sid = 'webcrypto-pbkdf2'
742+
const original = makeData()
743+
await storePromise.set(sid, original)
744+
const session = await storePromise.get(sid)
745+
t.deepEqual(session, JSON.parse(JSON.stringify(original)))
746+
}
747+
)
748+
749+
test.serial('web crypto adapter supports AES-CBC algorithm', async (t) => {
750+
const adapter = createWebCryptoAdapter({
751+
secret: 'sup3r-secr3t',
752+
algorithm: 'AES-CBC',
753+
ivLength: 16,
754+
})
755+
;({ store, storePromise } = createStoreHelper({
756+
cryptoAdapter: adapter,
757+
collectionName: 'webcrypto-aes-cbc',
758+
}))
759+
const sid = 'webcrypto-aes-cbc'
760+
const original = makeData()
761+
await storePromise.set(sid, original)
762+
const session = await storePromise.get(sid)
763+
t.deepEqual(session, JSON.parse(JSON.stringify(original)))
764+
})
765+
766+
test.serial(
767+
'cryptoAdapter works with default stringify (string payload)',
768+
async (t) => {
769+
const adapter: CryptoAdapter = {
770+
encrypt: async (payload) => `enc:${payload}`,
771+
decrypt: async (payload) => payload.replace(/^enc:/, ''),
772+
}
773+
;({ store, storePromise } = createStoreHelper({
774+
cryptoAdapter: adapter,
775+
collectionName: 'crypto-default-stringify',
776+
}))
777+
const sid = 'crypto-default-stringify'
778+
const original = makeData()
779+
await storePromise.set(sid, original)
780+
const session = await storePromise.get(sid)
781+
t.deepEqual(session, JSON.parse(JSON.stringify(original)))
782+
}
783+
)
784+
785+
test.serial(
786+
'cryptoAdapter works with stringify=false (raw object path)',
787+
async (t) => {
788+
const adapter: CryptoAdapter = {
789+
encrypt: async (payload) => `enc:${payload}`,
790+
decrypt: async (payload) => payload.replace(/^enc:/, ''),
791+
}
792+
;({ store, storePromise } = createStoreHelper({
793+
cryptoAdapter: adapter,
794+
stringify: false,
795+
collectionName: 'crypto-stringify-false',
796+
}))
797+
const sid = 'crypto-stringify-false'
798+
const original = makeDataNoCookie()
799+
// @ts-ignore
800+
await storePromise.set(sid, original)
801+
const session = await storePromise.get(sid)
802+
t.deepEqual(session, original)
803+
}
804+
)
805+
806+
test.serial(
807+
'cryptoAdapter works with custom serialize/unserialize functions',
808+
async (t) => {
809+
const adapter: CryptoAdapter = {
810+
encrypt: async (payload) => `enc:${payload}`,
811+
decrypt: async (payload) => payload.replace(/^enc:/, ''),
812+
}
813+
const serialize = (session: SessionData) => ({
814+
...session,
815+
marker: true,
816+
})
817+
const unserialize = (payload: unknown) => {
818+
const { marker, ...rest } = payload as Record<string, unknown>
819+
return rest as SessionData
820+
}
821+
822+
;({ store, storePromise } = createStoreHelper({
823+
cryptoAdapter: adapter,
824+
serialize,
825+
unserialize,
826+
collectionName: 'crypto-custom-serialize',
827+
}))
828+
const sid = 'crypto-custom-serialize'
829+
const original = makeData()
830+
await storePromise.set(sid, original)
831+
const session = await storePromise.get(sid)
832+
t.deepEqual(session, JSON.parse(JSON.stringify(original)))
833+
}
834+
)
835+
621836
test.serial('basic operation flow with crypto', async (t) => {
622837
;({ store, storePromise } = createStoreHelper({
623838
crypto: { secret: 'secret' },

0 commit comments

Comments
 (0)