Skip to content

Commit 078fbee

Browse files
committed
feat: enhance security of secrets management system
- Increased PBKDF2 iterations to 600k/100k for stronger key derivation - Added memory zeroing for plaintext bytes in encryption/decryption operations - Implemented promise-based locking for key rotation to prevent concurrent modifications - Added detailed security documentation for deterministic salt design and SQL injection prevention - Updated UserSecretsStore to wait for any in-progress key rotation before executing operations - Added comprehensive
1 parent b26bbb6 commit 078fbee

File tree

5 files changed

+153
-73
lines changed

5 files changed

+153
-73
lines changed

docs/llm.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3078,10 +3078,10 @@ async performKeyRotation() {
30783078

30793079
**Location:** `/test/worker/services/secrets/`
30803080

3081-
Comprehensive test suite with 90 tests:
3082-
- Unit tests for KeyDerivation (17 tests)
3083-
- Unit tests for EncryptionService (18 tests)
3084-
- E2E tests for UserSecretsStore (55 tests)
3081+
Comprehensive test suite with **90+ tests** (3 test files):
3082+
- **KeyDerivation.test.ts** - 17 unit tests for key derivation
3083+
- **EncryptionService.test.ts** - 18 unit tests for encryption/decryption
3084+
- **UserSecretsStore.test.ts** - 55+ E2E tests for full DO lifecycle
30853085

30863086
**Run tests:**
30873087
```bash

worker/services/secrets/EncryptionService.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ export class EncryptionService {
2929
/**
3030
* Encrypt a secret value using XChaCha20-Poly1305
3131
* Returns encrypted data with nonce and salt for decryption
32+
*
33+
* Security Note: Memory Zeroing Limitation
34+
* - DEK (Uint8Array) is properly zeroed after use
35+
* - Plaintext string CANNOT be zeroed (JavaScript strings are immutable)
36+
* - This is a fundamental JavaScript limitation - no equivalent to C's memset(0)
37+
* - Plaintext remains in heap until garbage collection
38+
* - Cloudflare Workers have short lifetimes, reducing exposure window
39+
* - Key material (DEK, UMK) IS properly zeroed - most critical assets protected
40+
* - WASM could solve this but adds significant complexity
3241
*/
3342
async encrypt(value: string): Promise<EncryptedSecretData> {
3443
// Generate random salt and derive DEK
@@ -43,8 +52,10 @@ export class EncryptionService {
4352

4453
const keyPreview = this.createKeyPreview(value);
4554

46-
// Zero out DEK from memory (security best practice)
55+
// Zero out DEK and plaintext bytes from memory (security best practice)
56+
// Note: Original 'value' string cannot be zeroed (JS limitation documented above)
4757
dek.fill(0);
58+
plaintext.fill(0);
4859

4960
return {
5061
encryptedValue: ciphertext,
@@ -70,10 +81,14 @@ export class EncryptionService {
7081
const cipher = xchacha20poly1305(dek, encrypted.nonce);
7182
const plaintext = cipher.decrypt(encrypted.encryptedValue);
7283

73-
// Zero out DEK from memory (security best practice)
84+
// Decode to string before zeroing bytes
85+
const result = new TextDecoder().decode(plaintext);
86+
87+
// Zero out DEK and plaintext bytes from memory (security best practice)
7488
dek.fill(0);
89+
plaintext.fill(0);
7590

76-
return new TextDecoder().decode(plaintext);
91+
return result;
7792
} catch (error) {
7893
throw new Error('Failed to decrypt secret: invalid data or tampering detected');
7994
}

worker/services/secrets/KeyDerivation.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ export class KeyDerivation {
3535
* UMK = PBKDF2(MEK, salt=userId, 100k iterations)
3636
*
3737
* This creates a unique key per user while only storing one master key
38+
*
39+
* Security Note: Deterministic Salt Design
40+
* - userId is used as salt for DETERMINISTIC derivation (same userId → same UMK)
41+
* - This is REQUIRED for Durable Object architecture (DO must derive same key on each invocation)
42+
* - Random salt would require persistent storage, defeating hierarchical key design
43+
* - If MEK is compromised, attacker can decrypt everything regardless (MEK compromise = total failure)
44+
* - Context prefix provides domain separation: vibesdk:user:{userId}
3845
*/
3946
async deriveUserMasterKey(userId: string): Promise<Uint8Array> {
4047
if (!userId || userId.trim().length === 0) {
@@ -43,7 +50,7 @@ export class KeyDerivation {
4350

4451
const encoder = new TextEncoder();
4552

46-
// Use userId as salt with context prefix
53+
// Use userId as salt with context prefix for deterministic derivation
4754
const salt = encoder.encode(`${CRYPTO_CONSTANTS.UMK_CONTEXT_PREFIX}${userId}`);
4855

4956
// Import master key as key material

worker/services/secrets/UserSecretsStore.ts

Lines changed: 121 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
/**
2-
* UserSecretsStore - Rebuilt from scratch using proven MinimalSecretsStore pattern
2+
* UserSecretsStore - Durable Object for secure user API key storage
33
*
44
* Architecture:
55
* - One DO per user (userId as DO ID)
66
* - Hierarchical key derivation: MEK → UMK → DEK
7-
* - XChaCha20-Poly1305 encryption
7+
* - XChaCha20-Poly1305 AEAD encryption
8+
*
9+
* Key Rotation Locking:
10+
* - Promise-based lock prevents concurrent modifications during rotation
11+
* - All RPC methods call waitForRotation() first
12+
* - rotationInProgress promise cleared in finally block
13+
* - DO single-threading + promise await = complete mutual exclusion
814
*/
915

1016
import { DurableObject } from 'cloudflare:workers';
@@ -43,13 +49,17 @@ export class UserSecretsStore extends DurableObject<Env> {
4349
private userId: string;
4450
private keyDerivation!: KeyDerivation;
4551
private encryption!: EncryptionService;
52+
private rotationInProgress: Promise<void> | null = null;
4653

4754
constructor(ctx: DurableObjectState, env: Env) {
4855
super(ctx, env);
4956

5057
this.userId = ctx.id.name ?? ctx.id.toString();
5158

5259
// Use blockConcurrencyWhile for initialization
60+
// Security Note: This guarantees no RPC methods execute until initialize() completes.
61+
// Cloudflare Workers DO platform blocks ALL incoming requests during this call.
62+
// No additional await checks needed in methods - this is the official pattern.
5363
ctx.blockConcurrencyWhile(async () => {
5464
await this.initialize();
5565
});
@@ -150,7 +160,19 @@ export class UserSecretsStore extends DurableObject<Env> {
150160
return !!(this.encryption && this.keyDerivation);
151161
}
152162

163+
/**
164+
* Wait for any in-progress key rotation to complete
165+
* All RPC methods should call this first to ensure data consistency
166+
*/
167+
private async waitForRotation(): Promise<void> {
168+
if (this.rotationInProgress) {
169+
await this.rotationInProgress;
170+
}
171+
}
172+
153173
async listSecrets(): Promise<SecretMetadata[]> {
174+
await this.waitForRotation();
175+
154176
const result = this.ctx.storage.sql.exec(`
155177
SELECT
156178
id, name, secret_type, provider, key_preview, metadata,
@@ -166,6 +188,8 @@ export class UserSecretsStore extends DurableObject<Env> {
166188
}
167189

168190
async storeSecret(request: StoreSecretRequest): Promise<SecretMetadata | null> {
191+
await this.waitForRotation();
192+
169193
// Validate request - returns null on validation failure
170194
const validationError = this.validateSecretRequest(request);
171195
if (validationError) {
@@ -219,6 +243,8 @@ export class UserSecretsStore extends DurableObject<Env> {
219243
}
220244

221245
async getSecretValue(secretId: string): Promise<SecretWithValue | null> {
246+
await this.waitForRotation();
247+
222248
const result = this.ctx.storage.sql.exec(`
223249
SELECT * FROM secrets WHERE id = ? AND is_active = 1
224250
`, secretId);
@@ -259,6 +285,8 @@ export class UserSecretsStore extends DurableObject<Env> {
259285
}
260286

261287
async updateSecret(secretId: string, request: UpdateSecretRequest): Promise<SecretMetadata | null> {
288+
await this.waitForRotation();
289+
262290
// Check exists
263291
const result = this.ctx.storage.sql.exec(`
264292
SELECT * FROM secrets WHERE id = ? AND is_active = 1
@@ -312,6 +340,12 @@ export class UserSecretsStore extends DurableObject<Env> {
312340
updateValues.push(now);
313341
updateValues.push(secretId);
314342

343+
// Security Note: SQL Injection Safety
344+
// This dynamic query construction is SAFE because:
345+
// 1. Field names ("name = ?", "metadata = ?") are hardcoded string literals
346+
// 2. User input goes into parameterized ? placeholders (updateValues array)
347+
// 3. Array.join() only combines our hardcoded field assignments
348+
// 4. This is the standard parameterized query pattern for dynamic updates
315349
this.ctx.storage.sql.exec(`
316350
UPDATE secrets SET ${updateFields.join(', ')} WHERE id = ?
317351
`, ...updateValues);
@@ -326,6 +360,8 @@ export class UserSecretsStore extends DurableObject<Env> {
326360
}
327361

328362
async deleteSecret(secretId: string): Promise<boolean> {
363+
await this.waitForRotation();
364+
329365
// Soft delete
330366
const result = this.ctx.storage.sql.exec(`
331367
UPDATE secrets SET is_active = 0, updated_at = ?
@@ -479,81 +515,103 @@ export class UserSecretsStore extends DurableObject<Env> {
479515

480516
/**
481517
* Re-encrypt all active secrets with new master key
482-
* Pre-computes all encrypted values, then applies SQL updates atomically
518+
*
519+
* Locking Strategy:
520+
* - Sets rotationInProgress promise at start
521+
* - All RPC methods await this promise via waitForRotation()
522+
* - Ensures no operations can modify secrets during rotation
523+
* - Promise cleared in finally block (even on errors)
524+
* - DO single-threading + promise-based locking = complete protection
483525
*/
484526
private async performKeyRotation(newKeyFingerprint: string): Promise<number> {
485-
const result = this.ctx.storage.sql.exec(`
486-
SELECT * FROM secrets WHERE is_active = 1
487-
`);
488-
489-
const secrets = result.toArray();
490-
const now = Date.now();
491-
492-
// Phase 1: Pre-compute all encrypted values (async operations)
493-
const reencryptedSecrets: Array<{
494-
id: string;
495-
encryptedValue: Uint8Array;
496-
nonce: Uint8Array;
497-
salt: Uint8Array;
498-
}> = [];
499-
500-
for (const row of secrets) {
501-
const secretRow = row as Record<string, SqlStorageValue>;
502-
527+
// Create and store the rotation promise for locking
528+
const rotationPromise = (async (): Promise<number> => {
503529
try {
504-
const encrypted = await this.getEncryptedData(secretRow);
505-
const plaintext = await this.encryption.decrypt(encrypted);
506-
const reencrypted = await this.encryption.encrypt(plaintext);
530+
const result = this.ctx.storage.sql.exec(`
531+
SELECT * FROM secrets WHERE is_active = 1
532+
`);
533+
534+
const secrets = result.toArray();
535+
const now = Date.now();
536+
537+
// Phase 1: Pre-compute all encrypted values (async crypto operations)
538+
const reencryptedSecrets: Array<{
539+
id: string;
540+
encryptedValue: Uint8Array;
541+
nonce: Uint8Array;
542+
salt: Uint8Array;
543+
}> = [];
544+
545+
for (const row of secrets) {
546+
const secretRow = row as Record<string, SqlStorageValue>;
547+
548+
try {
549+
const encrypted = await this.getEncryptedData(secretRow);
550+
const plaintext = await this.encryption.decrypt(encrypted);
551+
const reencrypted = await this.encryption.encrypt(plaintext);
552+
553+
reencryptedSecrets.push({
554+
id: String(secretRow.id),
555+
encryptedValue: reencrypted.encryptedValue,
556+
nonce: reencrypted.nonce,
557+
salt: reencrypted.salt
558+
});
559+
} catch (error) {
560+
console.error(`Failed to rotate secret ${String(secretRow.id)}:`, error);
561+
}
562+
}
507563

508-
reencryptedSecrets.push({
509-
id: String(secretRow.id),
510-
encryptedValue: reencrypted.encryptedValue,
511-
nonce: reencrypted.nonce,
512-
salt: reencrypted.salt
564+
// Phase 2: Apply all SQL updates atomically in transaction
565+
const rotatedCount = this.ctx.storage.transactionSync(() => {
566+
let count = 0;
567+
568+
for (const secret of reencryptedSecrets) {
569+
this.ctx.storage.sql.exec(`
570+
UPDATE secrets
571+
SET encrypted_value = ?, nonce = ?, salt = ?, key_fingerprint = ?, updated_at = ?
572+
WHERE id = ?
573+
`,
574+
secret.encryptedValue,
575+
secret.nonce,
576+
secret.salt,
577+
newKeyFingerprint,
578+
now,
579+
secret.id
580+
);
581+
count++;
582+
}
583+
584+
this.ctx.storage.sql.exec(`
585+
UPDATE key_rotation_metadata
586+
SET current_key_fingerprint = ?,
587+
last_rotation_at = ?,
588+
rotation_count = rotation_count + 1
589+
WHERE id = 1
590+
`, newKeyFingerprint, now);
591+
592+
return count;
513593
});
514-
} catch (error) {
515-
console.error(`Failed to rotate secret ${String(secretRow.id)}:`, error);
594+
595+
return rotatedCount;
596+
} finally {
597+
// Always clear the lock, even on errors
598+
this.rotationInProgress = null;
516599
}
517-
}
600+
})();
518601

519-
// Phase 2: Apply all SQL updates atomically in transaction
520-
const rotatedCount = this.ctx.storage.transactionSync(() => {
521-
let count = 0;
522-
523-
for (const secret of reencryptedSecrets) {
524-
this.ctx.storage.sql.exec(`
525-
UPDATE secrets
526-
SET encrypted_value = ?, nonce = ?, salt = ?, key_fingerprint = ?, updated_at = ?
527-
WHERE id = ?
528-
`,
529-
secret.encryptedValue,
530-
secret.nonce,
531-
secret.salt,
532-
newKeyFingerprint,
533-
now,
534-
secret.id
535-
);
536-
count++;
537-
}
538-
539-
this.ctx.storage.sql.exec(`
540-
UPDATE key_rotation_metadata
541-
SET current_key_fingerprint = ?,
542-
last_rotation_at = ?,
543-
rotation_count = rotation_count + 1
544-
WHERE id = 1
545-
`, newKeyFingerprint, now);
546-
547-
return count;
548-
});
602+
// Store the promise to block other operations
603+
this.rotationInProgress = rotationPromise.then(() => {});
549604

550-
return rotatedCount;
605+
// Await and return the result
606+
return await rotationPromise;
551607
}
552608

553609
/**
554610
* Get key rotation statistics
555611
*/
556612
async getKeyRotationInfo(): Promise<KeyRotationInfo> {
613+
await this.waitForRotation();
614+
557615
const metadataResult = this.ctx.storage.sql.exec(`
558616
SELECT * FROM key_rotation_metadata WHERE id = 1
559617
`);

worker/services/secrets/constants.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
*/
99
export const CRYPTO_CONSTANTS = {
1010
// Key derivation
11-
UMK_ITERATIONS: 100000, // User Master Key derivation (PBKDF2)
12-
DEK_ITERATIONS: 10000, // Data Encryption Key derivation (PBKDF2)
11+
UMK_ITERATIONS: 600000, // User Master Key derivation (PBKDF2-SHA256)
12+
DEK_ITERATIONS: 100000, // Data Encryption Key derivation (PBKDF2-SHA256)
1313
SALT_SIZE: 16, // bytes
1414
NONCE_SIZE: 24, // bytes (XChaCha20)
1515
KEY_SIZE: 32, // bytes (256 bits)

0 commit comments

Comments
 (0)