Skip to content

Commit 6d90173

Browse files
authored
Merge pull request #125 from silinternational/feature/key-rotate
Release 2.5.0 -- rotate an API key
2 parents f077116 + ce3fc8b commit 6d90173

File tree

8 files changed

+390
-18
lines changed

8 files changed

+390
-18
lines changed

apikey.go

Lines changed: 141 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ import (
2020
// ApiKeyTablePK is the primary key in the ApiKey DynamoDB table
2121
const 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
2432
type 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
378499
func 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

Comments
 (0)