From 44a05be1af814b5b03edc67e04876a1bb6896400 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Sun, 20 Apr 2025 11:11:39 -0700 Subject: [PATCH] Encrypt connector sensitive data Add database field level encryption level to sensitive data to reduce the risk in term of data leak. I dedice to have only one key for now in a near future I may move to one master key and one encryption key per organization to make rotation easiest. I don't use built-in pg_crypto function to have clear seperation and avoid any encryption key leak. Signed-off-by: Bryan Frimin --- pkg/connector/connector.go | 28 ++--- pkg/coredata/connector.go | 62 ++++++---- pkg/coredata/migrations/20250420T110200Z.sql | 4 + pkg/crypto/cipher/cipher.go | 116 +++++++++++++++++++ pkg/probo/connector_service.go | 4 +- pkg/probo/service.go | 35 +++--- pkg/probod/connector_config.go | 44 ++++--- pkg/probod/probod.go | 19 +-- pkg/server/api/console/v1/resolver.go | 2 +- 9 files changed, 225 insertions(+), 89 deletions(-) create mode 100644 pkg/coredata/migrations/20250420T110200Z.sql create mode 100644 pkg/crypto/cipher/cipher.go diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index fde3d43f0..83ac617ea 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -42,27 +42,17 @@ const ( ProtocolOAuth2 ProtocolType = "oauth2" ) -func UnmarshalConnection(data []byte) (Connection, error) { - var typeContainer struct { - Type string `json:"type"` - } +func UnmarshalConnection(prtcl ProtocolType, data []byte) (Connection, error) { - if err := json.Unmarshal(data, &typeContainer); err != nil { - return nil, fmt.Errorf("failed to unmarshal connection type: %w", err) - } - - var conn Connection - - switch ProtocolType(typeContainer.Type) { + switch prtcl { case ProtocolOAuth2: - conn = &OAuth2Connection{} - default: - return nil, fmt.Errorf("unknown connection type: %s", typeContainer.Type) + var conn OAuth2Connection + if err := json.Unmarshal(data, &conn); err != nil { + return nil, fmt.Errorf("cannot unmarshal oauth2 connection: %w", err) + } + + return &conn, nil } - if err := conn.UnmarshalJSON(data); err != nil { - return nil, err - } - - return conn, nil + return nil, fmt.Errorf("unknown connection type: %s", prtcl) } diff --git a/pkg/coredata/connector.go b/pkg/coredata/connector.go index d363f1d4a..73ed8069c 100644 --- a/pkg/coredata/connector.go +++ b/pkg/coredata/connector.go @@ -21,6 +21,7 @@ import ( "time" "github.com/getprobo/probo/pkg/connector" + "github.com/getprobo/probo/pkg/crypto/cipher" "github.com/getprobo/probo/pkg/gid" "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" @@ -28,14 +29,14 @@ import ( type ( Connector struct { - ID gid.GID `db:"id"` - OrganizationID gid.GID `db:"organization_id"` - Name string `db:"name"` - Type string `db:"type"` - Connection connector.Connection `db:"-"` - RawConfig json.RawMessage `db:"connection"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + Name string `db:"name"` + Type connector.ProtocolType `db:"type"` + Connection connector.Connection `db:"-"` + EncryptedConnection []byte `db:"encrypted_connection"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } ) @@ -43,6 +44,7 @@ func (c *Connector) Upsert( ctx context.Context, conn pg.Conn, scope Scoper, + encryptionKey cipher.EncryptionKey, ) error { q := ` INSERT INTO @@ -52,7 +54,7 @@ INSERT INTO organization_id, name, type, - connection, + encrypted_connection, created_at, updated_at ) @@ -62,37 +64,47 @@ VALUES ( @organization_id, @name, @type, - @connection, + @encrypted_connection, @created_at, @updated_at ) ON CONFLICT (organization_id, name) DO UPDATE SET tenant_id = @tenant_id, organization_id = @organization_id, - connection = @connection, + encrypted_connection = @encrypted_connection, updated_at = @updated_at RETURNING id, organization_id, name, type, - connection, + encrypted_connection, created_at, updated_at ` + connection, err := json.Marshal(c.Connection) + if err != nil { + return fmt.Errorf("cannot marshal connection: %w", err) + } + + encryptedConnection, err := cipher.Encrypt(connection, encryptionKey) + if err != nil { + return fmt.Errorf("cannot encrypt connection: %w", err) + } + rows, err := conn.Query( ctx, q, pgx.StrictNamedArgs{ - "id": c.ID, - "tenant_id": scope.GetTenantID(), - "organization_id": c.OrganizationID, - "name": c.Name, - "type": c.Type, - "connection": c.Connection, - "created_at": c.CreatedAt, - "updated_at": c.UpdatedAt, + "id": c.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": c.OrganizationID, + "name": c.Name, + "type": c.Type, + "encrypted_connection": encryptedConnection, + "created_at": c.CreatedAt, + "updated_at": c.UpdatedAt, }, ) if err != nil { @@ -105,6 +117,16 @@ RETURNING return fmt.Errorf("cannot collect connectors: %w", err) } + decryptedConnection, err := cipher.Decrypt(cnnctr.EncryptedConnection, encryptionKey) + if err != nil { + return fmt.Errorf("cannot decrypt connection: %w", err) + } + + cnnctr.Connection, err = connector.UnmarshalConnection(cnnctr.Type, decryptedConnection) + if err != nil { + return fmt.Errorf("cannot unmarshal connection: %w", err) + } + *c = cnnctr return nil diff --git a/pkg/coredata/migrations/20250420T110200Z.sql b/pkg/coredata/migrations/20250420T110200Z.sql new file mode 100644 index 000000000..49bf4d027 --- /dev/null +++ b/pkg/coredata/migrations/20250420T110200Z.sql @@ -0,0 +1,4 @@ +DELETE FROM connectors; + +ALTER TABLE connectors DROP COLUMN connection; +ALTER TABLE connectors ADD COLUMN encrypted_connection BYTEA NOT NULL; \ No newline at end of file diff --git a/pkg/crypto/cipher/cipher.go b/pkg/crypto/cipher/cipher.go new file mode 100644 index 000000000..ac761f4d8 --- /dev/null +++ b/pkg/crypto/cipher/cipher.go @@ -0,0 +1,116 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package cipher + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" +) + +type ( + EncryptionKey [32]byte +) + +func NewEncryptionKey(key string) (EncryptionKey, error) { + if len(key) != 32 { + return EncryptionKey{}, fmt.Errorf("key must be 32 bytes for AES-256") + } + + var encryptionKey EncryptionKey + copy(encryptionKey[:], key) + + return encryptionKey, nil +} + +func (k EncryptionKey) Bytes() []byte { + return k[:] +} + +func (k *EncryptionKey) UnmarshalJSON(data []byte) error { + var key string + if err := json.Unmarshal(data, &key); err != nil { + return err + } + + return k.UnmarshalText([]byte(key)) +} + +func (k *EncryptionKey) UnmarshalText(text []byte) error { + decoded, err := base64.StdEncoding.DecodeString(string(text)) + if err != nil { + return fmt.Errorf("cannot decode base64 key: %w", err) + } + + if len(decoded) != 32 { + return fmt.Errorf("key must be 32 bytes for AES-256, got %d bytes after base64 decoding", len(decoded)) + } + + copy(k[:], decoded) + return nil +} + +func (k EncryptionKey) MarshalJSON() ([]byte, error) { + return json.Marshal(base64.StdEncoding.EncodeToString(k[:])) +} + +func Encrypt(data []byte, key EncryptionKey) ([]byte, error) { + block, err := aes.NewCipher(key.Bytes()) + if err != nil { + return nil, err + } + + nonce := make([]byte, 12) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + ciphertext := aesgcm.Seal(nonce, nonce, data, nil) + + return ciphertext, nil +} + +func Decrypt(data []byte, key EncryptionKey) ([]byte, error) { + block, err := aes.NewCipher(key.Bytes()) + if err != nil { + return nil, err + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + if len(data) < 12 { + return nil, fmt.Errorf("ciphertext too short") + } + nonce, ciphertext := data[:12], data[12:] + + plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("cannot decrypt: %w", err) + } + + return plaintext, nil +} diff --git a/pkg/probo/connector_service.go b/pkg/probo/connector_service.go index 9decc280f..411d1d611 100644 --- a/pkg/probo/connector_service.go +++ b/pkg/probo/connector_service.go @@ -33,7 +33,7 @@ type ( CreateOrUpdateConnectorRequest struct { OrganizationID gid.GID Name string - Type string + Type connector.ProtocolType Connection connector.Connection } ) @@ -75,7 +75,7 @@ func (s *ConnectorService) CreateOrUpdate(ctx context.Context, req CreateOrUpdat err = s.svc.pg.WithConn( ctx, func(conn pg.Conn) error { - if err := connector.Upsert(ctx, conn, s.svc.scope); err != nil { + if err := connector.Upsert(ctx, conn, s.svc.scope, s.svc.encryptionKey); err != nil { return fmt.Errorf("cannot upsert connector: %w", err) } diff --git a/pkg/probo/service.go b/pkg/probo/service.go index 24f996b5a..3e2db3ade 100644 --- a/pkg/probo/service.go +++ b/pkg/probo/service.go @@ -20,23 +20,25 @@ import ( "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/getprobo/probo/pkg/coredata" + "github.com/getprobo/probo/pkg/crypto/cipher" "github.com/getprobo/probo/pkg/gid" "go.gearno.de/kit/pg" ) type ( Service struct { - pg *pg.Client - s3 *s3.Client - bucket string + pg *pg.Client + s3 *s3.Client + bucket string + encryptionKey cipher.EncryptionKey } TenantService struct { - pg *pg.Client - s3 *s3.Client - bucket string - - scope coredata.Scoper + pg *pg.Client + s3 *s3.Client + bucket string + encryptionKey cipher.EncryptionKey + scope coredata.Scoper Frameworks *FrameworkService Mesures *MesureService @@ -55,6 +57,7 @@ type ( func NewService( ctx context.Context, + encryptionKey cipher.EncryptionKey, pgClient *pg.Client, s3Client *s3.Client, bucket string, @@ -64,9 +67,10 @@ func NewService( } svc := &Service{ - pg: pgClient, - s3: s3Client, - bucket: bucket, + pg: pgClient, + s3: s3Client, + bucket: bucket, + encryptionKey: encryptionKey, } return svc, nil @@ -74,10 +78,11 @@ func NewService( func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService { tenantService := &TenantService{ - pg: s.pg, - s3: s.s3, - bucket: s.bucket, - scope: coredata.NewScope(tenantID), + pg: s.pg, + s3: s.s3, + bucket: s.bucket, + encryptionKey: s.encryptionKey, + scope: coredata.NewScope(tenantID), } tenantService.Frameworks = &FrameworkService{svc: tenantService} diff --git a/pkg/probod/connector_config.go b/pkg/probod/connector_config.go index 0925fc720..aaeb57784 100644 --- a/pkg/probod/connector_config.go +++ b/pkg/probod/connector_config.go @@ -23,26 +23,26 @@ import ( type ( connectorConfig struct { - Name string `json:"name"` - Type string `json:"type"` - Config connector.Connector `json:"-"` + Name string `json:"name"` + Type connector.ProtocolType `json:"type"` + Config connector.Connector `json:"-"` } - connectorOAuth2Config struct { + connectorConfigOAuth2 struct { ClientID string `json:"client-id"` ClientSecret string `json:"client-secret"` RedirectURI string `json:"redirect-uri"` - Scopes []string `json:"scopes"` AuthURL string `json:"auth-url"` TokenURL string `json:"token-url"` + Scopes []string `json:"scopes"` } ) func (c *connectorConfig) UnmarshalJSON(data []byte) error { var tmp struct { - Name string `json:"name"` - Type string `json:"type"` - RawConfig json.RawMessage `json:"config"` + Name string `json:"name"` + Type connector.ProtocolType `json:"type"` + RawConfig json.RawMessage `json:"config"` } if err := json.Unmarshal(data, &tmp); err != nil { @@ -53,26 +53,24 @@ func (c *connectorConfig) UnmarshalJSON(data []byte) error { c.Type = tmp.Type switch tmp.Type { - case "oauth2": - var cfg connectorOAuth2Config - if err := json.Unmarshal(tmp.RawConfig, &cfg); err != nil { - return fmt.Errorf("cannot unmarshal oauth2 config: %w", err) + case connector.ProtocolOAuth2: + var config connectorConfigOAuth2 + if err := json.Unmarshal(tmp.RawConfig, &config); err != nil { + return fmt.Errorf("cannot unmarshal oauth2 connector config: %w", err) } - if cfg.ClientID == "" || cfg.ClientSecret == "" || cfg.AuthURL == "" || cfg.TokenURL == "" || cfg.RedirectURI == "" { - return fmt.Errorf("oauth2 config: client-id, client-secret, auth-url, token-url and redirect-uri are required") + oauth2Connector := connector.OAuth2Connector{ + ClientID: config.ClientID, + ClientSecret: config.ClientSecret, + RedirectURI: config.RedirectURI, + AuthURL: config.AuthURL, + TokenURL: config.TokenURL, + Scopes: config.Scopes, } - c.Config = &connector.OAuth2Connector{ - ClientID: cfg.ClientID, - ClientSecret: cfg.ClientSecret, - RedirectURI: cfg.RedirectURI, - Scopes: cfg.Scopes, - AuthURL: cfg.AuthURL, - TokenURL: cfg.TokenURL, - } + c.Config = &oauth2Connector default: - return fmt.Errorf("unknown %q connector type: %s", tmp.Name, tmp.Type) + return fmt.Errorf("unknown connector type: %q", tmp.Type) } return nil diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 1507fc44d..659c737c8 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -27,6 +27,7 @@ import ( "github.com/getprobo/probo/pkg/awsconfig" "github.com/getprobo/probo/pkg/connector" "github.com/getprobo/probo/pkg/coredata" + "github.com/getprobo/probo/pkg/crypto/cipher" "github.com/getprobo/probo/pkg/crypto/passwdhash" "github.com/getprobo/probo/pkg/mailer" "github.com/getprobo/probo/pkg/probo" @@ -49,13 +50,14 @@ type ( } config struct { - Hostname string `json:"hostname"` - Pg pgConfig `json:"pg"` - Api apiConfig `json:"api"` - Auth authConfig `json:"auth"` - AWS awsConfig `json:"aws"` - Mailer mailerConfig `json:"mailer"` - Connectors []connectorConfig `json:"connectors"` + Hostname string `json:"hostname"` + EncryptionKey cipher.EncryptionKey `json:"encryption-key"` + Pg pgConfig `json:"pg"` + Api apiConfig `json:"api"` + Auth authConfig `json:"auth"` + AWS awsConfig `json:"aws"` + Mailer mailerConfig `json:"mailer"` + Connectors []connectorConfig `json:"connectors"` } ) @@ -142,7 +144,6 @@ func (impl *Implm) Run( return fmt.Errorf("cannot get pepper bytes: %w", err) } - // Validate cookie secret _, err = impl.cfg.Auth.GetCookieSecretBytes() if err != nil { rootSpan.RecordError(err) @@ -195,7 +196,7 @@ func (impl *Implm) Run( return fmt.Errorf("cannot create usrmgr service: %w", err) } - proboService, err := probo.NewService(ctx, pgClient, s3Client, impl.cfg.AWS.Bucket) + proboService, err := probo.NewService(ctx, impl.cfg.EncryptionKey, pgClient, s3Client, impl.cfg.AWS.Bucket) if err != nil { return fmt.Errorf("cannot create probo service: %w", err) } diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index ca56171e7..2bb3c168f 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -129,7 +129,7 @@ func NewMux(proboSvc *probo.Service, usrmgrSvc *usrmgr.Service, authCfg AuthConf probo.CreateOrUpdateConnectorRequest{ OrganizationID: organizationID, Name: connectorID, - Type: string(connection.Type()), + Type: connector.ProtocolType(connection.Type()), Connection: connection, }, )