Encrypt/decrypt app secret variables (#17394)
Closes https://github.com/twentyhq/core-team-issues/issues/1724 From PR https://github.com/twentyhq/twenty/pull/15283, followed same implementation - Introduce EnvironmentModule to provide type safety for env-only variables - Encrypt secret variables - When querying app secret variable, display up to first 5 characters (depending on secret length) then `******`
This commit is contained in:
+9
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
|
||||
|
||||
@Module({
|
||||
providers: [SecretEncryptionService],
|
||||
exports: [SecretEncryptionService],
|
||||
})
|
||||
export class SecretEncryptionModule {}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
|
||||
|
||||
import { SecretEncryptionService } from './secret-encryption.service';
|
||||
|
||||
describe('SecretEncryptionService', () => {
|
||||
let service: SecretEncryptionService;
|
||||
|
||||
const mockAppSecret = 'mock-app-secret-for-testing-purposes-12345678';
|
||||
const testValue = 'my-secret-api-key-123';
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SecretEncryptionService,
|
||||
{
|
||||
provide: EnvironmentConfigDriver,
|
||||
useValue: {
|
||||
get: jest.fn().mockReturnValue(mockAppSecret),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SecretEncryptionService>(SecretEncryptionService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('encrypt and decrypt', () => {
|
||||
it('should encrypt and decrypt a value correctly', () => {
|
||||
const encrypted = service.encrypt(testValue);
|
||||
const decrypted = service.decrypt(encrypted);
|
||||
|
||||
expect(decrypted).toBe(testValue);
|
||||
expect(encrypted).not.toBe(testValue);
|
||||
});
|
||||
|
||||
it('should generate different encrypted values for the same input (due to random IV)', () => {
|
||||
const encrypted1 = service.encrypt(testValue);
|
||||
const encrypted2 = service.encrypt(testValue);
|
||||
|
||||
expect(encrypted1).not.toBe(encrypted2);
|
||||
|
||||
const decrypted1 = service.decrypt(encrypted1);
|
||||
const decrypted2 = service.decrypt(encrypted2);
|
||||
|
||||
expect(decrypted1).toBe(testValue);
|
||||
expect(decrypted2).toBe(testValue);
|
||||
});
|
||||
|
||||
it('should handle special characters in values', () => {
|
||||
const specialValue = 'api-key_WITH@special#chars!123$%^&*()';
|
||||
|
||||
const encrypted = service.encrypt(specialValue);
|
||||
const decrypted = service.decrypt(encrypted);
|
||||
|
||||
expect(decrypted).toBe(specialValue);
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
const emptyValue = '';
|
||||
|
||||
const encrypted = service.encrypt(emptyValue);
|
||||
const decrypted = service.decrypt(encrypted);
|
||||
|
||||
expect(decrypted).toBe(emptyValue);
|
||||
});
|
||||
|
||||
it('should handle long values', () => {
|
||||
const longValue = 'a'.repeat(1000);
|
||||
|
||||
const encrypted = service.encrypt(longValue);
|
||||
const decrypted = service.decrypt(encrypted);
|
||||
|
||||
expect(decrypted).toBe(longValue);
|
||||
});
|
||||
|
||||
it('should handle unicode characters', () => {
|
||||
const unicodeValue = 'secret-with-émojis-🔐-and-中文';
|
||||
|
||||
const encrypted = service.encrypt(unicodeValue);
|
||||
const decrypted = service.decrypt(encrypted);
|
||||
|
||||
expect(decrypted).toBe(unicodeValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encrypt edge cases', () => {
|
||||
it('should return undefined values as-is', () => {
|
||||
const result = service.encrypt(undefined as unknown as string);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return null values as-is', () => {
|
||||
const result = service.encrypt(null as unknown as string);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decrypt edge cases', () => {
|
||||
it('should return undefined values as-is', () => {
|
||||
const result = service.decrypt(undefined as unknown as string);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return null values as-is', () => {
|
||||
const result = service.decrypt(null as unknown as string);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decryptAndMask', () => {
|
||||
const mask = '********';
|
||||
|
||||
it('should return undefined values as-is', () => {
|
||||
const result = service.decryptAndMask({
|
||||
value: undefined as unknown as string,
|
||||
mask,
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return null values as-is', () => {
|
||||
const result = service.decryptAndMask({
|
||||
value: null as unknown as string,
|
||||
mask,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return only mask for short secrets (length <= 10)', () => {
|
||||
const shortSecret = 'value';
|
||||
const encrypted = service.encrypt(shortSecret);
|
||||
|
||||
const result = service.decryptAndMask({ value: encrypted, mask });
|
||||
|
||||
// "value" has 5 chars, floor(5/10) = 0, min(5, 0) = 0, slice(0,0) = ""
|
||||
expect(result).toBe(mask);
|
||||
});
|
||||
|
||||
it('should return partial value + mask for medium secrets (10 < length <= 50)', () => {
|
||||
const mediumSecret = 'sk-abc123def456';
|
||||
const encrypted = service.encrypt(mediumSecret);
|
||||
|
||||
const result = service.decryptAndMask({ value: encrypted, mask });
|
||||
|
||||
// "sk-abc123def456" has 15 chars, floor(15/10) = 1, min(5, 1) = 1
|
||||
expect(result).toBe(`s${mask}`);
|
||||
});
|
||||
|
||||
it('should return partial value + mask for longer secrets', () => {
|
||||
const longerSecret = 'sk-abcdefghij1234567890';
|
||||
const encrypted = service.encrypt(longerSecret);
|
||||
|
||||
const result = service.decryptAndMask({ value: encrypted, mask });
|
||||
|
||||
// "sk-abcdefghij1234567890" has 23 chars, floor(23/10) = 2, min(5, 2) = 2
|
||||
expect(result).toBe(`sk${mask}`);
|
||||
});
|
||||
|
||||
it('should cap visible chars at 5 for very long secrets', () => {
|
||||
const veryLongSecret = 'a'.repeat(100);
|
||||
const encrypted = service.encrypt(veryLongSecret);
|
||||
|
||||
const result = service.decryptAndMask({ value: encrypted, mask });
|
||||
|
||||
// 100 chars, floor(100/10) = 10, min(5, 10) = 5
|
||||
expect(result).toBe(`aaaaa${mask}`);
|
||||
});
|
||||
|
||||
it('should handle empty secret values', () => {
|
||||
const emptySecret = '';
|
||||
const encrypted = service.encrypt(emptySecret);
|
||||
|
||||
const result = service.decryptAndMask({ value: encrypted, mask });
|
||||
|
||||
// "" has 0 chars, floor(0/10) = 0, min(5, 0) = 0
|
||||
expect(result).toBe(mask);
|
||||
});
|
||||
});
|
||||
});
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
decryptText,
|
||||
encryptText,
|
||||
} from 'src/engine/core-modules/auth/auth.util';
|
||||
import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
|
||||
|
||||
@Injectable()
|
||||
export class SecretEncryptionService {
|
||||
constructor(
|
||||
private readonly environmentConfigDriver: EnvironmentConfigDriver,
|
||||
) {}
|
||||
|
||||
private getAppSecret(): string {
|
||||
return this.environmentConfigDriver.get('APP_SECRET');
|
||||
}
|
||||
|
||||
public encrypt(value: string): string {
|
||||
if (!isDefined(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const appSecret = this.getAppSecret();
|
||||
|
||||
return encryptText(value, appSecret);
|
||||
}
|
||||
|
||||
public decrypt(value: string): string {
|
||||
if (!isDefined(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const appSecret = this.getAppSecret();
|
||||
|
||||
return decryptText(value, appSecret);
|
||||
}
|
||||
|
||||
public decryptAndMask({
|
||||
value,
|
||||
mask,
|
||||
}: {
|
||||
value: string;
|
||||
mask: string;
|
||||
}): string {
|
||||
if (!isDefined(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const decryptedValue = this.decrypt(value);
|
||||
|
||||
const visibleCharsCount = Math.min(
|
||||
5,
|
||||
Math.floor(decryptedValue.length / 10),
|
||||
);
|
||||
|
||||
return `${decryptedValue.slice(0, visibleCharsCount)}${mask}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user