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
1016import { 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 ` ) ;
0 commit comments