@@ -13,6 +13,10 @@ import {
1313import { Workspace } from '@/modules/workspaces/entities/workspace.entity' ;
1414import { RedisLockService } from '@/services/redis/distributed-lock.service' ;
1515
16+ // ponytail: encryptCache / decryptCache removed — caching ciphertext breaks
17+ // AES-CBC randomized IV; caching plaintext leaks memory. Add back only if
18+ // profiling shows getDEK is a hot-path bottleneck, and only for dekCache.
19+
1620/**
1721 * Centralized service for workspace-level Data Encryption Key (DEK) resolution.
1822 *
@@ -26,14 +30,14 @@ import { RedisLockService } from '@/services/redis/distributed-lock.service';
2630export class WorkspaceEncryptionService implements OnModuleInit {
2731 private readonly logger = new Logger ( WorkspaceEncryptionService . name ) ;
2832 private readonly dekCache = new Map < string , Buffer > ( ) ;
29- private readonly encryptCache = new Map < string , string > ( ) ;
30- private readonly decryptCache = new Map < string , string > ( ) ;
3133 private static readonly MAX_CACHE_SIZE = 1024 ;
3234
33- private evictIfNeeded < K , V > ( map : Map < K , V > ) : void {
34- if ( map . size >= WorkspaceEncryptionService . MAX_CACHE_SIZE ) {
35- const oldest = map . keys ( ) . next ( ) . value as K | undefined ;
36- if ( oldest !== undefined ) map . delete ( oldest ) ;
35+ private evictOldestIfNeeded ( ) : void {
36+ if ( this . dekCache . size >= WorkspaceEncryptionService . MAX_CACHE_SIZE ) {
37+ const oldest = this . dekCache . keys ( ) . next ( ) ;
38+ if ( ! oldest . done ) {
39+ this . dekCache . delete ( oldest . value ) ;
40+ }
3741 }
3842 }
3943
@@ -93,18 +97,31 @@ export class WorkspaceEncryptionService implements OnModuleInit {
9397
9498 /**
9599 * Generate a DEK and persist the wrapped form for a specific workspace.
100+ * Uses a conditional update (WHERE dek IS NULL) so concurrent backfill
101+ * instances or lock-expiry races cannot overwrite an existing DEK.
96102 */
97- async ensureDEK ( workspaceId : string ) : Promise < void > {
103+ async ensureDEK ( workspaceId : string ) : Promise < boolean > {
98104 const dek = generateDEK ( ) ;
99105 const wrappedDEK = wrapDEK ( dek ) ;
100- await this . workspaceRepo . update ( workspaceId , {
101- dek : wrappedDEK ,
102- dekAt : new Date ( ) ,
106+ const result = await this . workspaceRepo . update (
107+ { id : workspaceId , dek : IsNull ( ) } ,
108+ { dek : wrappedDEK , dekAt : new Date ( ) } ,
109+ ) ;
110+ if ( result . affected && result . affected > 0 ) {
111+ this . evictOldestIfNeeded ( ) ;
112+ this . dekCache . set ( workspaceId , dek ) ;
113+ return true ;
114+ }
115+ // DEK already exists — another instance won the race. Load it into cache.
116+ const existing = await this . workspaceRepo . findOne ( {
117+ where : { id : workspaceId } ,
118+ select : [ 'dek' ] ,
103119 } ) ;
104- this . dekCache . set ( workspaceId , dek ) ;
105- // Ciphertexts encrypted with old DEK won't decrypt with the new one
106- this . encryptCache . clear ( ) ;
107- this . decryptCache . clear ( ) ;
120+ if ( existing ?. dek ) {
121+ this . evictOldestIfNeeded ( ) ;
122+ this . dekCache . set ( workspaceId , unwrapDEK ( existing . dek ) ) ;
123+ }
124+ return false ;
108125 }
109126
110127 /**
@@ -131,7 +148,7 @@ export class WorkspaceEncryptionService implements OnModuleInit {
131148 if ( ! workspace ?. dek ) return null ;
132149
133150 const dek = unwrapDEK ( workspace . dek ) ;
134- this . evictIfNeeded ( this . dekCache ) ;
151+ this . evictOldestIfNeeded ( ) ;
135152 this . dekCache . set ( workspaceId , dek ) ;
136153 return dek ;
137154 }
@@ -142,15 +159,8 @@ export class WorkspaceEncryptionService implements OnModuleInit {
142159 * for legacy workspaces without a DEK.
143160 */
144161 async encrypt ( workspaceId : string , text : string ) : Promise < string > {
145- const cacheKey = `${ workspaceId } \0${ text } ` ;
146- const cached = this . encryptCache . get ( cacheKey ) ;
147- if ( cached ) return cached ;
148-
149162 const dek = await this . getDEK ( workspaceId ) ;
150- const result = dek ? encryptWithDEK ( text , dek ) : encrypt ( text ) ;
151- this . evictIfNeeded ( this . encryptCache ) ;
152- this . encryptCache . set ( cacheKey , result ) ;
153- return result ;
163+ return dek ? encryptWithDEK ( text , dek ) : encrypt ( text ) ;
154164 }
155165
156166 /**
@@ -159,13 +169,7 @@ export class WorkspaceEncryptionService implements OnModuleInit {
159169 * for legacy encrypted data and pre-envelope-encryption workspaces.
160170 */
161171 async decrypt ( workspaceId : string , encryptedText : string ) : Promise < string > {
162- const cached = this . decryptCache . get ( encryptedText ) ;
163- if ( cached ) return cached ;
164-
165172 const dek = await this . getDEK ( workspaceId ) ;
166- const result = decryptWithDEK ( encryptedText , dek ) ;
167- this . evictIfNeeded ( this . decryptCache ) ;
168- this . decryptCache . set ( encryptedText , result ) ;
169- return result ;
173+ return decryptWithDEK ( encryptedText , dek ) ;
170174 }
171175}
0 commit comments