Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[management] Add GCM encryption and migrate legacy encrypted events #2569

Merged
merged 7 commits into from
Sep 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions management/server/activity/sqlite/crypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import (
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
)

var iv = []byte{10, 22, 13, 79, 05, 8, 52, 91, 87, 98, 88, 98, 35, 25, 13, 05}

type FieldEncrypt struct {
block cipher.Block
gcm cipher.AEAD
}

func GenerateKey() (string, error) {
Expand All @@ -35,22 +37,44 @@ func NewFieldEncrypt(key string) (*FieldEncrypt, error) {
if err != nil {
return nil, err
}

gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}

ec := &FieldEncrypt{
block: block,
gcm: gcm,
}

return ec, nil
}

func (ec *FieldEncrypt) Encrypt(payload string) string {
func (ec *FieldEncrypt) LegacyEncrypt(payload string) string {
plainText := pkcs5Padding([]byte(payload))
cipherText := make([]byte, len(plainText))
cbc := cipher.NewCBCEncrypter(ec.block, iv)
cbc.CryptBlocks(cipherText, plainText)
return base64.StdEncoding.EncodeToString(cipherText)
}

func (ec *FieldEncrypt) Decrypt(data string) (string, error) {
// Encrypt encrypts plaintext using AES-GCM
func (ec *FieldEncrypt) Encrypt(payload string) (string, error) {
plaintext := []byte(payload)
nonceSize := ec.gcm.NonceSize()

nonce := make([]byte, nonceSize, len(plaintext)+nonceSize+ec.gcm.Overhead())
if _, err := rand.Read(nonce); err != nil {
return "", err
}

ciphertext := ec.gcm.Seal(nonce, nonce, plaintext, nil)

return base64.StdEncoding.EncodeToString(ciphertext), nil
}

func (ec *FieldEncrypt) LegacyDecrypt(data string) (string, error) {
cipherText, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return "", err
Expand All @@ -65,6 +89,27 @@ func (ec *FieldEncrypt) Decrypt(data string) (string, error) {
return string(payload), nil
}

// Decrypt decrypts ciphertext using AES-GCM
func (ec *FieldEncrypt) Decrypt(data string) (string, error) {
cipherText, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return "", err
}

nonceSize := ec.gcm.NonceSize()
if len(cipherText) < nonceSize {
return "", errors.New("cipher text too short")
}

nonce, cipherText := cipherText[:nonceSize], cipherText[nonceSize:]
plainText, err := ec.gcm.Open(nil, nonce, cipherText, nil)
if err != nil {
return "", err
}

return string(plainText), nil
}

func pkcs5Padding(ciphertext []byte) []byte {
padding := aes.BlockSize - len(ciphertext)%aes.BlockSize
padText := bytes.Repeat([]byte{byte(padding)}, padding)
Expand Down
38 changes: 36 additions & 2 deletions management/server/activity/sqlite/crypt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ func TestGenerateKey(t *testing.T) {
t.Fatalf("failed to init email encryption: %s", err)
}

encrypted := ee.Encrypt(testData)
encrypted, err := ee.Encrypt(testData)
if err != nil {
t.Fatalf("failed to encrypt data: %s", err)
}

if encrypted == "" {
t.Fatalf("invalid encrypted text")
}
Expand All @@ -30,6 +34,32 @@ func TestGenerateKey(t *testing.T) {
}
}

func TestGenerateKeyLegacy(t *testing.T) {
testData := "[email protected]"
key, err := GenerateKey()
if err != nil {
t.Fatalf("failed to generate key: %s", err)
}
ee, err := NewFieldEncrypt(key)
if err != nil {
t.Fatalf("failed to init email encryption: %s", err)
}

encrypted := ee.LegacyEncrypt(testData)
if encrypted == "" {
t.Fatalf("invalid encrypted text")
}

decrypted, err := ee.LegacyDecrypt(encrypted)
if err != nil {
t.Fatalf("failed to decrypt data: %s", err)
}

if decrypted != testData {
t.Fatalf("decrypted data is not match with test data: %s, %s", testData, decrypted)
}
}

func TestCorruptKey(t *testing.T) {
testData := "[email protected]"
key, err := GenerateKey()
Expand All @@ -41,7 +71,11 @@ func TestCorruptKey(t *testing.T) {
t.Fatalf("failed to init email encryption: %s", err)
}

encrypted := ee.Encrypt(testData)
encrypted, err := ee.Encrypt(testData)
if err != nil {
t.Fatalf("failed to encrypt data: %s", err)
}

if encrypted == "" {
t.Fatalf("invalid encrypted text")
}
Expand Down
157 changes: 157 additions & 0 deletions management/server/activity/sqlite/migration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package sqlite

import (
"context"
"database/sql"
"fmt"

log "github.com/sirupsen/logrus"
)

func migrate(ctx context.Context, crypt *FieldEncrypt, db *sql.DB) error {
if _, err := db.Exec(createTableQuery); err != nil {
return err
}

if _, err := db.Exec(creatTableDeletedUsersQuery); err != nil {
return err
}

if err := updateDeletedUsersTable(ctx, db); err != nil {
return fmt.Errorf("failed to update deleted_users table: %v", err)
}

return migrateLegacyEncryptedUsersToGCM(ctx, crypt, db)
}

// updateDeletedUsersTable checks and updates the deleted_users table schema to ensure required columns exist.
func updateDeletedUsersTable(ctx context.Context, db *sql.DB) error {
exists, err := checkColumnExists(db, "deleted_users", "name")
if err != nil {
return err
}

if !exists {
log.WithContext(ctx).Debug("Adding name column to the deleted_users table")

_, err = db.Exec(`ALTER TABLE deleted_users ADD COLUMN name TEXT;`)
if err != nil {
return err
}

log.WithContext(ctx).Debug("Successfully added name column to the deleted_users table")
}

exists, err = checkColumnExists(db, "deleted_users", "enc_algo")
if err != nil {
return err
}

if !exists {
log.WithContext(ctx).Debug("Adding enc_algo column to the deleted_users table")

_, err = db.Exec(`ALTER TABLE deleted_users ADD COLUMN enc_algo TEXT;`)
if err != nil {
return err
}

log.WithContext(ctx).Debug("Successfully added enc_algo column to the deleted_users table")
}

return nil
}

// migrateLegacyEncryptedUsersToGCM migrates previously encrypted data using,
// legacy CBC encryption with a static IV to the new GCM encryption method.
func migrateLegacyEncryptedUsersToGCM(ctx context.Context, crypt *FieldEncrypt, db *sql.DB) error {
log.WithContext(ctx).Debug("Migrating CBC encrypted deleted users to GCM")

tx, err := db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %v", err)
}
defer func() {
_ = tx.Rollback()
}()

rows, err := tx.Query(fmt.Sprintf(`SELECT id, email, name FROM deleted_users where enc_algo IS NULL OR enc_algo != '%s'`, gcmEncAlgo))
if err != nil {
return fmt.Errorf("failed to execute select query: %v", err)
}
defer rows.Close()

updateStmt, err := tx.Prepare(`UPDATE deleted_users SET email = ?, name = ?, enc_algo = ? WHERE id = ?`)
if err != nil {
return fmt.Errorf("failed to prepare update statement: %v", err)
}
defer updateStmt.Close()

if err = processUserRows(ctx, crypt, rows, updateStmt); err != nil {
return err
}

if err = tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %v", err)
}

log.WithContext(ctx).Debug("Successfully migrated CBC encrypted deleted users to GCM")
return nil
}

// processUserRows processes database rows of user data, decrypts legacy encryption fields, and re-encrypts them using GCM.
func processUserRows(ctx context.Context, crypt *FieldEncrypt, rows *sql.Rows, updateStmt *sql.Stmt) error {
for rows.Next() {
var (
id, decryptedEmail, decryptedName string
email, name *string
)

err := rows.Scan(&id, &email, &name)
if err != nil {
return err
}

if email != nil {
decryptedEmail, err = crypt.LegacyDecrypt(*email)
if err != nil {
log.WithContext(ctx).Warnf("skipping migrating deleted user %s: %v",
id,
fmt.Errorf("failed to decrypt email: %w", err),
)
continue
}
}

if name != nil {
decryptedName, err = crypt.LegacyDecrypt(*name)
if err != nil {
log.WithContext(ctx).Warnf("skipping migrating deleted user %s: %v",
id,
fmt.Errorf("failed to decrypt name: %w", err),
)
continue
}
}

encryptedEmail, err := crypt.Encrypt(decryptedEmail)
if err != nil {
return fmt.Errorf("failed to encrypt email: %w", err)
}

encryptedName, err := crypt.Encrypt(decryptedName)
if err != nil {
return fmt.Errorf("failed to encrypt name: %w", err)
}

_, err = updateStmt.Exec(encryptedEmail, encryptedName, gcmEncAlgo, id)
if err != nil {
return err
}
}

if err := rows.Err(); err != nil {
return err
}

return nil
}
84 changes: 84 additions & 0 deletions management/server/activity/sqlite/migration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package sqlite

import (
"context"
"database/sql"
"path/filepath"
"testing"
"time"

_ "github.com/mattn/go-sqlite3"
"github.com/netbirdio/netbird/management/server/activity"

"github.com/stretchr/testify/require"
)

func setupDatabase(t *testing.T) *sql.DB {
t.Helper()

dbFile := filepath.Join(t.TempDir(), eventSinkDB)
db, err := sql.Open("sqlite3", dbFile)
require.NoError(t, err, "Failed to open database")

t.Cleanup(func() {
_ = db.Close()
})

_, err = db.Exec(createTableQuery)
require.NoError(t, err, "Failed to create events table")

_, err = db.Exec(`CREATE TABLE deleted_users (id TEXT NOT NULL, email TEXT NOT NULL, name TEXT);`)
require.NoError(t, err, "Failed to create deleted_users table")

return db
}

func TestMigrate(t *testing.T) {
db := setupDatabase(t)

key, err := GenerateKey()
require.NoError(t, err, "Failed to generate key")

crypt, err := NewFieldEncrypt(key)
require.NoError(t, err, "Failed to initialize FieldEncrypt")

legacyEmail := crypt.LegacyEncrypt("[email protected]")
legacyName := crypt.LegacyEncrypt("Test Account")

_, err = db.Exec(`INSERT INTO events(activity, timestamp, initiator_id, target_id, account_id, meta) VALUES(?, ?, ?, ?, ?, ?)`,
activity.UserDeleted, time.Now(), "initiatorID", "targetID", "accountID", "")
require.NoError(t, err, "Failed to insert event")

_, err = db.Exec(`INSERT INTO deleted_users(id, email, name) VALUES(?, ?, ?)`, "targetID", legacyEmail, legacyName)
require.NoError(t, err, "Failed to insert legacy encrypted data")

colExists, err := checkColumnExists(db, "deleted_users", "enc_algo")
require.NoError(t, err, "Failed to check if enc_algo column exists")
require.False(t, colExists, "enc_algo column should not exist before migration")

err = migrate(context.Background(), crypt, db)
require.NoError(t, err, "Migration failed")

colExists, err = checkColumnExists(db, "deleted_users", "enc_algo")
require.NoError(t, err, "Failed to check if enc_algo column exists after migration")
require.True(t, colExists, "enc_algo column should exist after migration")

var encAlgo string
err = db.QueryRow(`SELECT enc_algo FROM deleted_users LIMIT 1`, "").Scan(&encAlgo)
require.NoError(t, err, "Failed to select updated data")
require.Equal(t, gcmEncAlgo, encAlgo, "enc_algo should be set to 'GCM' after migration")

store, err := createStore(crypt, db)
require.NoError(t, err, "Failed to create store")

events, err := store.Get(context.Background(), "accountID", 0, 1, false)
require.NoError(t, err, "Failed to get events")

require.Len(t, events, 1, "Should have one event")
require.Equal(t, activity.UserDeleted, events[0].Activity, "activity should match")
require.Equal(t, "initiatorID", events[0].InitiatorID, "initiator id should match")
require.Equal(t, "targetID", events[0].TargetID, "target id should match")
require.Equal(t, "accountID", events[0].AccountID, "account id should match")
require.Equal(t, "[email protected]", events[0].Meta["email"], "email should match")
require.Equal(t, "Test Account", events[0].Meta["username"], "username should match")
}
Loading
Loading