From 6afb42e334077ffcdbd5a9dd2a73f1a1bb9facaf Mon Sep 17 00:00:00 2001 From: Irena Rindos Date: Mon, 23 Jun 2025 14:59:20 -0400 Subject: [PATCH] feat(target): add proxy certificate repos and protos (#1577) --- .../99903/01_alias_target_constraint.up.sql | 10 + ...t_credential_injection_certificates.up.sql | 132 ++++++ .../storage/target/store/v1/target.proto | 96 +++++ internal/session/session.go | 59 +++ internal/session/session_test.go | 60 +++ internal/target/options.go | 17 + internal/target/options_test.go | 14 + internal/target/store/target.pb.go | 389 ++++++++++++++++- internal/target/target.go | 8 + internal/target/target_certificate.go | 311 ++++++++++++++ internal/target/target_certificate_test.go | 402 ++++++++++++++++++ internal/target/targettest/target.go | 6 + internal/target/tcp/target.go | 9 +- 13 files changed, 1489 insertions(+), 24 deletions(-) create mode 100644 internal/db/schema/migrations/oss/postgres/99903/01_alias_target_constraint.up.sql create mode 100644 internal/db/schema/migrations/oss/postgres/99903/02_target_credential_injection_certificates.up.sql create mode 100644 internal/target/target_certificate.go create mode 100644 internal/target/target_certificate_test.go diff --git a/internal/db/schema/migrations/oss/postgres/99903/01_alias_target_constraint.up.sql b/internal/db/schema/migrations/oss/postgres/99903/01_alias_target_constraint.up.sql new file mode 100644 index 0000000000..004867a50a --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/99903/01_alias_target_constraint.up.sql @@ -0,0 +1,10 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + +-- Updates 85/04_alias_target.up.sql to add a unique constraint on the public id and destination id +alter table alias_target + add constraint alias_target_destination_uq unique (public_id, destination_id); + +commit; \ No newline at end of file diff --git a/internal/db/schema/migrations/oss/postgres/99903/02_target_credential_injection_certificates.up.sql b/internal/db/schema/migrations/oss/postgres/99903/02_target_credential_injection_certificates.up.sql new file mode 100644 index 0000000000..06aed125e9 --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/99903/02_target_credential_injection_certificates.up.sql @@ -0,0 +1,132 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + +create table target_proxy_certificate( + public_id wt_public_id primary key, + public_key bytea, + constraint public_key_must_not_be_empty + check(length(public_key) > 0), + private_key_encrypted bytea not null -- encrypted PEM encoded priv key + constraint private_key_must_not_be_empty + check(length(private_key_encrypted) > 0), + key_id kms_private_id not null -- key used to encrypt entries + constraint kms_data_key_version_fkey + references kms_data_key_version (private_id) + on delete restrict + on update cascade, + target_id wt_public_id not null + constraint target_proxy_fkey + references target (public_id) + on delete cascade + on update cascade, + certificate bytea not null + constraint certificate_must_not_be_empty + check(length(certificate) > 0), + not_valid_after wt_timestamp not null, + version wt_version, + create_time wt_timestamp, + update_time wt_timestamp +); + +create trigger immutable_columns before update on target_proxy_certificate + for each row execute procedure immutable_columns('public_id', 'target_id', 'create_time'); + +create trigger update_version_column after update on target_proxy_certificate + for each row execute procedure update_version_column(); + +create trigger update_time_column before update on target_proxy_certificate + for each row execute procedure update_time_column(); + +create trigger default_create_time_column before insert on target_proxy_certificate + for each row execute procedure default_create_time(); + +comment on table target_proxy_certificate is + 'target_proxy_certificate is a table where each row represents a proxy certificate for a target.'; + +create table target_alias_proxy_certificate( + public_id wt_public_id primary key, + public_key bytea, + constraint public_key_must_not_be_empty + check(length(public_key) > 0), + private_key_encrypted bytea not null -- encrypted PEM encoded priv key + constraint private_key_must_not_be_empty + check(length(private_key_encrypted) > 0), + key_id kms_private_id not null -- key used to encrypt entries + constraint kms_data_key_version_fkey + references kms_data_key_version (private_id) + on delete restrict + on update cascade, + target_id wt_public_id not null + constraint target_proxy_fkey + references target (public_id) + on delete cascade + on update cascade, + alias_id wt_public_id not null, + certificate bytea not null + constraint certificate_must_not_be_empty + check(length(certificate) > 0), + not_valid_after wt_timestamp not null, + version wt_version, + create_time wt_timestamp, + update_time wt_timestamp, + constraint alias_target_fkey + foreign key (alias_id, target_id) + references alias_target (public_id, destination_id) + on delete cascade + on update cascade +); + +create trigger immutable_columns before update on target_alias_proxy_certificate + for each row execute procedure immutable_columns('public_id', 'target_id', 'create_time'); + +create trigger update_version_column after update on target_alias_proxy_certificate + for each row execute procedure update_version_column(); + +create trigger update_time_column before update on target_alias_proxy_certificate + for each row execute procedure update_time_column(); + +create trigger default_create_time_column before insert on target_alias_proxy_certificate + for each row execute procedure default_create_time(); + +comment on table target_alias_proxy_certificate is + 'target_alias_proxy_certificate is a table where each row represents a proxy certificate for a target for use with an alias.'; + +-- To account for users updating target aliases to change either the target id, host id, or value of an alias, +-- on update to alias_target, entries in target_alias_certificate that +-- match the old target_id and alias_id will be deleted. +create function remove_target_alias_certificates_for_updated_alias() returns trigger +as $$ +begin + -- If the destination_id, host_id, and value of the alias have not changed, do nothing. + if old.destination_id is distinct from new.destination_id or + old.host_id is distinct from new.host_id or + old.value is distinct from new.value then + delete + from target_alias_proxy_certificate + where target_id = old.destination_id + and alias_id = old.public_id; + end if; + return new; +end; +$$ language plpgsql; + +create trigger remove_target_alias_certificates_for_updated_alias before update of destination_id, host_id, value on alias_target + for each row execute procedure remove_target_alias_certificates_for_updated_alias(); + +create table session_proxy_certificate( + session_id wt_public_id not null + constraint session_fkey + references session (public_id) + on delete cascade + on update cascade, + certificate bytea not null + constraint certificate_must_not_be_empty + check(length(certificate) > 0) +); + +comment on table session_proxy_certificate is + 'session_proxy_certificate is a table where each row maps a certificate to a session id.'; + +commit; \ No newline at end of file diff --git a/internal/proto/controller/storage/target/store/v1/target.proto b/internal/proto/controller/storage/target/store/v1/target.proto index 295af3eba2..8a3a3654a0 100644 --- a/internal/proto/controller/storage/target/store/v1/target.proto +++ b/internal/proto/controller/storage/target/store/v1/target.proto @@ -175,3 +175,99 @@ message CredentialSourceView { // @inject_tag: `gorm:"not_null"` string type = 20; } + +message TargetProxyCertificate { + // public_id is used to identify the target proxy key + // @inject_tag: `gorm:"primary_key"` + string public_id = 10; + + // target_id is used to access the proxy target this key is for + // @inject_tag: `gorm:"not_null"` + string target_id = 20; + + // public_key is the public key associated with this certificate + // @inject_tag: `gorm:"not_null"` + bytes public_key = 30; + + // private_key is the plaintext key. this is not stored in the db + // @inject_tag: `gorm:"-" wrapping:"pt,private_key"` + bytes private_key = 40; + + // private_key_encrypted is the encrypted PEM encoded private key + // @inject_tag: `gorm:"not_null" wrapping:"ct,private_key"` + bytes private_key_encrypted = 50; + + // key_id is the kms private id used to encrypt this entry's private key + // @inject_tag: `gorm:"not_null"` + string key_id = 60; + + // certificate is the PEM encoded certificate + // @inject_tag: `gorm:"not_null"` + bytes certificate = 70; + + // not_valid_after is the timestamp at which this certificate's validity period ends + // @inject_tag: `gorm:"not_null"` + timestamp.v1.Timestamp not_valid_after = 80; + + // version allows optimistic locking during modification + // @inject_tag: `gorm:"default:null"` + uint32 version = 90; + + // create_time from the RDBMS + // @inject_tag: `gorm:"default:current_timestamp"` + timestamp.v1.Timestamp create_time = 100; + + // update_time from the RDBMS + // @inject_tag: `gorm:"default:current_timestamp"` + timestamp.v1.Timestamp update_time = 110; +} + +message TargetAliasProxyCertificate { + // public_id is used to identify the target proxy key + // @inject_tag: `gorm:"primary_key"` + string public_id = 10; + + // target_id is used to access the proxy target this key is for + // @inject_tag: `gorm:"not_null"` + string target_id = 20; + + // public_key is the public key associated with this certificate + // @inject_tag: `gorm:"not_null"` + bytes public_key = 30; + + // private_key is the plaintext key. this is not stored in the db + // @inject_tag: `gorm:"-" wrapping:"pt,private_key"` + bytes private_key = 40; + + // private_key_encrypted is the encrypted PEM encoded private key + // @inject_tag: `gorm:"not_null" wrapping:"ct,private_key"` + bytes private_key_encrypted = 50; + + // key_id is the kms private id used to encrypt this entry's private key + // @inject_tag: `gorm:"not_null"` + string key_id = 60; + + // alias_id is the public id of the alias target + // @inject_tag: `gorm:"not_null"` + string alias_id = 70; + + // certificate is the PEM encoded certificate + // @inject_tag: `gorm:"not_null"` + bytes certificate = 80; + + // not_valid_after is the timestamp at which this certificate's validity period ends + // @inject_tag: `gorm:"not_null"` + timestamp.v1.Timestamp not_valid_after = 90; + + // version allows optimistic locking during modification + // @inject_tag: `gorm:"default:null"` + uint32 version = 100; + + // create_time from the RDBMS + // @inject_tag: `gorm:"default:current_timestamp"` + timestamp.v1.Timestamp create_time = 110; + + // update_time from the RDBMS + // @inject_tag: `gorm:"default:current_timestamp"` + timestamp.v1.Timestamp update_time = 120; +} diff --git a/internal/session/session.go b/internal/session/session.go index 152d71b13d..b1987daef9 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -608,3 +608,62 @@ type deletedSession struct { func (s *deletedSession) TableName() string { return "session_deleted" } + +// ProxyCertificate represents a session id to proxy certificate mapping +// It's stored during session authorization and used at connection authorization +// time to lookup the proxy certificate, if applicable +type ProxyCertificate struct { + SessionId string `gorm:"not_null"` + Certificate []byte `gorm:"not_null"` +} + +// NewProxyCertificate creates a new in memory ProxyCertificate +func NewProxyCertificate(ctx context.Context, sessionId string, certificate []byte) (*ProxyCertificate, error) { + const op = "session.NewProxyCertificate" + switch { + case sessionId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing session id") + case len(certificate) == 0: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing certificate") + } + + return &ProxyCertificate{ + SessionId: sessionId, + Certificate: certificate, + }, nil +} + +func alloProxyCertificate() *ProxyCertificate { + return &ProxyCertificate{} +} + +// Clone creates a clone of the ProxyCertificate +func (t *ProxyCertificate) Clone() *ProxyCertificate { + spc := &ProxyCertificate{ + SessionId: t.SessionId, + } + if t.Certificate != nil { + spc.Certificate = make([]byte, len(t.Certificate)) + copy(spc.Certificate, t.Certificate) + } + + return spc +} + +// VetForWrite implements db.VetForWrite() interface and validates the session proxy certificate +func (t *ProxyCertificate) VetForWrite(ctx context.Context, _ db.Reader, opType db.OpType, _ ...db.Option) error { + const op = "session.(ProxyCertificate).VetForWrite" + switch { + case t.SessionId == "": + return errors.New(ctx, errors.InvalidParameter, op, "missing session id") + case len(t.Certificate) == 0: + return errors.New(ctx, errors.InvalidParameter, op, "missing certificate") + } + + return nil +} + +// TableName returns the table name. +func (t *ProxyCertificate) TableName() string { + return "session_proxy_certificate" +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 3dcd958e07..e511fec369 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -433,3 +433,63 @@ func Test_newCert(t *testing.T) { assert.Equal(t, parsedCert.PublicKey.(crypto.PublicKey), ed25519.PrivateKey(key).Public()) }) } + +func TestProxyCertificate(t *testing.T) { + t.Parallel() + ctx := context.Background() + + sId := "test-session-id" + cert := make([]byte, 20) + if _, err := rand.Read(cert); err != nil { + require.NotNil(t, err) + } + + tests := []struct { + name string + sessionId string + certificate []byte + expected *ProxyCertificate + wantErr bool + wantErrContains string + }{ + { + name: "valid-target-cert", + sessionId: sId, + certificate: cert, + expected: &ProxyCertificate{ + SessionId: sId, + Certificate: cert, + }, + }, + { + name: "missing-session-id", + certificate: cert, + wantErr: true, + wantErrContains: "missing session id", + }, + { + name: "missing-cert", + sessionId: sId, + wantErr: true, + wantErrContains: "missing certificate", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + gotCert, err := NewProxyCertificate(ctx, tt.sessionId, tt.certificate) + + if tt.wantErr { + assert.Error(err) + return + } + require.NoError(err) + require.NotNil(gotCert) + assert.Equal(tt.expected.SessionId, gotCert.SessionId) + assert.Equal(tt.expected.Certificate, gotCert.Certificate) + }) + } +} diff --git a/internal/target/options.go b/internal/target/options.go index fc7a62e50e..adde381eb1 100644 --- a/internal/target/options.go +++ b/internal/target/options.go @@ -56,6 +56,8 @@ type options struct { WithNetResolver intglobals.NetIpResolver WithStartPageAfterItem pagination.Item withAliases []*talias.Alias + withAlias *talias.Alias + withTargetId string } func getDefaultOptions() options { @@ -83,6 +85,7 @@ func getDefaultOptions() options { WithIngressWorkerFilter: "", WithAddress: "", WithNetResolver: net.DefaultResolver, + withTargetId: "", } } @@ -284,3 +287,17 @@ func WithAliases(in []*talias.Alias) Option { o.withAliases = in } } + +// WithAlias provides an option to provide a single alias. +func WithAlias(in *talias.Alias) Option { + return func(o *options) { + o.withAlias = in + } +} + +// WithTargetId provides an option to provide a target ID. +func WithTargetId(in string) Option { + return func(o *options) { + o.withTargetId = in + } +} diff --git a/internal/target/options_test.go b/internal/target/options_test.go index 3e27f270a5..b2994b43f4 100644 --- a/internal/target/options_test.go +++ b/internal/target/options_test.go @@ -268,4 +268,18 @@ func Test_GetOpts(t *testing.T) { opts := GetOpts(WithAliases(input)) assert.Equal(input, opts.withAliases) }) + t.Run("WithAlias", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + al, err := talias.NewAlias(context.Background(), "global", "test") + require.NoError(err) + opts := GetOpts(WithAlias(al)) + assert.Equal(al, opts.withAlias) + }) + t.Run("WithTargetId", func(t *testing.T) { + assert := assert.New(t) + opts := GetOpts(WithTargetId("testId")) + testOpts := getDefaultOptions() + testOpts.withTargetId = "testId" + assert.Equal(opts, testOpts) + }) } diff --git a/internal/target/store/target.pb.go b/internal/target/store/target.pb.go index 935ebfda4b..98a1b34a3b 100644 --- a/internal/target/store/target.pb.go +++ b/internal/target/store/target.pb.go @@ -649,6 +649,308 @@ func (x *CredentialSourceView) GetType() string { return "" } +type TargetProxyCertificate struct { + state protoimpl.MessageState `protogen:"open.v1"` + // public_id is used to identify the target proxy key + // @inject_tag: `gorm:"primary_key"` + PublicId string `protobuf:"bytes,10,opt,name=public_id,json=publicId,proto3" json:"public_id,omitempty" gorm:"primary_key"` + // target_id is used to access the proxy target this key is for + // @inject_tag: `gorm:"not_null"` + TargetId string `protobuf:"bytes,20,opt,name=target_id,json=targetId,proto3" json:"target_id,omitempty" gorm:"not_null"` + // public_key is the public key associated with this certificate + // @inject_tag: `gorm:"not_null"` + PublicKey []byte `protobuf:"bytes,30,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty" gorm:"not_null"` + // private_key is the plaintext key. this is not stored in the db + // @inject_tag: `gorm:"-" wrapping:"pt,private_key"` + PrivateKey []byte `protobuf:"bytes,40,opt,name=private_key,json=privateKey,proto3" json:"private_key,omitempty" gorm:"-" wrapping:"pt,private_key"` + // private_key_encrypted is the encrypted PEM encoded private key + // @inject_tag: `gorm:"not_null" wrapping:"ct,private_key"` + PrivateKeyEncrypted []byte `protobuf:"bytes,50,opt,name=private_key_encrypted,json=privateKeyEncrypted,proto3" json:"private_key_encrypted,omitempty" gorm:"not_null" wrapping:"ct,private_key"` + // key_id is the kms private id used to encrypt this entry's private key + // @inject_tag: `gorm:"not_null"` + KeyId string `protobuf:"bytes,60,opt,name=key_id,json=keyId,proto3" json:"key_id,omitempty" gorm:"not_null"` + // certificate is the PEM encoded certificate + // @inject_tag: `gorm:"not_null"` + Certificate []byte `protobuf:"bytes,70,opt,name=certificate,proto3" json:"certificate,omitempty" gorm:"not_null"` + // not_valid_after is the timestamp at which this certificate's validity period ends + // @inject_tag: `gorm:"not_null"` + NotValidAfter *timestamp.Timestamp `protobuf:"bytes,80,opt,name=not_valid_after,json=notValidAfter,proto3" json:"not_valid_after,omitempty" gorm:"not_null"` + // version allows optimistic locking during modification + // @inject_tag: `gorm:"default:null"` + Version uint32 `protobuf:"varint,90,opt,name=version,proto3" json:"version,omitempty" gorm:"default:null"` + // create_time from the RDBMS + // @inject_tag: `gorm:"default:current_timestamp"` + CreateTime *timestamp.Timestamp `protobuf:"bytes,100,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty" gorm:"default:current_timestamp"` + // update_time from the RDBMS + // @inject_tag: `gorm:"default:current_timestamp"` + UpdateTime *timestamp.Timestamp `protobuf:"bytes,110,opt,name=update_time,json=updateTime,proto3" json:"update_time,omitempty" gorm:"default:current_timestamp"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TargetProxyCertificate) Reset() { + *x = TargetProxyCertificate{} + mi := &file_controller_storage_target_store_v1_target_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TargetProxyCertificate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TargetProxyCertificate) ProtoMessage() {} + +func (x *TargetProxyCertificate) ProtoReflect() protoreflect.Message { + mi := &file_controller_storage_target_store_v1_target_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TargetProxyCertificate.ProtoReflect.Descriptor instead. +func (*TargetProxyCertificate) Descriptor() ([]byte, []int) { + return file_controller_storage_target_store_v1_target_proto_rawDescGZIP(), []int{7} +} + +func (x *TargetProxyCertificate) GetPublicId() string { + if x != nil { + return x.PublicId + } + return "" +} + +func (x *TargetProxyCertificate) GetTargetId() string { + if x != nil { + return x.TargetId + } + return "" +} + +func (x *TargetProxyCertificate) GetPublicKey() []byte { + if x != nil { + return x.PublicKey + } + return nil +} + +func (x *TargetProxyCertificate) GetPrivateKey() []byte { + if x != nil { + return x.PrivateKey + } + return nil +} + +func (x *TargetProxyCertificate) GetPrivateKeyEncrypted() []byte { + if x != nil { + return x.PrivateKeyEncrypted + } + return nil +} + +func (x *TargetProxyCertificate) GetKeyId() string { + if x != nil { + return x.KeyId + } + return "" +} + +func (x *TargetProxyCertificate) GetCertificate() []byte { + if x != nil { + return x.Certificate + } + return nil +} + +func (x *TargetProxyCertificate) GetNotValidAfter() *timestamp.Timestamp { + if x != nil { + return x.NotValidAfter + } + return nil +} + +func (x *TargetProxyCertificate) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *TargetProxyCertificate) GetCreateTime() *timestamp.Timestamp { + if x != nil { + return x.CreateTime + } + return nil +} + +func (x *TargetProxyCertificate) GetUpdateTime() *timestamp.Timestamp { + if x != nil { + return x.UpdateTime + } + return nil +} + +type TargetAliasProxyCertificate struct { + state protoimpl.MessageState `protogen:"open.v1"` + // public_id is used to identify the target proxy key + // @inject_tag: `gorm:"primary_key"` + PublicId string `protobuf:"bytes,10,opt,name=public_id,json=publicId,proto3" json:"public_id,omitempty" gorm:"primary_key"` + // target_id is used to access the proxy target this key is for + // @inject_tag: `gorm:"not_null"` + TargetId string `protobuf:"bytes,20,opt,name=target_id,json=targetId,proto3" json:"target_id,omitempty" gorm:"not_null"` + // public_key is the public key associated with this certificate + // @inject_tag: `gorm:"not_null"` + PublicKey []byte `protobuf:"bytes,30,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty" gorm:"not_null"` + // private_key is the plaintext key. this is not stored in the db + // @inject_tag: `gorm:"-" wrapping:"pt,private_key"` + PrivateKey []byte `protobuf:"bytes,40,opt,name=private_key,json=privateKey,proto3" json:"private_key,omitempty" gorm:"-" wrapping:"pt,private_key"` + // private_key_encrypted is the encrypted PEM encoded private key + // @inject_tag: `gorm:"not_null" wrapping:"ct,private_key"` + PrivateKeyEncrypted []byte `protobuf:"bytes,50,opt,name=private_key_encrypted,json=privateKeyEncrypted,proto3" json:"private_key_encrypted,omitempty" gorm:"not_null" wrapping:"ct,private_key"` + // key_id is the kms private id used to encrypt this entry's private key + // @inject_tag: `gorm:"not_null"` + KeyId string `protobuf:"bytes,60,opt,name=key_id,json=keyId,proto3" json:"key_id,omitempty" gorm:"not_null"` + // alias_id is the public id of the alias target + // @inject_tag: `gorm:"not_null"` + AliasId string `protobuf:"bytes,70,opt,name=alias_id,json=aliasId,proto3" json:"alias_id,omitempty" gorm:"not_null"` + // certificate is the PEM encoded certificate + // @inject_tag: `gorm:"not_null"` + Certificate []byte `protobuf:"bytes,80,opt,name=certificate,proto3" json:"certificate,omitempty" gorm:"not_null"` + // not_valid_after is the timestamp at which this certificate's validity period ends + // @inject_tag: `gorm:"not_null"` + NotValidAfter *timestamp.Timestamp `protobuf:"bytes,90,opt,name=not_valid_after,json=notValidAfter,proto3" json:"not_valid_after,omitempty" gorm:"not_null"` + // version allows optimistic locking during modification + // @inject_tag: `gorm:"default:null"` + Version uint32 `protobuf:"varint,100,opt,name=version,proto3" json:"version,omitempty" gorm:"default:null"` + // create_time from the RDBMS + // @inject_tag: `gorm:"default:current_timestamp"` + CreateTime *timestamp.Timestamp `protobuf:"bytes,110,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty" gorm:"default:current_timestamp"` + // update_time from the RDBMS + // @inject_tag: `gorm:"default:current_timestamp"` + UpdateTime *timestamp.Timestamp `protobuf:"bytes,120,opt,name=update_time,json=updateTime,proto3" json:"update_time,omitempty" gorm:"default:current_timestamp"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TargetAliasProxyCertificate) Reset() { + *x = TargetAliasProxyCertificate{} + mi := &file_controller_storage_target_store_v1_target_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TargetAliasProxyCertificate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TargetAliasProxyCertificate) ProtoMessage() {} + +func (x *TargetAliasProxyCertificate) ProtoReflect() protoreflect.Message { + mi := &file_controller_storage_target_store_v1_target_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TargetAliasProxyCertificate.ProtoReflect.Descriptor instead. +func (*TargetAliasProxyCertificate) Descriptor() ([]byte, []int) { + return file_controller_storage_target_store_v1_target_proto_rawDescGZIP(), []int{8} +} + +func (x *TargetAliasProxyCertificate) GetPublicId() string { + if x != nil { + return x.PublicId + } + return "" +} + +func (x *TargetAliasProxyCertificate) GetTargetId() string { + if x != nil { + return x.TargetId + } + return "" +} + +func (x *TargetAliasProxyCertificate) GetPublicKey() []byte { + if x != nil { + return x.PublicKey + } + return nil +} + +func (x *TargetAliasProxyCertificate) GetPrivateKey() []byte { + if x != nil { + return x.PrivateKey + } + return nil +} + +func (x *TargetAliasProxyCertificate) GetPrivateKeyEncrypted() []byte { + if x != nil { + return x.PrivateKeyEncrypted + } + return nil +} + +func (x *TargetAliasProxyCertificate) GetKeyId() string { + if x != nil { + return x.KeyId + } + return "" +} + +func (x *TargetAliasProxyCertificate) GetAliasId() string { + if x != nil { + return x.AliasId + } + return "" +} + +func (x *TargetAliasProxyCertificate) GetCertificate() []byte { + if x != nil { + return x.Certificate + } + return nil +} + +func (x *TargetAliasProxyCertificate) GetNotValidAfter() *timestamp.Timestamp { + if x != nil { + return x.NotValidAfter + } + return nil +} + +func (x *TargetAliasProxyCertificate) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *TargetAliasProxyCertificate) GetCreateTime() *timestamp.Timestamp { + if x != nil { + return x.CreateTime + } + return nil +} + +func (x *TargetAliasProxyCertificate) GetUpdateTime() *timestamp.Timestamp { + if x != nil { + return x.UpdateTime + } + return nil +} + var File_controller_storage_target_store_v1_target_proto protoreflect.FileDescriptor const file_controller_storage_target_store_v1_target_proto_rawDesc = "" + @@ -713,7 +1015,42 @@ const file_controller_storage_target_store_v1_target_proto_rawDesc = "" + "\x14CredentialSourceView\x12\x1b\n" + "\tpublic_id\x18\n" + " \x01(\tR\bpublicId\x12\x12\n" + - "\x04type\x18\x14 \x01(\tR\x04typeB;Z9github.com/hashicorp/boundary/internal/target/store;storeb\x06proto3" + "\x04type\x18\x14 \x01(\tR\x04type\"\x87\x04\n" + + "\x16TargetProxyCertificate\x12\x1b\n" + + "\tpublic_id\x18\n" + + " \x01(\tR\bpublicId\x12\x1b\n" + + "\ttarget_id\x18\x14 \x01(\tR\btargetId\x12\x1d\n" + + "\n" + + "public_key\x18\x1e \x01(\fR\tpublicKey\x12\x1f\n" + + "\vprivate_key\x18( \x01(\fR\n" + + "privateKey\x122\n" + + "\x15private_key_encrypted\x182 \x01(\fR\x13privateKeyEncrypted\x12\x15\n" + + "\x06key_id\x18< \x01(\tR\x05keyId\x12 \n" + + "\vcertificate\x18F \x01(\fR\vcertificate\x12R\n" + + "\x0fnot_valid_after\x18P \x01(\v2*.controller.storage.timestamp.v1.TimestampR\rnotValidAfter\x12\x18\n" + + "\aversion\x18Z \x01(\rR\aversion\x12K\n" + + "\vcreate_time\x18d \x01(\v2*.controller.storage.timestamp.v1.TimestampR\n" + + "createTime\x12K\n" + + "\vupdate_time\x18n \x01(\v2*.controller.storage.timestamp.v1.TimestampR\n" + + "updateTime\"\xa7\x04\n" + + "\x1bTargetAliasProxyCertificate\x12\x1b\n" + + "\tpublic_id\x18\n" + + " \x01(\tR\bpublicId\x12\x1b\n" + + "\ttarget_id\x18\x14 \x01(\tR\btargetId\x12\x1d\n" + + "\n" + + "public_key\x18\x1e \x01(\fR\tpublicKey\x12\x1f\n" + + "\vprivate_key\x18( \x01(\fR\n" + + "privateKey\x122\n" + + "\x15private_key_encrypted\x182 \x01(\fR\x13privateKeyEncrypted\x12\x15\n" + + "\x06key_id\x18< \x01(\tR\x05keyId\x12\x19\n" + + "\balias_id\x18F \x01(\tR\aaliasId\x12 \n" + + "\vcertificate\x18P \x01(\fR\vcertificate\x12R\n" + + "\x0fnot_valid_after\x18Z \x01(\v2*.controller.storage.timestamp.v1.TimestampR\rnotValidAfter\x12\x18\n" + + "\aversion\x18d \x01(\rR\aversion\x12K\n" + + "\vcreate_time\x18n \x01(\v2*.controller.storage.timestamp.v1.TimestampR\n" + + "createTime\x12K\n" + + "\vupdate_time\x18x \x01(\v2*.controller.storage.timestamp.v1.TimestampR\n" + + "updateTimeB;Z9github.com/hashicorp/boundary/internal/target/store;storeb\x06proto3" var ( file_controller_storage_target_store_v1_target_proto_rawDescOnce sync.Once @@ -727,29 +1064,37 @@ func file_controller_storage_target_store_v1_target_proto_rawDescGZIP() []byte { return file_controller_storage_target_store_v1_target_proto_rawDescData } -var file_controller_storage_target_store_v1_target_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_controller_storage_target_store_v1_target_proto_msgTypes = make([]protoimpl.MessageInfo, 9) var file_controller_storage_target_store_v1_target_proto_goTypes = []any{ - (*TargetView)(nil), // 0: controller.storage.target.store.v1.TargetView - (*TargetHostSet)(nil), // 1: controller.storage.target.store.v1.TargetHostSet - (*TargetAddress)(nil), // 2: controller.storage.target.store.v1.TargetAddress - (*CredentialLibrary)(nil), // 3: controller.storage.target.store.v1.CredentialLibrary - (*StaticCredential)(nil), // 4: controller.storage.target.store.v1.StaticCredential - (*CredentialSource)(nil), // 5: controller.storage.target.store.v1.CredentialSource - (*CredentialSourceView)(nil), // 6: controller.storage.target.store.v1.CredentialSourceView - (*timestamp.Timestamp)(nil), // 7: controller.storage.timestamp.v1.Timestamp + (*TargetView)(nil), // 0: controller.storage.target.store.v1.TargetView + (*TargetHostSet)(nil), // 1: controller.storage.target.store.v1.TargetHostSet + (*TargetAddress)(nil), // 2: controller.storage.target.store.v1.TargetAddress + (*CredentialLibrary)(nil), // 3: controller.storage.target.store.v1.CredentialLibrary + (*StaticCredential)(nil), // 4: controller.storage.target.store.v1.StaticCredential + (*CredentialSource)(nil), // 5: controller.storage.target.store.v1.CredentialSource + (*CredentialSourceView)(nil), // 6: controller.storage.target.store.v1.CredentialSourceView + (*TargetProxyCertificate)(nil), // 7: controller.storage.target.store.v1.TargetProxyCertificate + (*TargetAliasProxyCertificate)(nil), // 8: controller.storage.target.store.v1.TargetAliasProxyCertificate + (*timestamp.Timestamp)(nil), // 9: controller.storage.timestamp.v1.Timestamp } var file_controller_storage_target_store_v1_target_proto_depIdxs = []int32{ - 7, // 0: controller.storage.target.store.v1.TargetView.create_time:type_name -> controller.storage.timestamp.v1.Timestamp - 7, // 1: controller.storage.target.store.v1.TargetView.update_time:type_name -> controller.storage.timestamp.v1.Timestamp - 7, // 2: controller.storage.target.store.v1.TargetHostSet.create_time:type_name -> controller.storage.timestamp.v1.Timestamp - 7, // 3: controller.storage.target.store.v1.CredentialLibrary.create_time:type_name -> controller.storage.timestamp.v1.Timestamp - 7, // 4: controller.storage.target.store.v1.StaticCredential.create_time:type_name -> controller.storage.timestamp.v1.Timestamp - 7, // 5: controller.storage.target.store.v1.CredentialSource.create_time:type_name -> controller.storage.timestamp.v1.Timestamp - 6, // [6:6] is the sub-list for method output_type - 6, // [6:6] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name + 9, // 0: controller.storage.target.store.v1.TargetView.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 9, // 1: controller.storage.target.store.v1.TargetView.update_time:type_name -> controller.storage.timestamp.v1.Timestamp + 9, // 2: controller.storage.target.store.v1.TargetHostSet.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 9, // 3: controller.storage.target.store.v1.CredentialLibrary.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 9, // 4: controller.storage.target.store.v1.StaticCredential.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 9, // 5: controller.storage.target.store.v1.CredentialSource.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 9, // 6: controller.storage.target.store.v1.TargetProxyCertificate.not_valid_after:type_name -> controller.storage.timestamp.v1.Timestamp + 9, // 7: controller.storage.target.store.v1.TargetProxyCertificate.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 9, // 8: controller.storage.target.store.v1.TargetProxyCertificate.update_time:type_name -> controller.storage.timestamp.v1.Timestamp + 9, // 9: controller.storage.target.store.v1.TargetAliasProxyCertificate.not_valid_after:type_name -> controller.storage.timestamp.v1.Timestamp + 9, // 10: controller.storage.target.store.v1.TargetAliasProxyCertificate.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 9, // 11: controller.storage.target.store.v1.TargetAliasProxyCertificate.update_time:type_name -> controller.storage.timestamp.v1.Timestamp + 12, // [12:12] is the sub-list for method output_type + 12, // [12:12] is the sub-list for method input_type + 12, // [12:12] is the sub-list for extension type_name + 12, // [12:12] is the sub-list for extension extendee + 0, // [0:12] is the sub-list for field type_name } func init() { file_controller_storage_target_store_v1_target_proto_init() } @@ -763,7 +1108,7 @@ func file_controller_storage_target_store_v1_target_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_controller_storage_target_store_v1_target_proto_rawDesc), len(file_controller_storage_target_store_v1_target_proto_rawDesc)), NumEnums: 0, - NumMessages: 7, + NumMessages: 9, NumExtensions: 0, NumServices: 0, }, diff --git a/internal/target/target.go b/internal/target/target.go index 77b017f205..2f8162ee9d 100644 --- a/internal/target/target.go +++ b/internal/target/target.go @@ -18,6 +18,12 @@ import ( "github.com/hashicorp/boundary/internal/types/resource" ) +// ServerCertificate holds the PEM encoded certificate and key for a target +type ServerCertificate struct { + CertificatePem []byte + PrivateKeyPem []byte +} + // Target is a commmon interface for all target subtypes type Target interface { GetPublicId() string @@ -42,6 +48,7 @@ type Target interface { GetCredentialSources() []CredentialSource GetStorageBucketId() string GetEnableSessionRecording() bool + GetProxyServerCertificate() *ServerCertificate Clone() Target SetPublicId(context.Context, string) error SetProjectId(string) @@ -64,6 +71,7 @@ type Target interface { SetStorageBucketId(string) SetEnableSessionRecording(bool) Oplog(op oplog.OpType) oplog.Metadata + SetProxyServerCertificate(*ServerCertificate) } const ( diff --git a/internal/target/target_certificate.go b/internal/target/target_certificate.go new file mode 100644 index 0000000000..d536bc995f --- /dev/null +++ b/internal/target/target_certificate.go @@ -0,0 +1,311 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package target + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + mathrand "math/rand" + "net" + "time" + + talias "github.com/hashicorp/boundary/internal/alias/target" + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/db/timestamp" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/target/store" + "github.com/hashicorp/boundary/internal/util" + wrapping "github.com/hashicorp/go-kms-wrapping/v2" + "github.com/hashicorp/go-kms-wrapping/v2/extras/structwrapping" + "google.golang.org/protobuf/proto" +) + +func generatePrivAndPubKeys(ctx context.Context) (privKeyBytes []byte, pubKeyBytes []byte, err error) { + const op = "target.generatePrivAndPubKeys" + // Generate a private key using the P521 curve + key, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + if err != nil { + return nil, nil, errors.New(ctx, errors.InvalidParameter, op, "failed to generate ECDSA key") + } + + privKeyBytes, err = x509.MarshalECPrivateKey(key) + if err != nil { + return nil, nil, errors.Wrap(ctx, err, op, errors.WithMsg("error marshalling private key")) + } + + pubKeyBytes, err = x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + return nil, nil, errors.Wrap(ctx, err, op, errors.WithMsg("error marshalling public key")) + } + return privKeyBytes, pubKeyBytes, nil +} + +// generateTargetCert generates a self-signed certificate for the target for localhost with the localhost addresses for ipv4 and ipv6. +// Supports the option withAlias to pass an alias for use in the cert DNS names field +func generateTargetCert(ctx context.Context, privKey *ecdsa.PrivateKey, exp time.Time, opt ...Option) ([]byte, error) { + const op = "target.generateTargetCert" + switch { + case util.IsNil(privKey): + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing private key") + case exp.IsZero(): + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing expiry") + case exp.Before(time.Now()): + return nil, errors.New(ctx, errors.InvalidParameter, op, "expiration time must be in the future") + } + + opts := GetOpts(opt...) + + template := &x509.Certificate{ + ExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + }, + Subject: pkix.Name{ + CommonName: "localhost", + }, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageKeyAgreement | x509.KeyUsageCertSign, + SerialNumber: big.NewInt(mathrand.Int63()), + NotBefore: time.Now().Add(-1 * time.Minute), + NotAfter: exp, + BasicConstraintsValid: true, + DNSNames: []string{"localhost"}, + } + + if opts.withAlias != nil { + template.DNSNames = append(template.DNSNames, opts.withAlias.Value) + } + + certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &privKey.PublicKey, privKey) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithCode(errors.GenCert)) + } + return certBytes, nil +} + +// TargetProxyCertificate represents a proxy certificate for a target +type TargetProxyCertificate struct { + *store.TargetProxyCertificate + tableName string `gorm:"-"` +} + +// newTargetProxyCertificate creates a new in memory TargetProxyCertificate +// Supports the options withTargetId to set the target ID +// If this is not provided, the TargetId will need to be set before storing the certificate +func NewTargetProxyCertificate(ctx context.Context, opt ...Option) (*TargetProxyCertificate, error) { + const op = "target.NewTargetProxyCertificate" + + opts := GetOpts(opt...) + + privKey, pubKey, err := generatePrivAndPubKeys(ctx) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("error generating target proxy certificate key")) + } + + notValidAfter := time.Now().AddDate(1, 0, 0) // 1 year from now + parsedKey, err := x509.ParseECPrivateKey(privKey) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("error parsing target proxy certificate key")) + } + certBytes, err := generateTargetCert(ctx, parsedKey, notValidAfter) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("error generating target certificate")) + } + + return &TargetProxyCertificate{ + TargetProxyCertificate: &store.TargetProxyCertificate{ + PrivateKey: privKey, + PublicKey: pubKey, + Certificate: certBytes, + NotValidAfter: timestamp.New(notValidAfter), + TargetId: opts.withTargetId, + }, + }, nil +} + +// encrypt the target cert key before writing it to the db +func (t *TargetProxyCertificate) encrypt(ctx context.Context, cipher wrapping.Wrapper) error { + const op = "target.(TargetProxyCertificate).encrypt" + if cipher == nil { + return errors.New(ctx, errors.InvalidParameter, op, "missing cipher") + } + if err := structwrapping.WrapStruct(ctx, cipher, t.TargetProxyCertificate, nil); err != nil { + return errors.Wrap(ctx, err, op, errors.WithCode(errors.Encrypt)) + } + keyId, err := cipher.KeyId(ctx) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithCode(errors.Encrypt), errors.WithMsg("failed to read cipher key id")) + } + t.KeyId = keyId + return nil +} + +// decrypt the target cert key after reading it from the db +func (t *TargetProxyCertificate) decrypt(ctx context.Context, cipher wrapping.Wrapper) error { + const op = "target.(TargetProxyCertificate).decrypt" + if cipher == nil { + return errors.New(ctx, errors.InvalidParameter, op, "missing cipher") + } + if err := structwrapping.UnwrapStruct(ctx, cipher, t.TargetProxyCertificate, nil); err != nil { + return errors.Wrap(ctx, err, op, errors.WithCode(errors.Decrypt)) + } + return nil +} + +func allocTargetProxyCertificate() *TargetProxyCertificate { + return &TargetProxyCertificate{ + TargetProxyCertificate: &store.TargetProxyCertificate{}, + } +} + +// Clone creates a clone of the TargetProxyCertificate +func (t *TargetProxyCertificate) Clone() *TargetProxyCertificate { + cp := proto.Clone(t.TargetProxyCertificate) + return &TargetProxyCertificate{ + TargetProxyCertificate: cp.(*store.TargetProxyCertificate), + } +} + +// VetForWrite implements db.VetForWrite() interface and validates a target certificate +func (t *TargetProxyCertificate) VetForWrite(ctx context.Context, _ db.Reader, opType db.OpType, _ ...db.Option) error { + const op = "target.(TargetProxyCertificate).VetForWrite" + switch { + case t.PrivateKeyEncrypted == nil: + return errors.New(ctx, errors.InvalidParameter, op, "missing private key") + case t.PublicKey == nil: + return errors.New(ctx, errors.InvalidParameter, op, "missing public key") + case t.KeyId == "": + return errors.New(ctx, errors.InvalidParameter, op, "missing key id") + case t.TargetId == "": + return errors.New(ctx, errors.InvalidParameter, op, "missing target id") + case len(t.Certificate) == 0: + return errors.New(ctx, errors.InvalidParameter, op, "missing certificate") + case t.NotValidAfter == nil: + return errors.New(ctx, errors.InvalidParameter, op, "missing not valid after") + } + + return nil +} + +// TableName returns the table name. +func (t *TargetProxyCertificate) TableName() string { + return "target_proxy_certificate" +} + +// TargetAliasProxyCertificate represents a certificate for a target accessed with an alias +type TargetAliasProxyCertificate struct { + *store.TargetAliasProxyCertificate + tableName string `gorm:"-"` +} + +// NewTargetAliasProxyCertificate creates a new in memory TargetAliasProxyCertificate +func NewTargetAliasProxyCertificate(ctx context.Context, targetId string, alias *talias.Alias) (*TargetAliasProxyCertificate, error) { + const op = "target.NewTargetAliasProxyCertificate" + switch { + case targetId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing target id") + case alias == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing target alias") + } + + privKey, pubKey, err := generatePrivAndPubKeys(ctx) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("error generating target proxy certificate key")) + } + notValidAfter := time.Now().AddDate(1, 0, 0) // 1 year from now + parsedKey, err := x509.ParseECPrivateKey(privKey) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("error parsing target proxy certificate key")) + } + certBytes, err := generateTargetCert(ctx, parsedKey, notValidAfter, WithAlias(alias)) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("error generating target certificate")) + } + + return &TargetAliasProxyCertificate{ + TargetAliasProxyCertificate: &store.TargetAliasProxyCertificate{ + TargetId: targetId, + PrivateKey: privKey, + PublicKey: pubKey, + AliasId: alias.PublicId, + Certificate: certBytes, + NotValidAfter: timestamp.New(notValidAfter), + }, + }, nil +} + +// encrypt the target cert key before writing it to the db +func (t *TargetAliasProxyCertificate) encrypt(ctx context.Context, cipher wrapping.Wrapper) error { + const op = "target.(TargetAliasProxyCertificate).encrypt" + if cipher == nil { + return errors.New(ctx, errors.InvalidParameter, op, "missing cipher") + } + if err := structwrapping.WrapStruct(ctx, cipher, t.TargetAliasProxyCertificate, nil); err != nil { + return errors.Wrap(ctx, err, op, errors.WithCode(errors.Encrypt)) + } + keyId, err := cipher.KeyId(ctx) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithCode(errors.Encrypt), errors.WithMsg("failed to read cipher key id")) + } + t.KeyId = keyId + return nil +} + +// decrypt the target cert key after reading it from the db +func (t *TargetAliasProxyCertificate) decrypt(ctx context.Context, cipher wrapping.Wrapper) error { + const op = "target.(TargetAliasProxyCertificate).decrypt" + if cipher == nil { + return errors.New(ctx, errors.InvalidParameter, op, "missing cipher") + } + if err := structwrapping.UnwrapStruct(ctx, cipher, t.TargetAliasProxyCertificate, nil); err != nil { + return errors.Wrap(ctx, err, op, errors.WithCode(errors.Decrypt)) + } + return nil +} + +func allocTargetAliasProxyCertificate() *TargetAliasProxyCertificate { + return &TargetAliasProxyCertificate{ + TargetAliasProxyCertificate: &store.TargetAliasProxyCertificate{}, + } +} + +// Clone creates a clone of the TargetAliasProxyCertificate +func (t *TargetAliasProxyCertificate) Clone() *TargetAliasProxyCertificate { + cp := proto.Clone(t.TargetAliasProxyCertificate) + return &TargetAliasProxyCertificate{ + TargetAliasProxyCertificate: cp.(*store.TargetAliasProxyCertificate), + } +} + +// VetForWrite implements db.VetForWrite() interface and validates the target alias certificate +func (t *TargetAliasProxyCertificate) VetForWrite(ctx context.Context, _ db.Reader, opType db.OpType, _ ...db.Option) error { + const op = "target.(TargetAliasProxyCertificate).VetForWrite" + switch { + case t.PrivateKeyEncrypted == nil: + return errors.New(ctx, errors.InvalidParameter, op, "missing private key") + case t.PublicKey == nil: + return errors.New(ctx, errors.InvalidParameter, op, "missing public key") + case t.KeyId == "": + return errors.New(ctx, errors.InvalidParameter, op, "missing key id") + case t.TargetId == "": + return errors.New(ctx, errors.InvalidParameter, op, "missing target id") + case t.AliasId == "": + return errors.New(ctx, errors.InvalidParameter, op, "missing alias id") + case len(t.Certificate) == 0: + return errors.New(ctx, errors.InvalidParameter, op, "missing certificate") + case t.NotValidAfter == nil: + return errors.New(ctx, errors.InvalidParameter, op, "missing not valid after") + } + + return nil +} + +// TableName returns the table name. +func (t *TargetAliasProxyCertificate) TableName() string { + return "target_alias_proxy_certificate" +} diff --git a/internal/target/target_certificate_test.go b/internal/target/target_certificate_test.go new file mode 100644 index 0000000000..6b0c619213 --- /dev/null +++ b/internal/target/target_certificate_test.go @@ -0,0 +1,402 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package target + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "testing" + "time" + + talias "github.com/hashicorp/boundary/internal/alias/target" + astore "github.com/hashicorp/boundary/internal/alias/target/store" + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/iam" + "github.com/hashicorp/boundary/internal/kms" + wrapping "github.com/hashicorp/go-kms-wrapping/v2" + "github.com/hashicorp/go-kms-wrapping/v2/aead" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateTargetCert(t *testing.T) { + t.Parallel() + ctx := context.Background() + + privKey, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + require.NoError(t, err) + + aliasValue := "test-alias-value" + alias := &talias.Alias{ + Alias: &astore.Alias{ + Value: aliasValue, + }, + } + + tests := []struct { + name string + privKey *ecdsa.PrivateKey + exp time.Time + opt []Option + wantErr bool + wantErrContains string + }{ + { + name: "valid-target-proxy-cert", + privKey: privKey, + exp: time.Now().Add(1 * time.Hour), + }, + { + name: "valid-with-alias", + privKey: privKey, + exp: time.Now().Add(1 * time.Hour), + opt: []Option{ + WithAlias(alias), + }, + }, + { + name: "invalid-expiration", + privKey: privKey, + exp: time.Now().Add(-1 * time.Hour), + wantErr: true, + wantErrContains: "expiration time must be in the future", + }, + { + name: "missing-key", + exp: time.Now().Add(1 * time.Hour), + wantErr: true, + wantErrContains: "missing private key", + }, + { + name: "missing-expiry", + privKey: privKey, + wantErr: true, + wantErrContains: "missing expiry", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + got, err := generateTargetCert(ctx, tt.privKey, tt.exp, tt.opt...) + + if tt.wantErr { + assert.Error(err) + return + } + require.NoError(err) + require.NotNil(got) + + pCert, err := x509.ParseCertificate(got) + require.NoError(err) + require.NotNil(pCert) + + require.Equal("localhost", pCert.Subject.CommonName) + // Cert timestamps do not have ms resolution + tt.exp = tt.exp.Truncate(time.Second) + require.True(tt.exp.Equal(pCert.NotAfter)) + require.Contains(pCert.DNSNames, "localhost") + if tt.opt != nil { + opts := GetOpts(tt.opt...) + require.Contains(pCert.DNSNames, opts.withAlias.Value) + } + }) + } +} + +func TestTargetProxyCertificate(t *testing.T) { + t.Parallel() + ctx := context.Background() + + tId := "test-target-id" + + tests := []struct { + name string + opt []Option + wantErr bool + wantErrContains string + }{ + { + name: "valid-target-proxy-cert", + }, + { + name: "valid-with-options", + opt: []Option{ + WithTargetId(tId), + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + gotCert, err := NewTargetProxyCertificate(ctx, tt.opt...) + + if tt.wantErr { + assert.Error(err) + return + } + require.NoError(err) + require.NotNil(gotCert) + + if tt.opt != nil { + assert.Equal(tId, gotCert.TargetId) + } + }) + } +} + +func Test_encrypt_decrypt_TargetCert(t *testing.T) { + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + rootWrapper := db.TestWrapper(t) + kmsCache := kms.TestKms(t, conn, rootWrapper) + org, proj := iam.TestScopes(t, iam.TestRepo(t, conn, rootWrapper)) + databaseWrapper, err := kmsCache.GetWrapper(ctx, org.PublicId, kms.KeyPurposeDatabase) + require.NoError(t, err) + projDatabaseWrapper, err := kmsCache.GetWrapper(ctx, proj.PublicId, kms.KeyPurposeDatabase) + require.NoError(t, err) + + proxyCert, err := NewTargetProxyCertificate(ctx) + require.NoError(t, err) + + tests := []struct { + name string + certKey *TargetProxyCertificate + encryptWrapper wrapping.Wrapper + wantEncryptErrMatch *errors.Template + decryptWrapper wrapping.Wrapper + wantDecryptErrMatch *errors.Template + }{ + { + name: "success", + certKey: proxyCert, + encryptWrapper: databaseWrapper, + decryptWrapper: databaseWrapper, + }, + { + name: "encrypt-missing-wrapper", + certKey: proxyCert, + wantEncryptErrMatch: errors.T(errors.InvalidParameter), + }, + { + name: "encrypt-bad-wrapper", + certKey: proxyCert, + encryptWrapper: &aead.Wrapper{}, + wantEncryptErrMatch: errors.T(errors.Encrypt), + }, + { + name: "encrypt-missing-wrapper", + certKey: proxyCert, + encryptWrapper: databaseWrapper, + wantDecryptErrMatch: errors.T(errors.InvalidParameter), + }, + { + name: "decrypt-bad-wrapper", + certKey: proxyCert, + encryptWrapper: databaseWrapper, + decryptWrapper: &aead.Wrapper{}, + wantDecryptErrMatch: errors.T(errors.Decrypt), + }, + { + name: "decrypt-wrong-wrapper", + certKey: proxyCert, + encryptWrapper: databaseWrapper, + decryptWrapper: projDatabaseWrapper, + wantDecryptErrMatch: errors.T(errors.Decrypt), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + encryptedCert := tt.certKey.Clone() + err = encryptedCert.encrypt(ctx, tt.encryptWrapper) + if tt.wantEncryptErrMatch != nil { + require.Error(err) + assert.Truef(errors.Match(tt.wantEncryptErrMatch, err), "expected %q and got err: %+v", tt.wantEncryptErrMatch.Code, err) + return + } + require.NoError(err) + assert.NotEmpty(encryptedCert.PrivateKeyEncrypted) + + decryptedCert := encryptedCert.Clone() + decryptedCert.PrivateKey = []byte("") + err = decryptedCert.decrypt(ctx, tt.decryptWrapper) + if tt.wantDecryptErrMatch != nil { + require.Error(err) + assert.Truef(errors.Match(tt.wantDecryptErrMatch, err), "expected %q and got err: %+v", tt.wantDecryptErrMatch.Code, err) + return + } + require.NoError(err) + assert.Equal(tt.certKey.PrivateKey, decryptedCert.PrivateKey) + }) + } +} + +func TestTargetAliasProxyCertificate(t *testing.T) { + t.Parallel() + ctx := context.Background() + + tId := "test-target-id" + tAId := "test-alias-id" + aliasValue := "test-alias-value" + alias := &talias.Alias{ + Alias: &astore.Alias{ + PublicId: tAId, + Value: aliasValue, + }, + } + + tests := []struct { + name string + targetId string + alias *talias.Alias + wantErr bool + wantErrContains string + }{ + { + name: "valid-target-cert", + targetId: tId, + alias: alias, + }, + { + name: "missing-target-id", + alias: alias, + wantErr: true, + wantErrContains: "missing target id", + }, + { + name: "missing-alias", + targetId: tId, + wantErr: true, + wantErrContains: "missing alias", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + gotCert, err := NewTargetAliasProxyCertificate(ctx, tt.targetId, tt.alias) + + if tt.wantErr { + assert.Error(err) + return + } + require.NoError(err) + require.NotNil(gotCert) + assert.Equal(tId, gotCert.TargetId) + assert.Equal(tAId, gotCert.AliasId) + assert.NotNil(gotCert.Certificate) + }) + } +} + +func Test_encrypt_decrypt_TargetAliasCert(t *testing.T) { + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + rootWrapper := db.TestWrapper(t) + kmsCache := kms.TestKms(t, conn, rootWrapper) + org, proj := iam.TestScopes(t, iam.TestRepo(t, conn, rootWrapper)) + databaseWrapper, err := kmsCache.GetWrapper(ctx, org.PublicId, kms.KeyPurposeDatabase) + require.NoError(t, err) + projDatabaseWrapper, err := kmsCache.GetWrapper(ctx, proj.PublicId, kms.KeyPurposeDatabase) + require.NoError(t, err) + + tId := "test-target-id" + tAId := "test-alias-id" + aliasValue := "test-alias-value" + alias := &talias.Alias{ + Alias: &astore.Alias{ + PublicId: tAId, + Value: aliasValue, + }, + } + + proxyCert, err := NewTargetAliasProxyCertificate(ctx, tId, alias) + require.NoError(t, err) + + tests := []struct { + name string + certKey *TargetAliasProxyCertificate + encryptWrapper wrapping.Wrapper + wantEncryptErrMatch *errors.Template + decryptWrapper wrapping.Wrapper + wantDecryptErrMatch *errors.Template + }{ + { + name: "success", + certKey: proxyCert, + encryptWrapper: databaseWrapper, + decryptWrapper: databaseWrapper, + }, + { + name: "encrypt-missing-wrapper", + certKey: proxyCert, + wantEncryptErrMatch: errors.T(errors.InvalidParameter), + }, + { + name: "encrypt-bad-wrapper", + certKey: proxyCert, + encryptWrapper: &aead.Wrapper{}, + wantEncryptErrMatch: errors.T(errors.Encrypt), + }, + { + name: "encrypt-missing-wrapper", + certKey: proxyCert, + encryptWrapper: databaseWrapper, + wantDecryptErrMatch: errors.T(errors.InvalidParameter), + }, + { + name: "decrypt-bad-wrapper", + certKey: proxyCert, + encryptWrapper: databaseWrapper, + decryptWrapper: &aead.Wrapper{}, + wantDecryptErrMatch: errors.T(errors.Decrypt), + }, + { + name: "decrypt-wrong-wrapper", + certKey: proxyCert, + encryptWrapper: databaseWrapper, + decryptWrapper: projDatabaseWrapper, + wantDecryptErrMatch: errors.T(errors.Decrypt), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + encryptedCert := tt.certKey.Clone() + err = encryptedCert.encrypt(ctx, tt.encryptWrapper) + if tt.wantEncryptErrMatch != nil { + require.Error(err) + assert.Truef(errors.Match(tt.wantEncryptErrMatch, err), "expected %q and got err: %+v", tt.wantEncryptErrMatch.Code, err) + return + } + require.NoError(err) + assert.NotEmpty(encryptedCert.PrivateKeyEncrypted) + + decryptedCert := encryptedCert.Clone() + decryptedCert.PrivateKey = []byte("") + err = decryptedCert.decrypt(ctx, tt.decryptWrapper) + if tt.wantDecryptErrMatch != nil { + require.Error(err) + assert.Truef(errors.Match(tt.wantDecryptErrMatch, err), "expected %q and got err: %+v", tt.wantDecryptErrMatch.Code, err) + return + } + require.NoError(err) + assert.Equal(tt.certKey.PrivateKey, decryptedCert.PrivateKey) + }) + } +} diff --git a/internal/target/targettest/target.go b/internal/target/targettest/target.go index 036fb16544..9cf1266970 100644 --- a/internal/target/targettest/target.go +++ b/internal/target/targettest/target.go @@ -161,6 +161,10 @@ func (t *Target) GetStorageBucketId() string { return "" } +func (t *Target) GetProxyServerCertificate() *target.ServerCertificate { + return nil +} + func (t *Target) Clone() target.Target { cp := proto.Clone(t.Target) return &Target{ @@ -253,6 +257,8 @@ func (t *Target) SetEnableSessionRecording(_ bool) {} func (t *Target) SetStorageBucketId(_ string) {} +func (t *Target) SetProxyServerCertificate(*target.ServerCertificate) {} + func (t *Target) Oplog(op oplog.OpType) oplog.Metadata { return oplog.Metadata{ "resource-public-id": []string{t.PublicId}, diff --git a/internal/target/tcp/target.go b/internal/target/tcp/target.go index 2ef44f1d26..c3cb64081d 100644 --- a/internal/target/tcp/target.go +++ b/internal/target/tcp/target.go @@ -164,6 +164,10 @@ func (t *Target) GetStorageBucketId() string { return "" } +func (t *Target) GetProxyServerCertificate() *target.ServerCertificate { + return nil +} + func (t *Target) SetPublicId(ctx context.Context, publicId string) error { const op = "tcp.(Target).SetPublicId" if !strings.HasPrefix(publicId, TargetPrefix+"_") { @@ -247,5 +251,6 @@ func (t *Target) SetCredentialSources(sources []target.CredentialSource) { t.CredentialSources = sources } -func (t *Target) SetEnableSessionRecording(_ bool) {} -func (t *Target) SetStorageBucketId(_ string) {} +func (t *Target) SetEnableSessionRecording(_ bool) {} +func (t *Target) SetStorageBucketId(_ string) {} +func (t *Target) SetProxyServerCertificate(*target.ServerCertificate) {}