Skip to content

Commit b1971b6

Browse files
authored
feat(secure): level envelope encryption with key rotation (#546)
1 parent 0fa3052 commit b1971b6

20 files changed

Lines changed: 700 additions & 158 deletions

core-api/example.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ REDIS_URL=redis://:password@localhost:6379/0
1313

1414
GEO_IP_URL=localhost:4360
1515

16+
# Envelope encryption: comma-separated KEKs. Last key = active (encrypt + decrypt), older = decrypt-only
17+
ENCRYPTION_KEYS=your-encryption-key-change-in-production
18+
1619
# RustFS Configuration
1720
RUSTFS_ENDPOINT=http://localhost:9000
1821
RUSTFS_ACCESS_KEY=rustfsadmin

core-api/src/common/utils/encryption.util.ts

Lines changed: 84 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,32 +9,97 @@ import { DEFAULT_ENCRYPTION_KEY } from '../constants/app.constants';
99
const ALGORITHM = 'aes-256-cbc';
1010
const IV_LENGTH = 16;
1111

12-
function getEncryptionKey(): Buffer {
13-
let key = process.env.ENCRYPTION_KEY;
14-
if (!key) {
15-
key = DEFAULT_ENCRYPTION_KEY;
16-
}
17-
return createHash('sha256').update(key).digest();
12+
/**
13+
* Parse ENCRYPTION_KEYS env var into SHA-256 hashed key buffers.
14+
* Format: "key-v1,key-v2,key-v3" — comma-separated.
15+
* Falls back to DEFAULT_ENCRYPTION_KEY if env var is not set.
16+
*
17+
* Last key = active (used for encrypt).
18+
* All keys = valid for decrypt.
19+
*
20+
* Key rotation: append a new key → new data encrypted with new key,
21+
* old data still decryptable via index prefix lookup.
22+
*/
23+
export function parseEncryptionKeys(): Buffer[] {
24+
const raw = process.env.ENCRYPTION_KEYS;
25+
const keys = raw
26+
? raw.split(',').map((k) => k.trim()).filter(Boolean)
27+
: [DEFAULT_ENCRYPTION_KEY];
28+
return keys.map((k) => createHash('sha256').update(k).digest());
29+
}
30+
31+
/**
32+
* Returns the active (latest) encryption key for encrypting new data.
33+
* Always the last key in ENCRYPTION_KEYS list.
34+
*/
35+
export function getActiveEncryptionKey(): Buffer {
36+
const keys = parseEncryptionKeys();
37+
return keys[keys.length - 1];
1838
}
1939

40+
/**
41+
* Encrypt plaintext with the active KEK.
42+
* Output format: "{activeIndex}:ivHex:encryptedHex"
43+
* Index is the position in ENCRYPTION_KEYS (0-based), enabling O(1) decrypt.
44+
*
45+
* @deprecated For workspace-scoped encryption, use encryptWithDEK instead.
46+
*/
2047
export function encrypt(text: string): string {
48+
const keys = parseEncryptionKeys();
49+
const activeIndex = keys.length - 1;
50+
const key = keys[activeIndex];
2151
const iv = randomBytes(IV_LENGTH);
22-
const cipher = createCipheriv(ALGORITHM, getEncryptionKey(), iv);
23-
const encrypted = Buffer.concat([
24-
cipher.update(text, 'utf8'),
25-
cipher.final(),
26-
]);
27-
return iv.toString('hex') + ':' + encrypted.toString('hex');
52+
const cipher = createCipheriv(ALGORITHM, key, iv);
53+
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
54+
return `${activeIndex}:${iv.toString('hex')}:${encrypted.toString('hex')}`;
2855
}
2956

57+
/**
58+
* Decrypt ciphertext. Supports both new and old formats:
59+
* - New: "{index}:ivHex:encryptedHex" → O(1) key lookup by index
60+
* - Old: "ivHex:encryptedHex" (no prefix) → tries all keys (O(n), backward compat)
61+
*
62+
* @deprecated For workspace-scoped decryption, use decryptWithDEK instead.
63+
*/
3064
export function decrypt(encryptedText: string): string {
31-
const [ivHex, encryptedHex] = encryptedText.split(':');
65+
const parts = encryptedText.split(':');
66+
67+
// Detect format: if first part is numeric and has 3+ segments, it's the key index
68+
const firstPartIsIndex = parts.length >= 3 && /^\d+$/.test(parts[0]);
69+
70+
let ivHex: string, encryptedHex: string;
71+
let keys: Buffer[];
72+
73+
if (firstPartIsIndex) {
74+
const keyIndex = parseInt(parts[0], 10);
75+
ivHex = parts[1];
76+
encryptedHex = parts.slice(2).join(':');
77+
const allKeys = parseEncryptionKeys();
78+
if (keyIndex < 0 || keyIndex >= allKeys.length) {
79+
throw new Error(`Invalid key index: ${keyIndex}`);
80+
}
81+
keys = allKeys; // O(1) on match, O(n) fallback — try all keys for backward compat
82+
} else {
83+
// Old format: no prefix, try all keys
84+
ivHex = parts[0];
85+
encryptedHex = parts.slice(1).join(':');
86+
keys = parseEncryptionKeys(); // all keys
87+
}
88+
3289
const iv = Buffer.from(ivHex, 'hex');
3390
const encrypted = Buffer.from(encryptedHex, 'hex');
34-
const decipher = createDecipheriv(ALGORITHM, getEncryptionKey(), iv);
35-
const decrypted = Buffer.concat([
36-
decipher.update(encrypted),
37-
decipher.final(),
38-
]);
39-
return decrypted.toString('utf8');
91+
92+
const errors: Error[] = [];
93+
for (const key of keys) {
94+
try {
95+
const decipher = createDecipheriv(ALGORITHM, key, iv);
96+
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
97+
} catch (e) {
98+
errors.push(e as Error);
99+
}
100+
}
101+
102+
throw new Error(
103+
`Decryption failed with ${keys.length} key(s): ${errors.map((e) => e.message).join('; ')}`,
104+
);
40105
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { createHash, randomBytes, createCipheriv } from 'crypto';
2+
import {
3+
generateDEK,
4+
wrapDEK,
5+
unwrapDEK,
6+
encryptWithDEK,
7+
decryptWithDEK,
8+
} from './workspace-encryption.util';
9+
10+
// Helper: manually encrypt with DEFAULT key in OLD format (no prefix)
11+
function encryptLegacy(text: string): string {
12+
const key = createHash('sha256')
13+
.update('OASM_DEFAULT_ENCRYPTION_KEY')
14+
.digest();
15+
const iv = randomBytes(16);
16+
const cipher = createCipheriv('aes-256-cbc', key, iv);
17+
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
18+
return iv.toString('hex') + ':' + encrypted.toString('hex');
19+
}
20+
21+
describe('generateDEK', () => {
22+
it('should generate a 32-byte buffer', () => {
23+
const dek = generateDEK();
24+
expect(Buffer.isBuffer(dek)).toBe(true);
25+
expect(dek.length).toBe(32);
26+
});
27+
28+
it('should generate unique keys each call', () => {
29+
const dek1 = generateDEK();
30+
const dek2 = generateDEK();
31+
expect(dek1.equals(dek2)).toBe(false);
32+
});
33+
});
34+
35+
describe('wrapDEK / unwrapDEK', () => {
36+
it('should roundtrip: wrap then unwrap produces original DEK', () => {
37+
const dek = generateDEK();
38+
const wrapped = wrapDEK(dek);
39+
expect(typeof wrapped).toBe('string');
40+
expect(wrapped).toContain(':');
41+
42+
// Should have numeric index prefix
43+
const firstColon = wrapped.indexOf(':');
44+
const indexStr = wrapped.substring(0, firstColon);
45+
expect(/^\d+$/.test(indexStr)).toBe(true);
46+
47+
const unwrapped = unwrapDEK(wrapped);
48+
expect(Buffer.isBuffer(unwrapped)).toBe(true);
49+
expect(dek.equals(unwrapped)).toBe(true);
50+
});
51+
52+
it('should fail unwrap on corrupted data', () => {
53+
expect(() => unwrapDEK('0:bad:data')).toThrow();
54+
});
55+
56+
it('should fail unwrap on invalid key index', () => {
57+
expect(() => unwrapDEK('99:abc:def')).toThrow(/key index/);
58+
});
59+
});
60+
61+
describe('encryptWithDEK / decryptWithDEK', () => {
62+
const sampleText = 'Hello, World! Đây là dữ liệu cần mã hóa.';
63+
64+
it('should roundtrip with valid DEK', () => {
65+
const dek = generateDEK();
66+
const encrypted = encryptWithDEK(sampleText, dek);
67+
expect(encrypted).not.toBe(sampleText);
68+
expect(encrypted).toContain(':');
69+
70+
const decrypted = decryptWithDEK(encrypted, dek);
71+
expect(decrypted).toBe(sampleText);
72+
});
73+
74+
it('should handle empty string', () => {
75+
const dek = generateDEK();
76+
const encrypted = encryptWithDEK('', dek);
77+
expect(decryptWithDEK(encrypted, dek)).toBe('');
78+
});
79+
80+
it('should handle UTF-8 characters', () => {
81+
const dek = generateDEK();
82+
const text = 'こんにちは世界 🌍';
83+
const encrypted = encryptWithDEK(text, dek);
84+
expect(decryptWithDEK(encrypted, dek)).toBe(text);
85+
});
86+
87+
it('should fail decrypt with wrong DEK', () => {
88+
const dek1 = generateDEK();
89+
const dek2 = generateDEK();
90+
const encrypted = encryptWithDEK(sampleText, dek1);
91+
expect(() => decryptWithDEK(encrypted, dek2)).toThrow();
92+
});
93+
});
94+
95+
describe('backward compatibility — decryptWithDEK with legacy KEK data', () => {
96+
const sampleText = 'Hello, World! Đây là dữ liệu cần mã hóa.';
97+
98+
it('should decrypt legacy KEK data (no prefix) when DEK is null', () => {
99+
const legacyEncrypted = encryptLegacy(sampleText);
100+
// Legacy format: no numeric prefix
101+
expect(legacyEncrypted.split(':')[0]).not.toMatch(/^\d+$/);
102+
103+
const result = decryptWithDEK(legacyEncrypted, null);
104+
expect(result).toBe(sampleText);
105+
});
106+
107+
it('should fall back to KEK when DEK decryption fails', () => {
108+
const legacyEncrypted = encryptLegacy(sampleText);
109+
const dek = generateDEK();
110+
const result = decryptWithDEK(legacyEncrypted, dek);
111+
expect(result).toBe(sampleText);
112+
});
113+
});
114+
115+
describe('end-to-end envelope encryption flow', () => {
116+
const sampleText = 'Full lifecycle test.';
117+
118+
it('should simulate the full workspace encryption lifecycle', () => {
119+
// 1. Generate DEK
120+
const dek = generateDEK();
121+
// 2. Wrap DEK with KEK (what gets stored in workspace.dek)
122+
const wrappedDEK = wrapDEK(dek);
123+
// 3. Store wrappedDEK in DB (simulated)
124+
const storedWrappedDEK = wrappedDEK;
125+
// 4. Later: unwrap DEK from stored value
126+
const unwrappedDEK = unwrapDEK(storedWrappedDEK);
127+
expect(dek.equals(unwrappedDEK)).toBe(true);
128+
// 5. Encrypt data with DEK (no prefix — DEK is the key)
129+
const encrypted = encryptWithDEK(sampleText, unwrappedDEK);
130+
// 6. Decrypt data with DEK
131+
const decrypted = decryptWithDEK(encrypted, unwrappedDEK);
132+
expect(decrypted).toBe(sampleText);
133+
// 7. Different workspace DEK cannot decrypt
134+
const otherDEK = generateDEK();
135+
expect(() => decryptWithDEK(encrypted, otherDEK)).toThrow();
136+
});
137+
});
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import {
2+
createCipheriv,
3+
createDecipheriv,
4+
randomBytes,
5+
} from 'crypto';
6+
import {
7+
decrypt,
8+
getActiveEncryptionKey,
9+
parseEncryptionKeys,
10+
} from './encryption.util';
11+
12+
const ALGORITHM = 'aes-256-cbc';
13+
const IV_LENGTH = 16;
14+
const DEK_LENGTH = 32; // 256 bits for AES-256
15+
16+
/**
17+
* Generate a random 32-byte Data Encryption Key (DEK).
18+
* Each workspace gets its own DEK.
19+
*/
20+
export function generateDEK(): Buffer {
21+
return randomBytes(DEK_LENGTH);
22+
}
23+
24+
/**
25+
* Wrap (encrypt) a DEK with the system KEK.
26+
* Uses the active key (last in ENCRYPTION_KEYS).
27+
* Returns "{activeIndex}:ivHex:encryptedHex" for storage in workspaces.dek.
28+
*
29+
* The index prefix enables O(1) key lookup during unwrap — critical for
30+
* key rotation: DEK wrapped with key-0 remains decryptable after adding key-1.
31+
*/
32+
export function wrapDEK(dek: Buffer): string {
33+
const iv = randomBytes(IV_LENGTH);
34+
const cipher = createCipheriv(ALGORITHM, getActiveEncryptionKey(), iv);
35+
const encrypted = Buffer.concat([cipher.update(dek), cipher.final()]);
36+
const allKeys = parseEncryptionKeys();
37+
const activeIndex = allKeys.length - 1;
38+
return `${activeIndex}:${iv.toString('hex')}:${encrypted.toString('hex')}`;
39+
}
40+
41+
/**
42+
* Unwrap (decrypt) a wrapped DEK using the system KEK.
43+
* Uses key index prefix for O(1) lookup.
44+
* Falls back to trial decryption for legacy format (no prefix).
45+
*/
46+
export function unwrapDEK(wrappedDEK: string): Buffer {
47+
const parts = wrappedDEK.split(':');
48+
const firstPartIsIndex = parts.length >= 3 && /^\d+$/.test(parts[0]);
49+
50+
let ivHex: string, encryptedHex: string, keys: Buffer[];
51+
52+
if (firstPartIsIndex) {
53+
const keyIndex = parseInt(parts[0], 10);
54+
const allKeys = parseEncryptionKeys();
55+
if (keyIndex < 0 || keyIndex >= allKeys.length) {
56+
throw new Error(
57+
`Invalid key index in wrapped DEK: ${keyIndex} (have ${allKeys.length} keys)`,
58+
);
59+
}
60+
keys = allKeys; // try all keys for backward compat
61+
ivHex = parts[1];
62+
encryptedHex = parts.slice(2).join(':');
63+
} else {
64+
// Legacy format — no prefix, try all keys
65+
keys = parseEncryptionKeys();
66+
ivHex = parts[0];
67+
encryptedHex = parts.slice(1).join(':');
68+
}
69+
70+
const iv = Buffer.from(ivHex, 'hex');
71+
const encrypted = Buffer.from(encryptedHex, 'hex');
72+
73+
for (const key of keys) {
74+
try {
75+
const decipher = createDecipheriv(ALGORITHM, key, iv);
76+
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
77+
} catch {
78+
// Try next key
79+
}
80+
}
81+
throw new Error('Failed to unwrap DEK — no valid KEK found');
82+
}
83+
84+
/**
85+
* Encrypt data using a workspace-specific DEK.
86+
* Format: "ivHex:encryptedHex" (no key index — DEK is the key itself).
87+
*/
88+
export function encryptWithDEK(text: string, dek: Buffer): string {
89+
const iv = randomBytes(IV_LENGTH);
90+
const cipher = createCipheriv(ALGORITHM, dek, iv);
91+
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
92+
return iv.toString('hex') + ':' + encrypted.toString('hex');
93+
}
94+
95+
/**
96+
* Decrypt data. Fallback chain: DEK → KEK (index prefix) → KEK trial.
97+
*
98+
* @param encryptedText - "ivHex:encryptedHex" (DEK) or "{index}:ivHex:encryptedHex" (KEK)
99+
* @param dek - Cleartext DEK, or null for pre-envelope-encryption workspaces
100+
*/
101+
export function decryptWithDEK(encryptedText: string, dek: Buffer | null): string {
102+
const parts = encryptedText.split(':');
103+
const firstPartIsIndex = parts.length >= 3 && /^\d+$/.test(parts[0]);
104+
const ivHex = firstPartIsIndex ? parts[1] : parts[0];
105+
const encryptedHex = firstPartIsIndex
106+
? parts.slice(2).join(':')
107+
: parts.slice(1).join(':');
108+
109+
if (!ivHex || !encryptedHex) {
110+
throw new Error('Invalid encrypted data format');
111+
}
112+
const iv = Buffer.from(ivHex, 'hex');
113+
const encrypted = Buffer.from(encryptedHex, 'hex');
114+
115+
// Layer 1: DEK (only for non-prefixed data — DEK-encrypted data has no prefix)
116+
if (dek !== null && !firstPartIsIndex) {
117+
try {
118+
const decipher = createDecipheriv(ALGORITHM, dek, iv);
119+
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
120+
} catch {
121+
// DEK failed, fall through to KEK
122+
}
123+
}
124+
125+
// Layer 2: KEK — leverage shared decrypt() which handles both formats
126+
return decrypt(encryptedText);
127+
}

0 commit comments

Comments
 (0)