Skip to content

Commit 72ab459

Browse files
authored
fix(telegram,encryption): restore DEK-based decryption (#548)
1 parent b1971b6 commit 72ab459

3 files changed

Lines changed: 60 additions & 58 deletions

File tree

core-api/src/modules/integrations/telegram-polling.service.ts

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -95,22 +95,21 @@ export class TelegramPollingService implements OnApplicationBootstrap {
9595
),
9696
);
9797
}
98-
9998
private async pollBot(integration: Integration): Promise<void> {
100-
const dek = await this.workspaceEncryption.getDEK(integration.workspaceId);
101-
const config = decryptSensitiveConfigFields(integration.config, dek);
102-
const botToken = config.botToken as string | undefined;
103-
if (!botToken) return;
104-
105-
// Use getUpdates with a long timeout — Telegram holds the connection
106-
// open for up to POLL_TIMEOUT seconds if no new messages.
107-
const offset = this.getOffset(botToken);
108-
const params = new URLSearchParams({
109-
timeout: String(POLL_TIMEOUT),
110-
offset: String(offset),
111-
});
112-
11399
try {
100+
const dek = await this.workspaceEncryption.getDEK(integration.workspaceId);
101+
const config = decryptSensitiveConfigFields(integration.config, dek);
102+
const botToken = config.botToken as string | undefined;
103+
if (!botToken) return;
104+
105+
// Use getUpdates with a long timeout — Telegram holds the connection
106+
// open for up to POLL_TIMEOUT seconds if no new messages.
107+
const offset = this.getOffset(botToken);
108+
const params = new URLSearchParams({
109+
timeout: String(POLL_TIMEOUT),
110+
offset: String(offset),
111+
});
112+
114113
const signal = this.abortController?.signal;
115114
if (signal?.aborted) return;
116115

@@ -120,9 +119,12 @@ export class TelegramPollingService implements OnApplicationBootstrap {
120119
);
121120

122121
if (!response.ok) {
123-
this.logger.warn(
124-
`Telegram polling HTTP ${response.status} for integration ${integration.id}`,
125-
);
122+
// 409 = another instance is already polling this bot (expected in dev)
123+
if (response.status !== 409) {
124+
this.logger.warn(
125+
`Telegram polling HTTP ${response.status} for integration ${integration.id}`,
126+
);
127+
}
126128
return;
127129
}
128130

@@ -137,10 +139,7 @@ export class TelegramPollingService implements OnApplicationBootstrap {
137139
if (signal?.aborted) return;
138140

139141
try {
140-
await this.telegramWebhookService.processUpdate(
141-
142-
update,
143-
);
142+
await this.telegramWebhookService.processUpdate(update);
144143
} catch (err) {
145144
this.logger.error('Error processing polling update', err);
146145
}

core-api/src/modules/notifications/processors/notifications.processor.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,15 +109,14 @@ export class NotificationsConsumer extends WorkerHost {
109109

110110
// Only push to integrations with this notification type enabled.
111111
const dek = await this.workspaceEncryption.getDEK(workspaceId);
112-
const enabledIntegrations = integrations
113-
.map((integration) => ({
114-
integration,
115-
config: decryptSensitiveConfigFields(integration.config, dek),
116-
}))
117-
.filter(({ config }) => config[type] !== false);
118112

119113
const results = await Promise.allSettled(
120-
enabledIntegrations.map(async ({ integration, config }) => {
114+
integrations.map(async (integration) => {
115+
const config = decryptSensitiveConfigFields(integration.config, dek);
116+
117+
// Skip integrations with this notification type disabled.
118+
if (config[type] === false) return;
119+
121120
const pushConfig: Record<string, unknown> = {
122121
...config,
123122
text: message,

core-api/src/services/workspace-encryption/workspace-encryption.service.ts

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import {
1313
import { Workspace } from '@/modules/workspaces/entities/workspace.entity';
1414
import { 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';
2630
export 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

Comments
 (0)