Skip to content

Commit ca8add5

Browse files
authored
Merge pull request #159 from sil-org/feature/improve-rotate
improve key rotatation
2 parents 5c66fbc + 03d3a85 commit ca8add5

File tree

4 files changed

+111
-74
lines changed

4 files changed

+111
-74
lines changed

apikey.go

Lines changed: 52 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package mfa
22

33
import (
4+
"context"
45
"crypto/aes"
56
"crypto/cipher"
67
"crypto/rand"
@@ -46,14 +47,26 @@ type ApiKey struct {
4647
Store *Storage `dynamodbav:"-" json:"-"`
4748
}
4849

50+
// CompletionStats stores the number of records processed in a batch operation
51+
type CompletionStats struct {
52+
Complete int `json:"complete"`
53+
Incomplete int `json:"incomplete"`
54+
}
55+
56+
// BatchStats stores the number of TOTP and Webauth records processed in a batch operation
57+
type BatchStats struct {
58+
TOTP CompletionStats `json:"totp"`
59+
Webauthn CompletionStats `json:"webauthn"`
60+
}
61+
4962
// Load refreshes an ApiKey from the database record
5063
func (k *ApiKey) Load() error {
5164
return k.Store.Load(envConfig.ApiKeyTable, ApiKeyTablePK, k.Key, k)
5265
}
5366

5467
// Save an ApiKey to the database
55-
func (k *ApiKey) Save() error {
56-
return k.Store.Store(envConfig.ApiKeyTable, k)
68+
func (k *ApiKey) Save(ctx context.Context) error {
69+
return k.Store.StoreCtx(ctx, envConfig.ApiKeyTable, k)
5770
}
5871

5972
// Hash generates a bcrypt hash from the Secret field and stores it in HashedSecret
@@ -221,61 +234,60 @@ func (k *ApiKey) Activate() error {
221234

222235
// ReEncryptTOTPs loads each TOTP record that was encrypted using the old key, re-encrypts it using the new
223236
// key, and writes the updated data back to the database.
224-
func (k *ApiKey) ReEncryptTOTPs(storage *Storage, oldKey ApiKey) (complete, incomplete int, err error) {
237+
func (k *ApiKey) ReEncryptTOTPs(ctx context.Context, storage *Storage, oldKey ApiKey) (CompletionStats, error) {
225238
var records []TOTP
226-
err = storage.ScanApiKey(envConfig.TotpTable, oldKey.Key, &records)
239+
err := storage.ScanApiKey(envConfig.TotpTable, oldKey.Key, &records)
227240
if err != nil {
228241
err = fmt.Errorf("failed to query %s table for key %s: %w", envConfig.TotpTable, oldKey.Key, err)
229-
return
242+
return CompletionStats{}, err
230243
}
231244

232-
incomplete = len(records)
245+
stats := CompletionStats{Incomplete: len(records)}
233246
for _, r := range records {
234247
err = k.ReEncryptLegacy(oldKey, &r.EncryptedTotpKey)
235248
if err != nil {
236-
err = fmt.Errorf("failed to re-encrypt TOTP %v: %w", r.UUID, err)
237-
return
249+
return stats, fmt.Errorf("failed to re-encrypt TOTP %v: %w", r.UUID, err)
238250
}
239251

240252
r.ApiKey = k.Key
241253

242-
err = storage.Store(envConfig.TotpTable, &r)
254+
err = storage.StoreCtx(ctx, envConfig.TotpTable, &r)
243255
if err != nil {
244-
err = fmt.Errorf("failed to store TOTP %v: %w", r.UUID, err)
245-
return
256+
return stats, fmt.Errorf("failed to store TOTP %v: %w", r.UUID, err)
246257
}
247-
complete++
248-
incomplete--
258+
259+
stats.Complete++
260+
stats.Incomplete--
249261
}
250-
return
262+
return stats, nil
251263
}
252264

253265
// ReEncryptWebAuthnUsers loads each WebAuthn record that was encrypted using the old key, re-encrypts it using the new
254266
// key, and writes the updated data back to the database.
255-
func (k *ApiKey) ReEncryptWebAuthnUsers(storage *Storage, oldKey ApiKey) (complete, incomplete int, err error) {
267+
func (k *ApiKey) ReEncryptWebAuthnUsers(ctx context.Context, storage *Storage, oldKey ApiKey) (CompletionStats, error) {
256268
var users []WebauthnUser
257-
err = storage.ScanApiKey(envConfig.WebauthnTable, oldKey.Key, &users)
269+
err := storage.ScanApiKey(envConfig.WebauthnTable, oldKey.Key, &users)
258270
if err != nil {
259271
err = fmt.Errorf("failed to query %s table for key %s: %w", envConfig.WebauthnTable, oldKey.Key, err)
260-
return
272+
return CompletionStats{}, err
261273
}
262274

263-
incomplete = len(users)
275+
stats := CompletionStats{Incomplete: len(users)}
264276
for _, user := range users {
265277
user.ApiKey = oldKey
266-
err = k.ReEncryptWebAuthnUser(storage, user)
278+
err = k.ReEncryptWebAuthnUser(ctx, storage, user)
267279
if err != nil {
268-
err = fmt.Errorf("failed to re-encrypt Webauthn %v: %w", user.ID, err)
269-
return
280+
return stats, fmt.Errorf("reencryption failed %v: %w", user.ID, err)
270281
}
271-
complete++
272-
incomplete--
282+
283+
stats.Complete++
284+
stats.Incomplete--
273285
}
274-
return
286+
return stats, nil
275287
}
276288

277289
// ReEncryptWebAuthnUser re-encrypts a WebAuthnUser using the new key, and writes the updated data back to the database.
278-
func (k *ApiKey) ReEncryptWebAuthnUser(storage *Storage, user WebauthnUser) error {
290+
func (k *ApiKey) ReEncryptWebAuthnUser(ctx context.Context, storage *Storage, user WebauthnUser) error {
279291
oldKey := user.ApiKey
280292
err := k.ReEncrypt(oldKey, &user.EncryptedSessionData)
281293
if err != nil {
@@ -297,7 +309,7 @@ func (k *ApiKey) ReEncryptWebAuthnUser(storage *Storage, user WebauthnUser) erro
297309
user.ApiKey = *k
298310
user.ApiKeyValue = k.Key
299311

300-
err = storage.Store(envConfig.WebauthnTable, &user)
312+
err = storage.StoreCtx(ctx, envConfig.WebauthnTable, &user)
301313
if err != nil {
302314
return err
303315
}
@@ -348,6 +360,8 @@ func (k *ApiKey) ReEncryptLegacy(oldKey ApiKey, v *string) error {
348360
// ActivateApiKey is the handler for the POST /api-key/activate endpoint. It creates the key secret and updates the
349361
// database record.
350362
func (a *App) ActivateApiKey(w http.ResponseWriter, r *http.Request) {
363+
ctx := r.Context()
364+
351365
var requestBody struct {
352366
ApiKeyValue string `json:"apiKeyValue"`
353367
Email string `json:"email"`
@@ -393,7 +407,7 @@ func (a *App) ActivateApiKey(w http.ResponseWriter, r *http.Request) {
393407
return
394408
}
395409

396-
err = newKey.Save()
410+
err = newKey.Save(ctx)
397411
if err != nil {
398412
log.Printf("failed to save key: %s", err)
399413
jsonResponse(w, internalServerError, http.StatusInternalServerError)
@@ -412,6 +426,8 @@ func (a *App) ActivateApiKey(w http.ResponseWriter, r *http.Request) {
412426

413427
// CreateApiKey is the handler for the POST /api-key endpoint. It creates a new API Key and saves it to the database.
414428
func (a *App) CreateApiKey(w http.ResponseWriter, r *http.Request) {
429+
ctx := r.Context()
430+
415431
var requestBody struct {
416432
Email string `json:"email"`
417433
}
@@ -436,7 +452,7 @@ func (a *App) CreateApiKey(w http.ResponseWriter, r *http.Request) {
436452
}
437453

438454
key.Store = a.db
439-
err = key.Save()
455+
err = key.Save(ctx)
440456
if err != nil {
441457
log.Printf("failed to save key: %s", err)
442458
jsonResponse(w, internalServerError, http.StatusInternalServerError)
@@ -456,6 +472,7 @@ func (a *App) CreateApiKey(w http.ResponseWriter, r *http.Request) {
456472
// any number of times to continue the process. A status of 200 does not indicate that all keys were encrypted using the
457473
// new key. Check the response data to determine if the rotation process is complete.
458474
func (a *App) RotateApiKey(w http.ResponseWriter, r *http.Request) {
475+
ctx := r.Context()
459476
var requestBody map[string]string
460477
err := json.NewDecoder(r.Body).Decode(&requestBody)
461478
if err != nil {
@@ -489,25 +506,19 @@ func (a *App) RotateApiKey(w http.ResponseWriter, r *http.Request) {
489506
return
490507
}
491508

492-
totpComplete, totpIncomplete, err := newKey.ReEncryptTOTPs(a.GetDB(), oldKey)
509+
webauthnStats, err := newKey.ReEncryptWebAuthnUsers(ctx, a.GetDB(), oldKey)
493510
if err != nil {
494-
log.Printf("failed to re-encrypt TOTP data: %s", err)
495-
jsonResponse(w, internalServerError, http.StatusInternalServerError)
496-
return
511+
log.Printf("failed to re-encrypt one or more WebAuthn record: %s", err)
497512
}
498513

499-
webauthnComplete, webauthnIncomplete, err := newKey.ReEncryptWebAuthnUsers(a.GetDB(), oldKey)
514+
totpStats, err := newKey.ReEncryptTOTPs(ctx, a.GetDB(), oldKey)
500515
if err != nil {
501-
log.Printf("failed to re-encrypt WebAuthn data: %s", err)
502-
jsonResponse(w, internalServerError, http.StatusInternalServerError)
503-
return
516+
log.Printf("failed to re-encrypt one or more TOTP record: %s", err)
504517
}
505518

506-
responseBody := map[string]int{
507-
"totpComplete": totpComplete,
508-
"totpIncomplete": totpIncomplete,
509-
"webauthnComplete": webauthnComplete,
510-
"webauthnIncomplete": webauthnIncomplete,
519+
responseBody := BatchStats{
520+
TOTP: totpStats,
521+
Webauthn: webauthnStats,
511522
}
512523

513524
jsonResponse(w, responseBody, http.StatusOK)

apikey_test.go

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,11 @@ func (ms *MfaSuite) TestAppRotateApiKey() {
366366
key := user.ApiKey
367367
must(db.Store(config.ApiKeyTable, key))
368368

369-
totp := ms.newTOTP(key)
369+
const numberOfTOTPs = 1000
370+
totpList := make([]TOTP, numberOfTOTPs)
371+
for i := range totpList {
372+
totpList[i] = ms.newTOTP(key)
373+
}
370374

371375
newKey := newTestKey()
372376
must(db.Store(config.ApiKeyTable, newKey))
@@ -425,7 +429,9 @@ func (ms *MfaSuite) TestAppRotateApiKey() {
425429
request.Header.Set(HeaderAPISecret, tt.key.Secret)
426430

427431
ctxWithUser := context.WithValue(request.Context(), UserContextKey, tt.key)
428-
request = request.WithContext(ctxWithUser)
432+
ctxWithDeadline, cancel := context.WithTimeout(ctxWithUser, time.Second)
433+
defer cancel()
434+
request = request.WithContext(ctxWithDeadline)
429435

430436
res := httptest.NewRecorder()
431437
Router(ms.app).ServeHTTP(res, request)
@@ -436,14 +442,24 @@ func (ms *MfaSuite) TestAppRotateApiKey() {
436442
return
437443
}
438444

439-
var response map[string]int
445+
var response BatchStats
440446
ms.decodeBody(res.Body.Bytes(), &response)
441-
ms.Equal(1, response["totpComplete"])
442-
ms.Equal(1, response["webauthnComplete"])
443-
444-
totpFromDB := TOTP{UUID: totp.UUID, ApiKey: newKey.Key}
445-
must(db.Load(config.TotpTable, "uuid", totp.UUID, &totpFromDB))
446-
ms.Equal(newKey.Key, totpFromDB.ApiKey)
447+
ms.Greater(response.TOTP.Complete, 0, "none of the TOTPs were re-encrypted")
448+
ms.Less(response.TOTP.Complete, numberOfTOTPs, "test didn't cancel before completion")
449+
ms.Equalf(numberOfTOTPs, response.TOTP.Complete+response.TOTP.Incomplete,
450+
"total of TOTP.Complete (%d) and TOTP.Incomplete (%d) should equal the total number of TOTPs (%d)",
451+
response.TOTP.Complete, response.TOTP.Incomplete, numberOfTOTPs)
452+
ms.Equal(1, response.Webauthn.Complete)
453+
454+
foundOne := false
455+
for i := range totpList {
456+
totpFromDB := TOTP{UUID: totpList[i].UUID, ApiKey: newKey.Key}
457+
must(db.Load(config.TotpTable, "uuid", totpList[i].UUID, &totpFromDB))
458+
if newKey.Key == totpFromDB.ApiKey {
459+
foundOne = true
460+
}
461+
}
462+
ms.True(foundOne, "did not find a TOTP with the new key")
447463

448464
dbUser := WebauthnUser{ID: user.ID, Store: db, ApiKey: newKey}
449465
must(dbUser.Load())
@@ -522,10 +538,10 @@ func (ms *MfaSuite) TestApiKey_ReEncryptTOTPs() {
522538

523539
_ = ms.newTOTP(oldKey)
524540

525-
complete, incomplete, err := newKey.ReEncryptTOTPs(storage, oldKey)
541+
stats, err := newKey.ReEncryptTOTPs(ms.T().Context(), storage, oldKey)
526542
ms.NoError(err)
527-
ms.Equal(1, complete)
528-
ms.Equal(0, incomplete)
543+
ms.Equal(1, stats.Complete)
544+
ms.Equal(0, stats.Incomplete)
529545
}
530546

531547
func (ms *MfaSuite) TestReEncryptWebAuthnUsers() {
@@ -540,10 +556,10 @@ func (ms *MfaSuite) TestReEncryptWebAuthnUsers() {
540556
newKey := newTestKey()
541557
must(ms.app.GetDB().Store(ms.app.GetConfig().ApiKeyTable, newKey))
542558

543-
complete, incomplete, err := newKey.ReEncryptWebAuthnUsers(storage, users[0].ApiKey)
559+
stats, err := newKey.ReEncryptWebAuthnUsers(ms.T().Context(), storage, users[0].ApiKey)
544560
ms.NoError(err)
545-
ms.Equal(0, incomplete)
546-
ms.Equal(1, complete)
561+
ms.Equal(0, stats.Incomplete)
562+
ms.Equal(1, stats.Complete)
547563

548564
// verify only users[0] is affected because each test user belongs to a different key
549565
for i, user := range users {
@@ -585,7 +601,7 @@ func (ms *MfaSuite) TestReEncryptWebAuthnUser() {
585601
must(ms.app.GetDB().Store(ms.app.GetConfig().ApiKeyTable, newKey))
586602
ms.NotEqual(newKey.Secret, tt.user.ApiKey.Secret)
587603

588-
err = newKey.ReEncryptWebAuthnUser(storage, tt.user)
604+
err = newKey.ReEncryptWebAuthnUser(ms.T().Context(), storage, tt.user)
589605
ms.NoError(err)
590606

591607
dbUser := WebauthnUser{ID: tt.user.ID, ApiKey: newKey, Store: storage}

openapi.yaml

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -519,22 +519,28 @@ paths:
519519
schema:
520520
type: object
521521
properties:
522-
totpComplete:
523-
type: integer
524-
description: the number of TOTP codes that were encrypted with the new key
525-
required: true
526-
totpIncomplete:
527-
type: integer
528-
description: the number of TOTP codes that have not yet been encrypted with the new key
529-
required: true
530-
webauthnComplete:
531-
type: integer
532-
description: the number of Webauthn passkeys that were encrypted with the new key
533-
required: true
534-
webauthnIncomplete:
535-
type: integer
536-
description: the number of Webauthn passkeys that have not yet been encrypted with the new key
537-
required: true
522+
totp:
523+
type: object
524+
properties:
525+
complete:
526+
type: integer
527+
description: the number of TOTP codes that were encrypted with the new key
528+
required: true
529+
incomplete:
530+
type: integer
531+
description: the number of TOTP codes that have not yet been encrypted with the new key
532+
required: true
533+
webauthn:
534+
type: object
535+
properties:
536+
complete:
537+
type: integer
538+
description: the number of Webauthn passkeys that were encrypted with the new key
539+
required: true
540+
incomplete:
541+
type: integer
542+
description: the number of Webauthn passkeys that have not yet been encrypted with the new key
543+
required: true
538544
400:
539545
description: Bad Request
540546
content:

storage.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ func NewStorage(config aws.Config) (*Storage, error) {
3333

3434
// Store puts item at key.
3535
func (s *Storage) Store(table string, item interface{}) error {
36+
return s.StoreCtx(context.Background(), table, item)
37+
}
38+
39+
// StoreCtx puts item at key. This is a context-aware equivalent of Store.
40+
func (s *Storage) StoreCtx(ctx context.Context, table string, item interface{}) error {
3641
av, err := attributevalue.MarshalMap(item)
3742
if err != nil {
3843
return err
@@ -43,7 +48,6 @@ func (s *Storage) Store(table string, item interface{}) error {
4348
TableName: aws.String(table),
4449
}
4550

46-
ctx := context.Background()
4751
_, err = s.client.PutItem(ctx, input)
4852
return err
4953
}

0 commit comments

Comments
 (0)