diff --git a/backend/controller/encryption/api/database.go b/backend/controller/encryption/api/database.go index 0d477e52c2..2004b6f676 100644 --- a/backend/controller/encryption/api/database.go +++ b/backend/controller/encryption/api/database.go @@ -37,6 +37,7 @@ func (e *EncryptedColumn[SK]) Scan(src interface{}) error { type EncryptedTimelineColumn = EncryptedColumn[TimelineSubKey] type EncryptedAsyncColumn = EncryptedColumn[AsyncSubKey] +type EncryptedIdentityColumn = EncryptedColumn[IdentityKeySubKey] type OptionalEncryptedTimelineColumn = optional.Option[EncryptedTimelineColumn] type OptionalEncryptedAsyncColumn = optional.Option[EncryptedAsyncColumn] @@ -53,3 +54,8 @@ func (TimelineSubKey) SubKey() string { return "timeline" } type AsyncSubKey struct{} func (AsyncSubKey) SubKey() string { return "async" } + +// IdentityKeySubKey is a type that represents the subkey for identity keys. +type IdentityKeySubKey struct{} + +func (IdentityKeySubKey) SubKey() string { return "identity" } diff --git a/backend/controller/encryption/service.go b/backend/controller/encryption/service.go index db97fbc98c..ad2519a8aa 100644 --- a/backend/controller/encryption/service.go +++ b/backend/controller/encryption/service.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/alecthomas/types/optional" + "github.com/tink-crypto/tink-go/v2/tink" "github.com/TBD54566975/ftl/backend/controller/encryption/api" "github.com/TBD54566975/ftl/backend/controller/encryption/internal/dal" @@ -31,6 +32,17 @@ func New(ctx context.Context, conn libdal.Connection, encryptionBuilder Builder) return &Service{encryptor: encryptor}, nil } +// AEAD returns the AEAD instance used by the encryptor. +// TODO: Remove this method once we have a better way to handle this. +func (s *Service) AEAD() (tink.AEAD, error) { + kmsEncryptor, ok := s.encryptor.(*KMSEncryptor) + if !ok { + return nil, fmt.Errorf("encryptor is not of type *KMSEncryptor") + } + + return kmsEncryptor.kekAEAD, nil +} + // EncryptJSON encrypts the given JSON object and stores it in the provided destination. func (s *Service) EncryptJSON(v any, dest api.Encrypted) error { serialized, err := json.Marshal(v) diff --git a/backend/controller/identity/dal/dal.go b/backend/controller/identity/dal/dal.go new file mode 100644 index 0000000000..52994700ca --- /dev/null +++ b/backend/controller/identity/dal/dal.go @@ -0,0 +1,48 @@ +package dal + +import ( + "context" + "fmt" + + "github.com/TBD54566975/ftl/backend/controller/identity/dal/internal/sql" + "github.com/TBD54566975/ftl/backend/libdal" +) + +type DAL struct { + *libdal.Handle[DAL] + db sql.Querier +} + +func New(conn libdal.Connection) *DAL { + return &DAL{ + db: sql.New(conn), + Handle: libdal.New(conn, func(h *libdal.Handle[DAL]) *DAL { + return &DAL{Handle: h, db: sql.New(h.Connection)} + }), + } +} + +type EncryptedIdentity = sql.GetIdentityKeysRow + +func (d *DAL) GetOnlyIdentityKey(ctx context.Context) (EncryptedIdentity, error) { + rows, err := d.db.GetIdentityKeys(ctx) + if err != nil { + return EncryptedIdentity{}, fmt.Errorf("failed to get only identity key: %w", err) + } + if len(rows) == 0 { + return EncryptedIdentity{}, libdal.ErrNotFound + } + if len(rows) > 1 { + return EncryptedIdentity{}, fmt.Errorf("too many identity keys found: %d", len(rows)) + } + + return rows[0], nil +} + +func (d *DAL) CreateOnlyIdentityKey(ctx context.Context, e EncryptedIdentity) error { + if err := d.db.CreateOnlyIdentityKey(ctx, e.Private, e.Public, e.VerifySignature); err != nil { + return fmt.Errorf("failed to create only identity key: %w", err) + } + + return nil +} diff --git a/backend/controller/identity/dal/internal/sql/db.go b/backend/controller/identity/dal/internal/sql/db.go new file mode 100644 index 0000000000..0e0973111c --- /dev/null +++ b/backend/controller/identity/dal/internal/sql/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package sql + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/backend/controller/identity/dal/internal/sql/models.go b/backend/controller/identity/dal/internal/sql/models.go new file mode 100644 index 0000000000..76baffe98e --- /dev/null +++ b/backend/controller/identity/dal/internal/sql/models.go @@ -0,0 +1,5 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package sql diff --git a/backend/controller/identity/dal/internal/sql/querier.go b/backend/controller/identity/dal/internal/sql/querier.go new file mode 100644 index 0000000000..c6ebc4258a --- /dev/null +++ b/backend/controller/identity/dal/internal/sql/querier.go @@ -0,0 +1,18 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package sql + +import ( + "context" + + "github.com/TBD54566975/ftl/backend/controller/encryption/api" +) + +type Querier interface { + CreateOnlyIdentityKey(ctx context.Context, private api.EncryptedIdentityColumn, public []byte, verifySignature []byte) error + GetIdentityKeys(ctx context.Context) ([]GetIdentityKeysRow, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/backend/controller/identity/dal/internal/sql/queries.sql b/backend/controller/identity/dal/internal/sql/queries.sql new file mode 100644 index 0000000000..64e6f83c77 --- /dev/null +++ b/backend/controller/identity/dal/internal/sql/queries.sql @@ -0,0 +1,8 @@ +-- name: GetIdentityKeys :many +SELECT private, public, verify_signature +FROM identity_keys +LIMIT 2; + +-- name: CreateOnlyIdentityKey :exec +INSERT INTO identity_keys (private, public, verify_signature) +VALUES ($1, $2, $3); diff --git a/backend/controller/identity/dal/internal/sql/queries.sql.go b/backend/controller/identity/dal/internal/sql/queries.sql.go new file mode 100644 index 0000000000..aee7bdaeb8 --- /dev/null +++ b/backend/controller/identity/dal/internal/sql/queries.sql.go @@ -0,0 +1,57 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: queries.sql + +package sql + +import ( + "context" + + "github.com/TBD54566975/ftl/backend/controller/encryption/api" +) + +const createOnlyIdentityKey = `-- name: CreateOnlyIdentityKey :exec +INSERT INTO identity_keys (private, public, verify_signature) +VALUES ($1, $2, $3) +` + +func (q *Queries) CreateOnlyIdentityKey(ctx context.Context, private api.EncryptedIdentityColumn, public []byte, verifySignature []byte) error { + _, err := q.db.ExecContext(ctx, createOnlyIdentityKey, private, public, verifySignature) + return err +} + +const getIdentityKeys = `-- name: GetIdentityKeys :many +SELECT private, public, verify_signature +FROM identity_keys +LIMIT 2 +` + +type GetIdentityKeysRow struct { + Private api.EncryptedIdentityColumn + Public []byte + VerifySignature []byte +} + +func (q *Queries) GetIdentityKeys(ctx context.Context) ([]GetIdentityKeysRow, error) { + rows, err := q.db.QueryContext(ctx, getIdentityKeys) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetIdentityKeysRow + for rows.Next() { + var i GetIdentityKeysRow + if err := rows.Scan(&i.Private, &i.Public, &i.VerifySignature); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/backend/controller/identity/identity.go b/backend/controller/identity/identity.go new file mode 100644 index 0000000000..0c32d31b3b --- /dev/null +++ b/backend/controller/identity/identity.go @@ -0,0 +1,183 @@ +package identity + +import ( + "bytes" + "context" + "database/sql" + "errors" + "fmt" + + "github.com/tink-crypto/tink-go/v2/keyset" + + encryptionsvc "github.com/TBD54566975/ftl/backend/controller/encryption" + "github.com/TBD54566975/ftl/backend/controller/encryption/api" + "github.com/TBD54566975/ftl/backend/controller/identity/dal" + "github.com/TBD54566975/ftl/backend/libdal" + internalidentity "github.com/TBD54566975/ftl/internal/identity" + "github.com/TBD54566975/ftl/internal/log" +) + +type Service struct { + dal dal.DAL + encryption *encryptionsvc.Service + signer internalidentity.Signer + verifier internalidentity.Verifier +} + +func New(ctx context.Context, encryption *encryptionsvc.Service, conn *sql.DB) (*Service, error) { + svc := &Service{ + dal: *dal.New(conn), + encryption: encryption, + } + + err := svc.ensureIdentity(ctx) + if err != nil { + return nil, fmt.Errorf("failed to ensure identity: %w", err) + } + + keyPair, err := svc.getKeyPair(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get key pair: %w", err) + } + + signer, err := keyPair.Signer() + if err != nil { + return nil, fmt.Errorf("failed to create signer: %w", err) + } + svc.signer = signer + + verifier, err := keyPair.Verifier() + if err != nil { + return nil, fmt.Errorf("failed to create verifier: %w", err) + } + svc.verifier = verifier + + return svc, nil +} + +func (s Service) Sign(data []byte) (*internalidentity.SignedData, error) { + signedData, err := s.signer.Sign(data) + if err != nil { + return nil, fmt.Errorf("failed to sign data: %w", err) + } + + return signedData, nil +} + +func (s Service) Verify(signedData internalidentity.SignedData) error { + err := s.verifier.Verify(signedData) + if err != nil { + return fmt.Errorf("failed to verify data: %w", err) + } + + return nil +} + +func (s Service) getKeyPair(ctx context.Context) (internalidentity.KeyPair, error) { + identity, err := s.dal.GetOnlyIdentityKey(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get only identity key: %w", err) + } + + reader := keyset.NewBinaryReader(bytes.NewReader(identity.Private.Bytes())) + aead, err := s.encryption.AEAD() + if err != nil { + return nil, fmt.Errorf("failed to get AEAD: %w", err) + } + + handle, err := keyset.Read(reader, aead) + if err != nil { + return nil, fmt.Errorf("failed to read keyset: %w", err) + } + + keyPair := internalidentity.NewTinkKeyPair(*handle) + return keyPair, nil +} + +const verificationText = "My voice is my passport, verify me." + +func (s Service) ensureIdentity(ctx context.Context) (err error) { + logger := log.FromContext(ctx) + tx, err := s.dal.Begin(ctx) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.CommitOrRollback(ctx, &err) + + _, err = s.dal.GetOnlyIdentityKey(ctx) + if err != nil { + if !errors.Is(err, libdal.ErrNotFound) { + return fmt.Errorf("failed to get only identity key: %w", err) + } + + logger.Debugf("Generating identity key") + err = s.generateAndSaveIdentity(ctx, tx) + if err != nil { + return fmt.Errorf("failed to generate and save identity: %w", err) + } + } else { + logger.Debugf("Identity key already exists") + } + + return nil +} + +func (s Service) generateAndSaveIdentity(ctx context.Context, tx *dal.DAL) error { + pair, err := internalidentity.GenerateTinkKeyPair() + if err != nil { + return fmt.Errorf("failed to generate key pair: %w", err) + } + + signer, err := pair.Signer() + if err != nil { + return fmt.Errorf("failed to create signer: %w", err) + } + + signed, err := signer.Sign([]byte(verificationText)) + if err != nil { + return fmt.Errorf("failed to sign verification: %w", err) + } + + verifier, err := pair.Verifier() + if err != nil { + return fmt.Errorf("failed to create verifier: %w", err) + } + + // For total sanity, verify immediately + if err = verifier.Verify(*signed); err != nil { + return fmt.Errorf("failed to verify signed verification: %w", err) + } + + // TODO: Make this support different encryptors. + // Might need to refactor internal/identity to access controller encryption types. + // It's a bit tricky because you can't take out the private key from the keyset without + // encrypting it with the AEAD. + handle := pair.Handle() + buf := new(bytes.Buffer) + writer := keyset.NewBinaryWriter(buf) + aead, err := s.encryption.AEAD() + if err != nil { + return fmt.Errorf("failed to get AEAD: %w", err) + } + if err := handle.Write(writer, aead); err != nil { + return fmt.Errorf("failed to write keyset: %w", err) + } + encryptedIdentityColumn := api.EncryptedIdentityColumn{} + encryptedIdentityColumn.Set(buf.Bytes()) + + public, err := pair.Public() + if err != nil { + return fmt.Errorf("failed to get public key: %w", err) + } + + encryptedIdentity := &dal.EncryptedIdentity{ + Private: encryptedIdentityColumn, + Public: public, + VerifySignature: signed.Signature, + } + if err := tx.CreateOnlyIdentityKey(ctx, *encryptedIdentity); err != nil { + return fmt.Errorf("failed to create only identity key: %w", err) + } + + return nil +} diff --git a/backend/controller/identity/identity_test.go b/backend/controller/identity/identity_test.go new file mode 100644 index 0000000000..d75901ed59 --- /dev/null +++ b/backend/controller/identity/identity_test.go @@ -0,0 +1,35 @@ +package identity + +import ( + "context" + "testing" + + "github.com/alecthomas/assert/v2" + "github.com/alecthomas/types/optional" + + "github.com/TBD54566975/ftl/backend/controller/encryption" + "github.com/TBD54566975/ftl/backend/controller/sql/sqltest" + "github.com/TBD54566975/ftl/internal/log" +) + +func TestIdentity(t *testing.T) { + ctx := log.ContextWithNewDefaultLogger(context.Background()) + ctx, cancel := context.WithCancel(ctx) + t.Cleanup(cancel) + + conn := sqltest.OpenForTesting(ctx, t) + + uri := "fake-kms://CK6YwYkBElQKSAowdHlwZS5nb29nbGVhcGlzLmNvbS9nb29nbGUuY3J5cHRvLnRpbmsuQWVzR2NtS2V5EhIaEJy4TIQgfCuwxA3ZZgChp_wYARABGK6YwYkBIAE" + encryption, err := encryption.New(ctx, conn, encryption.NewBuilder().WithKMSURI(optional.Some(uri))) + assert.NoError(t, err) + + service, err := New(ctx, encryption, conn) + assert.NoError(t, err) + signedData, err := service.Sign([]byte("test")) + assert.NoError(t, err) + + service, err = New(ctx, encryption, conn) + assert.NoError(t, err) + err = service.Verify(*signedData) + assert.NoError(t, err) +} diff --git a/backend/controller/sql/schema/20240919001309_create_identity_keys_table.sql b/backend/controller/sql/schema/20240919001309_create_identity_keys_table.sql new file mode 100644 index 0000000000..36da29bf4e --- /dev/null +++ b/backend/controller/sql/schema/20240919001309_create_identity_keys_table.sql @@ -0,0 +1,13 @@ +-- migrate:up + +CREATE DOMAIN encrypted_identity AS BYTEA; + +CREATE TABLE identity_keys ( + id BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + private encrypted_identity NOT NULL, + public BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'utc'), + verify_signature BYTEA NOT NULL +); + +-- migrate:down diff --git a/deployment/base/db-migrate/kustomization.yml b/deployment/base/db-migrate/kustomization.yml index 2e713d205e..25b3cb3f5b 100644 --- a/deployment/base/db-migrate/kustomization.yml +++ b/deployment/base/db-migrate/kustomization.yml @@ -28,3 +28,4 @@ configMapGenerator: - ./schema/20240916190209_rename_controller_to_controllers.sql - ./schema/20240917015216_add_ingress_event_type.sql - ./schema/20240917062716_change_deployments_index.sql + - ./schema/20240919001309_create_identity_keys_table.sql diff --git a/internal/identity/api.go b/internal/identity/api.go new file mode 100644 index 0000000000..5ae2405965 --- /dev/null +++ b/internal/identity/api.go @@ -0,0 +1,21 @@ +package identity + +type KeyPair interface { + Signer() (Signer, error) + Verifier() (Verifier, error) + Public() ([]byte, error) +} + +type Signer interface { + Sign(data []byte) (*SignedData, error) + Public() ([]byte, error) +} + +type SignedData struct { + Data []byte + Signature []byte +} + +type Verifier interface { + Verify(signedData SignedData) error +} diff --git a/internal/identity/identity_test.go b/internal/identity/identity_test.go new file mode 100644 index 0000000000..93b663d80e --- /dev/null +++ b/internal/identity/identity_test.go @@ -0,0 +1,30 @@ +package identity + +import ( + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestBasics(t *testing.T) { + keyPair, err := GenerateTinkKeyPair() + assert.NoError(t, err) + + signer, err := keyPair.Signer() + assert.NoError(t, err) + + data := []byte("hunter2") + signedData, err := signer.Sign(data) + assert.NoError(t, err) + + verifier, err := keyPair.Verifier() + assert.NoError(t, err) + + err = verifier.Verify(*signedData) + assert.NoError(t, err) + + // Now fail it just for sanity + signedData.Signature[0] = ^signedData.Signature[0] + err = verifier.Verify(*signedData) + assert.EqualError(t, err, "failed to verify signature: verifier_factory: invalid signature") +} diff --git a/internal/identity/tinksign.go b/internal/identity/tinksign.go new file mode 100644 index 0000000000..5263e53918 --- /dev/null +++ b/internal/identity/tinksign.go @@ -0,0 +1,119 @@ +package identity + +import ( + "bytes" + "fmt" + + "github.com/tink-crypto/tink-go/v2/keyset" + "github.com/tink-crypto/tink-go/v2/signature" + "github.com/tink-crypto/tink-go/v2/tink" +) + +var _ Signer = &TinkSigner{} + +type TinkSigner struct { + signer tink.Signer +} + +func (k TinkSigner) Sign(data []byte) (*SignedData, error) { + bytes, err := k.signer.Sign(data) + if err != nil { + return nil, fmt.Errorf("failed to sign data: %w", err) + } + + return &SignedData{ + Data: data, + Signature: bytes, + }, nil +} + +func (k TinkSigner) Public() ([]byte, error) { + panic("implement me") +} + +var _ Verifier = &TinkVerifier{} + +type TinkVerifier struct { + verifier tink.Verifier +} + +func (k TinkVerifier) Verify(signedData SignedData) error { + err := k.verifier.Verify(signedData.Signature, signedData.Data) + if err != nil { + return fmt.Errorf("failed to verify signature: %w", err) + } + + return nil +} + +var _ KeyPair = &TinkKeyPair{} + +type TinkKeyPair struct { + keysetHandle keyset.Handle +} + +func (t TinkKeyPair) Signer() (Signer, error) { + signer, err := signature.NewSigner(&t.keysetHandle) + if err != nil { + return nil, fmt.Errorf("failed to create signer: %w", err) + } + + return TinkSigner{ + signer: signer, + }, nil +} + +func (t TinkKeyPair) Verifier() (Verifier, error) { + public, err := t.keysetHandle.Public() + if err != nil { + return nil, fmt.Errorf("failed to get public keyset from keyset handle: %w", err) + } + + verifier, err := signature.NewVerifier(public) + if err != nil { + return nil, fmt.Errorf("failed to create verifier: %w", err) + } + + return &TinkVerifier{ + verifier: verifier, + }, nil +} + +func (t TinkKeyPair) Public() ([]byte, error) { + publicHandle, err := t.keysetHandle.Public() + if err != nil { + return nil, fmt.Errorf("failed to get public keyset from keyset handle: %w", err) + } + + buf := new(bytes.Buffer) + writer := keyset.NewBinaryWriter(buf) + if err := publicHandle.WriteWithNoSecrets(writer); err != nil { + return nil, fmt.Errorf("failed to write public keyset to buffer: %w", err) + } + + return buf.Bytes(), nil +} + +// Handle returns the keyset handle. +// TODO: Remove this. We don't want to expose the private key. +func (t TinkKeyPair) Handle() keyset.Handle { + return t.keysetHandle +} + +// GenerateTinkKeyPair creates a new key pair using Tink's ED25519 key template +func GenerateTinkKeyPair() (*TinkKeyPair, error) { + keysetHandle, err := keyset.NewHandle(signature.ED25519KeyTemplate()) + if err != nil { + return nil, fmt.Errorf("failed to create keyset handle: %w", err) + } + + return &TinkKeyPair{ + keysetHandle: *keysetHandle, + }, nil +} + +func NewTinkKeyPair(handle keyset.Handle) *TinkKeyPair { + return &TinkKeyPair{ + keysetHandle: handle, + } +} diff --git a/sqlc.yaml b/sqlc.yaml index fc98108e42..5753242230 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -67,6 +67,8 @@ sql: nullable: true go_type: type: "optional.Option[model.CronJobKey]" + - db_type: "encrypted_identity" + go_type: "github.com/TBD54566975/ftl/backend/controller/encryption/api.EncryptedIdentityColumn" - db_type: "encrypted_async" go_type: "github.com/TBD54566975/ftl/backend/controller/encryption/api.EncryptedAsyncColumn" - db_type: "encrypted_async" @@ -173,6 +175,13 @@ sql: go: <<: *gengo out: "backend/controller/leases/dbleaser/internal/sql" + - <<: *daldir + queries: + - backend/controller/identity/dal/internal/sql/queries.sql + gen: + go: + <<: *gengo + out: "backend/controller/identity/dal/internal/sql" - <<: *daldir queries: - backend/controller/encryption/internal/sql/queries.sql