Skip to content

Commit aac6700

Browse files
authored
feat(security): encrypt backup credentials and improve database reliability (#22)
* feat(security): encrypt backup credentials at rest and add database reliability improvements - Add AES-256-GCM encryption for backup target secrets (access key ID, secret key, restic password) stored in the database - Create shared crypto.Encryptor package for consistent encryption across services - Add SQLite PRAGMAs (foreign_keys, WAL, busy_timeout, synchronous) via new db.OpenSQLite helper for improved reliability - Set SQLite connection pool limits (MaxOpenConns=1, MaxIdleConns=1) for single-writer model - Add missing database indexes for common query patterns (nodes, keys, fabric_organizations, node_keys, backups, fabric_chaincodes) - Backwards compatible: plaintext values are transparently read via IsEncrypted check Signed-off-by: David Viejo <dviejo@kungfusoftware.es> * fix(db): add missing FK values for networks with foreign_keys=ON The new OpenSQLite helper enables foreign_keys=ON, but the networks table references node_statuses and blockchain_platforms with values that don't exist in those enum tables: - blockchain_platforms only had FABRIC/BESU but network code uses fabric/besu - node_statuses lacked network-specific statuses (genesis_block_created, etc.) This migration adds the missing enum values so network creation succeeds. Signed-off-by: David Viejo <dviejo@kungfusoftware.es> * fix(besu): remove invalid network_nodes creation from genesis block CreateGenesisBlock was creating network_nodes entries using key IDs instead of node IDs (which don't exist yet at genesis time). With foreign_keys=ON this correctly fails. Remove the premature insertion since nodes are created after the network via the testnet command. Signed-off-by: David Viejo <dviejo@kungfusoftware.es> --------- Signed-off-by: David Viejo <dviejo@kungfusoftware.es>
1 parent 7d75ee0 commit aac6700

File tree

9 files changed

+366
-35
lines changed

9 files changed

+366
-35
lines changed

cmd/serve/serve.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package serve
22

