@@ -20,6 +20,14 @@ import (
2020// ApiKeyTablePK is the primary key in the ApiKey DynamoDB table
2121const ApiKeyTablePK = "value"
2222
23+ // key rotation request parameters
24+ const (
25+ paramNewKeyId = "newKeyId"
26+ paramNewKeySecret = "newKeySecret"
27+ paramOldKeyId = "oldKeyId"
28+ paramOldKeySecret = "oldKeySecret"
29+ )
30+
2331// ApiKey holds API key data from DynamoDB
2432type ApiKey struct {
2533 Key string `dynamodbav:"value" json:"value"`
@@ -205,23 +213,59 @@ func (k *ApiKey) Activate() error {
205213 return nil
206214}
207215
216+ // ReEncryptTOTPs loads each TOTP record that was encrypted using the old key, re-encrypts it using the new
217+ // key, and writes the updated data back to the database.
218+ func (k * ApiKey ) ReEncryptTOTPs (storage * Storage , oldKey ApiKey ) (complete , incomplete int , err error ) {
219+ var records []TOTP
220+ err = storage .ScanApiKey (envConfig .TotpTable , oldKey .Key , & records )
221+ if err != nil {
222+ err = fmt .Errorf ("failed to query %s table for key %s: %w" , envConfig .TotpTable , oldKey .Key , err )
223+ return
224+ }
225+
226+ incomplete = len (records )
227+ for _ , r := range records {
228+ err = k .ReEncryptLegacy (oldKey , & r .EncryptedTotpKey )
229+ if err != nil {
230+ err = fmt .Errorf ("failed to re-encrypt TOTP %v: %w" , r .UUID , err )
231+ return
232+ }
233+
234+ r .ApiKey = k .Key
235+
236+ err = storage .Store (envConfig .TotpTable , & r )
237+ if err != nil {
238+ err = fmt .Errorf ("failed to store TOTP %v: %w" , r .UUID , err )
239+ return
240+ }
241+ complete ++
242+ incomplete --
243+ }
244+ return
245+ }
246+
208247// ReEncryptWebAuthnUsers loads each WebAuthn record that was encrypted using the old key, re-encrypts it using the new
209248// key, and writes the updated data back to the database.
210- func (k * ApiKey ) ReEncryptWebAuthnUsers (storage * Storage , oldKey ApiKey ) error {
249+ func (k * ApiKey ) ReEncryptWebAuthnUsers (storage * Storage , oldKey ApiKey ) ( complete , incomplete int , err error ) {
211250 var users []WebauthnUser
212- err : = storage .ScanApiKey (envConfig .WebauthnTable , oldKey .Key , & users )
251+ err = storage .ScanApiKey (envConfig .WebauthnTable , oldKey .Key , & users )
213252 if err != nil {
214- return fmt .Errorf ("failed to query %s table for key %s: %w" , envConfig .WebauthnTable , oldKey .Key , err )
253+ err = fmt .Errorf ("failed to query %s table for key %s: %w" , envConfig .WebauthnTable , oldKey .Key , err )
254+ return
215255 }
216256
257+ incomplete = len (users )
217258 for _ , user := range users {
218259 user .ApiKey = oldKey
219260 err = k .ReEncryptWebAuthnUser (storage , user )
220261 if err != nil {
221- return err
262+ err = fmt .Errorf ("failed to re-encrypt Webauthn %v: %w" , user .ID , err )
263+ return
222264 }
265+ complete ++
266+ incomplete --
223267 }
224- return nil
268+ return
225269}
226270
227271// ReEncryptWebAuthnUser re-encrypts a WebAuthnUser using the new key, and writes the updated data back to the database.
@@ -283,15 +327,15 @@ func (k *ApiKey) ReEncryptLegacy(oldKey ApiKey, v *string) error {
283327
284328 plaintext , err := oldKey .DecryptLegacy (* v )
285329 if err != nil {
286- return err
330+ return fmt . Errorf ( "failed to decrypt data: %w" , err )
287331 }
288332
289333 newCiphertext , err := k .EncryptLegacy (plaintext )
290334 if err != nil {
291- return err
335+ return fmt . Errorf ( "failed to encrypt data: %w" , err )
292336 }
293337
294- * v = string ( newCiphertext )
338+ * v = newCiphertext
295339 return nil
296340}
297341
@@ -305,7 +349,7 @@ func (a *App) ActivateApiKey(w http.ResponseWriter, r *http.Request) {
305349
306350 err := json .NewDecoder (r .Body ).Decode (& requestBody )
307351 if err != nil {
308- jsonResponse (w , fmt .Errorf ("invalid request: %s " , err ), http .StatusBadRequest )
352+ jsonResponse (w , fmt .Errorf ("invalid request: %w " , err ), http .StatusBadRequest )
309353 return
310354 }
311355
@@ -322,19 +366,19 @@ func (a *App) ActivateApiKey(w http.ResponseWriter, r *http.Request) {
322366 newKey := ApiKey {Key : requestBody .ApiKeyValue , Store : a .db }
323367 err = newKey .Load ()
324368 if err != nil {
325- jsonResponse (w , fmt .Errorf ("key not found: %s " , err ), http .StatusNotFound )
369+ jsonResponse (w , fmt .Errorf ("key not found: %w " , err ), http .StatusNotFound )
326370 return
327371 }
328372
329373 err = newKey .Activate ()
330374 if err != nil {
331- jsonResponse (w , fmt .Errorf ("failed to activate key: %s " , err ), http .StatusBadRequest )
375+ jsonResponse (w , fmt .Errorf ("failed to activate key: %w " , err ), http .StatusBadRequest )
332376 return
333377 }
334378
335379 err = newKey .Save ()
336380 if err != nil {
337- jsonResponse (w , fmt .Errorf ("failed to save key: %s " , err ), http .StatusInternalServerError )
381+ jsonResponse (w , fmt .Errorf ("failed to save key: %w " , err ), http .StatusInternalServerError )
338382 return
339383 }
340384
@@ -349,7 +393,7 @@ func (a *App) CreateApiKey(w http.ResponseWriter, r *http.Request) {
349393
350394 err := json .NewDecoder (r .Body ).Decode (& requestBody )
351395 if err != nil {
352- jsonResponse (w , fmt .Errorf ("invalid request: %s " , err ), http .StatusBadRequest )
396+ jsonResponse (w , fmt .Errorf ("invalid request: %w " , err ), http .StatusBadRequest )
353397 return
354398 }
355399
@@ -360,20 +404,97 @@ func (a *App) CreateApiKey(w http.ResponseWriter, r *http.Request) {
360404
361405 key , err := NewApiKey (requestBody .Email )
362406 if err != nil {
363- jsonResponse (w , fmt .Errorf ("failed to create a random key: %s " , err ), http .StatusInternalServerError )
407+ jsonResponse (w , fmt .Errorf ("failed to create a random key: %w " , err ), http .StatusInternalServerError )
364408 return
365409 }
366410
367411 key .Store = a .db
368412 err = key .Save ()
369413 if err != nil {
370- jsonResponse (w , fmt .Errorf ("failed to save key: %s " , err ), http .StatusInternalServerError )
414+ jsonResponse (w , fmt .Errorf ("failed to save key: %w " , err ), http .StatusInternalServerError )
371415 return
372416 }
373417
374418 jsonResponse (w , nil , http .StatusNoContent )
375419}
376420
421+ // RotateApiKey facilitates the rotation of API Keys. All data in webauthn and totp tables that is encrypted by the old
422+ // key will be re-encrypted using the new key. If the process does not run to completion, this endpoint can be called
423+ // any number of times to continue the process. A status of 200 does not indicate that all keys were encrypted using the
424+ // new key. Check the response data to determine if the rotation process is complete.
425+ func (a * App ) RotateApiKey (w http.ResponseWriter , r * http.Request ) {
426+ requestBody , err := parseRotateKeyRequestBody (r .Body )
427+ if err != nil {
428+ jsonResponse (w , fmt .Errorf ("invalid request: %w" , err ), http .StatusBadRequest )
429+ return
430+ }
431+
432+ oldKey := ApiKey {Key : requestBody [paramOldKeyId ], Store : a .GetDB ()}
433+ err = oldKey .loadAndCheck (requestBody [paramOldKeySecret ])
434+ if err != nil {
435+ jsonResponse (w , fmt .Errorf ("old key is not valid: %w" , err ), http .StatusNotFound )
436+ return
437+ }
438+
439+ newKey := ApiKey {Key : requestBody [paramNewKeyId ], Store : a .GetDB ()}
440+ err = newKey .loadAndCheck (requestBody [paramNewKeySecret ])
441+ if err != nil {
442+ jsonResponse (w , fmt .Errorf ("new key is not valid: %w" , err ), http .StatusNotFound )
443+ return
444+ }
445+
446+ totpComplete , totpIncomplete , err := newKey .ReEncryptTOTPs (a .GetDB (), oldKey )
447+ if err != nil {
448+ jsonResponse (w , fmt .Errorf ("failed to re-encrypt TOTP data: %w" , err ), http .StatusInternalServerError )
449+ return
450+ }
451+
452+ webauthnComplete , webauthnIncomplete , err := newKey .ReEncryptWebAuthnUsers (a .GetDB (), oldKey )
453+ if err != nil {
454+ jsonResponse (w , fmt .Errorf ("failed to re-encrypt WebAuthn data: %w" , err ), http .StatusInternalServerError )
455+ return
456+ }
457+
458+ responseBody := map [string ]int {
459+ "totpComplete" : totpComplete ,
460+ "totpIncomplete" : totpIncomplete ,
461+ "webauthnComplete" : webauthnComplete ,
462+ "webauthnIncomplete" : webauthnIncomplete ,
463+ }
464+
465+ jsonResponse (w , responseBody , http .StatusOK )
466+ }
467+
468+ func parseRotateKeyRequestBody (body io.Reader ) (map [string ]string , error ) {
469+ var requestBody map [string ]string
470+ err := json .NewDecoder (body ).Decode (& requestBody )
471+ if err != nil {
472+ return nil , fmt .Errorf ("invalid request in RotateApiKey: %w" , err )
473+ }
474+
475+ fields := []string {paramNewKeyId , paramNewKeySecret , paramOldKeyId , paramOldKeySecret }
476+ for _ , field := range fields {
477+ if _ , ok := requestBody [field ]; ! ok {
478+ return nil , fmt .Errorf ("%s is required" , field )
479+ }
480+ }
481+ return requestBody , nil
482+ }
483+
484+ func (k * ApiKey ) loadAndCheck (secret string ) error {
485+ err := k .Load ()
486+ if err != nil {
487+ return fmt .Errorf ("failed to load key: %w" , err )
488+ }
489+
490+ err = k .IsCorrect (secret )
491+ if err != nil {
492+ return fmt .Errorf ("key is not valid: %w" , err )
493+ }
494+ k .Secret = secret
495+ return nil
496+ }
497+
377498// NewApiKey creates a new key with a random value
378499func NewApiKey (email string ) (ApiKey , error ) {
379500 random := make ([]byte , 20 )
@@ -405,3 +526,8 @@ func newCipherBlock(key string) (cipher.Block, error) {
405526 }
406527 return block , nil
407528}
529+
530+ // debugString is used by the debugger to show useful ApiKey information in watched variables
531+ func (k * ApiKey ) debugString () string {
532+ return fmt .Sprintf ("key: %s, secret: %s" , k .Key , k .Secret )
533+ }
0 commit comments