33
import (
44
"context"
5-
"database/sql"
65
"embed"
76
"encoding/hex"
87
"fmt"
@@ -21,6 +20,7 @@ import (
2120
backuphttp "github.com/chainlaunch/chainlaunch/pkg/backups/http"
2221
backupservice "github.com/chainlaunch/chainlaunch/pkg/backups/service"
2322
configservice "github.com/chainlaunch/chainlaunch/pkg/config"
23+
"github.com/chainlaunch/chainlaunch/pkg/crypto"
2424
"github.com/chainlaunch/chainlaunch/pkg/db"
2525
fabrichandler "github.com/chainlaunch/chainlaunch/pkg/fabric/handler"
2626
fabricservice "github.com/chainlaunch/chainlaunch/pkg/fabric/service"
@@ -289,7 +289,7 @@ func ensureKeyExists(filename string, dataPath string) (string, error) {
289289
}
290290

291291
// setupServer configures and returns the HTTP server
292-
func (c *serveCmd) setupServer(queries *db.Queries, authService *auth.AuthService, views embed.FS, dev bool, dbPath string, dataPath string, projectsDir string) *chi.Mux {
292+
func (c *serveCmd) setupServer(queries *db.Queries, authService *auth.AuthService, views embed.FS, dev bool, dbPath string, dataPath string, projectsDir string, encryptor *crypto.Encryptor) *chi.Mux {
293293
// Initialize services
294294
keyManagementService, err := service.NewKeyManagementService(queries)
295295
if err != nil {
@@ -324,7 +324,8 @@ func (c *serveCmd) setupServer(queries *db.Queries, authService *auth.AuthServic
324324

325325
networksService := networksservice.NewNetworkService(queries, nodesService, keyManagementService, logger, organizationService)
326326
notificationService := notificationservice.NewNotificationService(queries, logger)
327-
backupService := backupservice.NewBackupService(queries, logger, notificationService, dbPath, configService)
327+
328+
backupService := backupservice.NewBackupService(queries, logger, notificationService, dbPath, configService, encryptor)
328329

329330
// Initialize and start monitoring service
330331
monitoringConfig := &monitoring.Config{
@@ -773,8 +774,8 @@ func (c *serveCmd) preRun() error {
773774
c.dataPath = absPath
774775
}
775776

776-
// Initialize database connection
777-
database, err := sql.Open("sqlite3", c.dbPath)
777+
// Initialize database connection with security and reliability PRAGMAs
778+
database, err := db.OpenSQLite(c.dbPath)
778779
if err != nil {
779780
log.Fatalf("Failed to open database: %v", err)
780781
}
@@ -862,8 +863,14 @@ func (c *serveCmd) run() error {
862863
log.Printf("Updated password and role for user: %s", user.Username)
863864
}
864865

866+
// Create encryptor for encrypting sensitive data at rest
867+
encryptor, err := crypto.NewEncryptor(encryptionKey)
868+
if err != nil {
869+
log.Fatalf("Failed to create encryptor: %v", err)
870+
}
871+
865872
// Setup and start HTTP server
866-
router := c.setupServer(c.queries, authService, c.configCMD.Views, c.dev, c.dbPath, c.dataPath, c.projectsDir)
873+
router := c.setupServer(c.queries, authService, c.configCMD.Views, c.dev, c.dbPath, c.dataPath, c.projectsDir, encryptor)
867874

868875
// Start HTTP server in a goroutine
869876
httpServer := &http.Server{

pkg/backups/service/service.go

Lines changed: 115 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"encoding/base64"
2020

2121
"github.com/chainlaunch/chainlaunch/pkg/config"
22+
"github.com/chainlaunch/chainlaunch/pkg/crypto"
2223
"github.com/chainlaunch/chainlaunch/pkg/db"
2324
"github.com/chainlaunch/chainlaunch/pkg/logger"
2425
"github.com/chainlaunch/chainlaunch/pkg/notifications"
@@ -40,15 +41,20 @@ type BackupService struct {
4041
stopCh chan struct{}
4142
databasePath string
4243
configService *config.ConfigService
44+
encryptor *crypto.Encryptor
4345
}
4446

45-
// NewBackupService creates a new backup service
47+
// NewBackupService creates a new backup service.
48+
// The encryptor parameter is used to encrypt/decrypt sensitive backup target
49+
// credentials (secret keys, restic passwords). Pass nil to disable encryption
50+
// (not recommended for production).
4651
func NewBackupService(
4752
queries *db.Queries,
4853
logger *logger.Logger,
4954
notificationSvc *notificationService.NotificationService,
5055
databasePath string,
5156
configService *config.ConfigService,
57+
encryptor *crypto.Encryptor,
5258
) *BackupService {
5359
c := cron.New(cron.WithSeconds())
5460
c.Start()
@@ -62,6 +68,7 @@ func NewBackupService(
6268
stopCh: make(chan struct{}),
6369
databasePath: databasePath,
6470
configService: configService,
71+
encryptor: encryptor,
6572
}
6673

6774
// Load and schedule existing backup schedules
@@ -109,22 +116,41 @@ func (s *BackupService) CreateBackupTarget(ctx context.Context, params CreateBac
109116
return nil, fmt.Errorf("failed to generate restic password: %w", err)
110117
}
111118

119+
// Encrypt sensitive credentials before storing
120+
encAccessKeyID, err := s.encryptSecret(params.AccessKeyID)
121+
if err != nil {
122+
return nil, fmt.Errorf("failed to encrypt access key ID: %w", err)
123+
}
124+
encSecretKey, err := s.encryptSecret(params.SecretKey)
125+
if err != nil {
126+
return nil, fmt.Errorf("failed to encrypt secret key: %w", err)
127+
}
128+
encResticPassword, err := s.encryptSecret(resticPassword)
129+
if err != nil {
130+
return nil, fmt.Errorf("failed to encrypt restic password: %w", err)
131+
}
132+
112133
target, err := s.queries.CreateBackupTarget(ctx, &db.CreateBackupTargetParams{
113134
Name: params.Name,
114135
Type: string(params.Type),
115136
BucketName: sql.NullString{String: params.BucketName, Valid: params.BucketName != ""},
116137
Region: sql.NullString{String: params.Region, Valid: params.Region != ""},
117138
BucketPath: sql.NullString{String: params.BucketPath, Valid: params.BucketPath != ""},
118-
AccessKeyID: sql.NullString{String: params.AccessKeyID, Valid: params.AccessKeyID != ""},
119-
SecretKey: sql.NullString{String: params.SecretKey, Valid: params.SecretKey != ""},
139+
AccessKeyID: sql.NullString{String: encAccessKeyID, Valid: encAccessKeyID != ""},
140+
SecretKey: sql.NullString{String: encSecretKey, Valid: encSecretKey != ""},
120141
S3PathStyle: sql.NullBool{Bool: params.ForcePathStyle, Valid: true},
121142
Endpoint: sql.NullString{String: params.Endpoint, Valid: params.Endpoint != ""},
122-
ResticPassword: sql.NullString{String: resticPassword, Valid: true},
143+
ResticPassword: sql.NullString{String: encResticPassword, Valid: true},
123144
})
124145
if err != nil {
125146
return nil, fmt.Errorf("failed to create backup target: %w", err)
126147
}
127148

149+
decAccessKeyID, err := s.decryptSecret(target.AccessKeyID.String)
150+
if err != nil {
151+
return nil, fmt.Errorf("failed to decrypt access key ID: %w", err)
152+
}
153+
128154
return &BackupTargetDTO{
129155
ID: target.ID,
130156
Name: target.Name,
@@ -133,7 +159,7 @@ func (s *BackupService) CreateBackupTarget(ctx context.Context, params CreateBac
133159
Region: target.Region.String,
134160
Endpoint: target.Endpoint.String,
135161
BucketPath: target.BucketPath.String,
136-
AccessKeyID: target.AccessKeyID.String,
162+
AccessKeyID: decAccessKeyID,
137163
ForcePathStyle: target.S3PathStyle.Bool,
138164
CreatedAt: target.CreatedAt,
139165
UpdatedAt: &target.UpdatedAt.Time,
@@ -329,11 +355,25 @@ func (s *BackupService) performS3Backup(ctx context.Context, backup *db.Backup,
329355
return fmt.Errorf("backup configuration error: invalid endpoint URL: %w", err)
330356
}
331357

358+
// Decrypt credentials for use
359+
accessKeyID, err := s.decryptSecret(target.AccessKeyID.String)
360+
if err != nil {
361+
return fmt.Errorf("failed to decrypt access key ID: %w", err)
362+
}
363+
secretKey, err := s.decryptSecret(target.SecretKey.String)
364+
if err != nil {
365+
return fmt.Errorf("failed to decrypt secret key: %w", err)
366+
}
367+
resticPassword, err := s.decryptSecret(target.ResticPassword.String)
368+
if err != nil {
369+
return fmt.Errorf("failed to decrypt restic password: %w", err)
370+
}
371+
332372
// Set up restic environment variables for S3
333373
env := []string{
334-
fmt.Sprintf("AWS_ACCESS_KEY_ID=%s", target.AccessKeyID.String),
335-
fmt.Sprintf("AWS_SECRET_ACCESS_KEY=%s", target.SecretKey.String),
336-
fmt.Sprintf("RESTIC_PASSWORD=%s", target.ResticPassword.String),
374+
fmt.Sprintf("AWS_ACCESS_KEY_ID=%s", accessKeyID),
375+
fmt.Sprintf("AWS_SECRET_ACCESS_KEY=%s", secretKey),
376+
fmt.Sprintf("RESTIC_PASSWORD=%s", resticPassword),
337377
fmt.Sprintf("AWS_ENDPOINT=%s", customURL.Host),
338378
}
339379

@@ -658,6 +698,10 @@ func (s *BackupService) ListBackupTargets(ctx context.Context) ([]*BackupTargetD
658698

659699
dtos := make([]*BackupTargetDTO, len(targets))
660700
for i, target := range targets {
701+
decAccessKeyID, err := s.decryptSecret(target.AccessKeyID.String)
702+
if err != nil {
703+
return nil, fmt.Errorf("failed to decrypt access key ID: %w", err)
704+
}
661705
dtos[i] = &BackupTargetDTO{
662706
ID: target.ID,
663707
Name: target.Name,
@@ -666,7 +710,7 @@ func (s *BackupService) ListBackupTargets(ctx context.Context) ([]*BackupTargetD
666710
Region: target.Region.String,
667711
Endpoint: target.Endpoint.String,
668712
BucketPath: target.BucketPath.String,
669-
AccessKeyID: target.AccessKeyID.String,
713+
AccessKeyID: decAccessKeyID,
670714
ForcePathStyle: target.S3PathStyle.Bool,
671715
CreatedAt: target.CreatedAt,
672716
UpdatedAt: &target.UpdatedAt.Time,
@@ -683,6 +727,11 @@ func (s *BackupService) GetBackupTarget(ctx context.Context, id int64) (*BackupT
683727
return nil, fmt.Errorf("failed to get backup target: %w", err)
684728
}
685729

730+
decAccessKeyID, err := s.decryptSecret(target.AccessKeyID.String)
731+
if err != nil {
732+
return nil, fmt.Errorf("failed to decrypt access key ID: %w", err)
733+
}
734+
686735
return &BackupTargetDTO{
687736
ID: target.ID,
688737
Name: target.Name,
@@ -691,7 +740,7 @@ func (s *BackupService) GetBackupTarget(ctx context.Context, id int64) (*BackupT
691740
Region: target.Region.String,
692741
Endpoint: target.Endpoint.String,
693742
BucketPath: target.BucketPath.String,
694-
AccessKeyID: target.AccessKeyID.String,
743+
AccessKeyID: decAccessKeyID,
695744
ForcePathStyle: target.S3PathStyle.Bool,
696745
CreatedAt: target.CreatedAt,
697746
UpdatedAt: &target.UpdatedAt.Time,
@@ -905,11 +954,25 @@ func (s *BackupService) deleteBackupFile(ctx context.Context, backup *db.Backup,
905954

906955
// deleteS3BackupFile deletes a backup file from S3 using restic
907956
func (s *BackupService) deleteS3BackupFile(ctx context.Context, backup *db.Backup, target *db.BackupTarget) error {
957+
// Decrypt credentials for use
958+
accessKeyID, err := s.decryptSecret(target.AccessKeyID.String)
959+
if err != nil {
960+
return fmt.Errorf("failed to decrypt access key ID: %w", err)
961+
}
962+
secretKey, err := s.decryptSecret(target.SecretKey.String)
963+
if err != nil {
964+
return fmt.Errorf("failed to decrypt secret key: %w", err)
965+
}
966+
resticPassword, err := s.decryptSecret(target.ResticPassword.String)
967+
if err != nil {
968+
return fmt.Errorf("failed to decrypt restic password: %w", err)
969+
}
970+
908971
// Set up restic environment variables
909972
env := []string{
910-
fmt.Sprintf("AWS_ACCESS_KEY_ID=%s", target.AccessKeyID.String),
911-
fmt.Sprintf("AWS_SECRET_ACCESS_KEY=%s", target.SecretKey.String),
912-
fmt.Sprintf("RESTIC_PASSWORD=%s", target.ResticPassword.String),
973+
fmt.Sprintf("AWS_ACCESS_KEY_ID=%s", accessKeyID),
974+
fmt.Sprintf("AWS_SECRET_ACCESS_KEY=%s", secretKey),
975+
fmt.Sprintf("RESTIC_PASSWORD=%s", resticPassword),
913976
fmt.Sprintf("AWS_ENDPOINT=%s", target.Endpoint.String),
914977
}
915978

@@ -981,6 +1044,27 @@ func (s *BackupService) findLatestSnapshot(env []string) (string, error) {
9811044
return snapshots[0].ID, nil
9821045
}
9831046

1047+
// encryptSecret encrypts a secret string if an encryptor is configured.
1048+
// Returns the original string if no encryptor is set (backwards compatible).
1049+
func (s *BackupService) encryptSecret(secret string) (string, error) {
1050+
if s.encryptor == nil || secret == "" {
1051+
return secret, nil
1052+
}
1053+
return s.encryptor.Encrypt(secret)
1054+
}
1055+
1056+
// decryptSecret decrypts a secret string if an encryptor is configured.
1057+
// Handles both encrypted and plaintext values for backwards compatibility.
1058+
func (s *BackupService) decryptSecret(secret string) (string, error) {
1059+
if s.encryptor == nil || secret == "" {
1060+
return secret, nil
1061+
}
1062+
if !crypto.IsEncrypted(secret) {
1063+
return secret, nil
1064+
}
1065+
return s.encryptor.Decrypt(secret)
1066+
}
1067+
9841068
// Add helper function to generate secure password
9851069
func generateSecurePassword() (string, error) {
9861070
bytes := make([]byte, 32) // 256 bits
@@ -1008,6 +1092,16 @@ func (s *BackupService) UpdateBackupTarget(ctx context.Context, params UpdateBac
10081092
}
10091093
}
10101094

1095+
// Encrypt sensitive credentials before storing
1096+
encAccessKeyID, err := s.encryptSecret(params.AccessKeyID)
1097+
if err != nil {
1098+
return nil, fmt.Errorf("failed to encrypt access key ID: %w", err)
1099+
}
1100+
encSecretKey, err := s.encryptSecret(params.SecretKey)
1101+
if err != nil {
1102+
return nil, fmt.Errorf("failed to encrypt secret key: %w", err)
1103+
}
1104+
10111105
// Update the target
10121106
target, err := s.queries.UpdateBackupTarget(ctx, &db.UpdateBackupTargetParams{
10131107
ID: params.ID,
@@ -1016,8 +1110,8 @@ func (s *BackupService) UpdateBackupTarget(ctx context.Context, params UpdateBac
10161110
BucketName: sql.NullString{String: params.BucketName, Valid: params.BucketName != ""},
10171111
Region: sql.NullString{String: params.Region, Valid: params.Region != ""},
10181112
BucketPath: sql.NullString{String: params.BucketPath, Valid: params.BucketPath != ""},
1019-
AccessKeyID: sql.NullString{String: params.AccessKeyID, Valid: params.AccessKeyID != ""},
1020-
SecretKey: sql.NullString{String: params.SecretKey, Valid: params.SecretKey != ""},
1113+
AccessKeyID: sql.NullString{String: encAccessKeyID, Valid: encAccessKeyID != ""},
1114+
SecretKey: sql.NullString{String: encSecretKey, Valid: encSecretKey != ""},
10211115
S3PathStyle: sql.NullBool{Bool: params.ForcePathStyle, Valid: true},
10221116
Endpoint: sql.NullString{String: params.Endpoint, Valid: params.Endpoint != ""},
10231117
})
@@ -1028,6 +1122,11 @@ func (s *BackupService) UpdateBackupTarget(ctx context.Context, params UpdateBac
10281122
return nil, fmt.Errorf("failed to update backup target: %w", err)
10291123
}
10301124

1125+
decAccessKeyID, err := s.decryptSecret(target.AccessKeyID.String)
1126+
if err != nil {
1127+
return nil, fmt.Errorf("failed to decrypt access key ID: %w", err)
1128+
}
1129+
10311130
return &BackupTargetDTO{
10321131
ID: target.ID,
10331132
Name: target.Name,
@@ -1036,7 +1135,7 @@ func (s *BackupService) UpdateBackupTarget(ctx context.Context, params UpdateBac
10361135
Region: target.Region.String,
10371136
Endpoint: target.Endpoint.String,
10381137
BucketPath: target.BucketPath.String,
1039-
AccessKeyID: target.AccessKeyID.String,
1138+
AccessKeyID: decAccessKeyID,
10401139
ForcePathStyle: target.S3PathStyle.Bool,
10411140
CreatedAt: target.CreatedAt,
10421141
UpdatedAt: &target.UpdatedAt.Time,

0 commit comments

Comments
 (0)