From 92e6a6de26b06b60fc4104a568420228805415d9 Mon Sep 17 00:00:00 2001 From: irenarindos Date: Tue, 24 Jun 2025 14:21:12 -0400 Subject: [PATCH] rdp(repo): persist target proxy certificates --- globals/prefixes.go | 3 + .../handlers/targets/target_service.go | 12 +- internal/target/exports_test.go | 10 +- internal/target/options.go | 4 +- internal/target/options_test.go | 2 +- internal/target/repository.go | 122 +++++- .../repository_proxy_server_certificate.go | 167 ++++++++ ...epository_proxy_server_certificate_test.go | 385 ++++++++++++++++++ internal/target/rewrapping.go | 92 +++++ internal/target/rewrapping_test.go | 171 ++++++++ internal/target/target_certificate.go | 265 ++++++++++-- internal/target/target_certificate_test.go | 56 ++- internal/target/targettest/target.go | 7 +- 13 files changed, 1237 insertions(+), 59 deletions(-) create mode 100644 internal/target/repository_proxy_server_certificate.go create mode 100644 internal/target/repository_proxy_server_certificate_test.go create mode 100644 internal/target/rewrapping.go create mode 100644 internal/target/rewrapping_test.go diff --git a/globals/prefixes.go b/globals/prefixes.go index 7d8ac635a..5286d664f 100644 --- a/globals/prefixes.go +++ b/globals/prefixes.go @@ -135,6 +135,9 @@ const ( // TargetAliasPrefix is the prefix for target aliases TargetAliasPrefix = "alt" + + // ProxyServerCertificatePrefix is the prefix for proxy server certificates + ProxyServerCertificatePrefix = "psc" ) type ResourceInfo struct { diff --git a/internal/daemon/controller/handlers/targets/target_service.go b/internal/daemon/controller/handlers/targets/target_service.go index deb67b2a8..7ac52ba13 100644 --- a/internal/daemon/controller/handlers/targets/target_service.go +++ b/internal/daemon/controller/handlers/targets/target_service.go @@ -734,18 +734,20 @@ func (s Service) AuthorizeSession(ctx context.Context, req *pbs.AuthorizeSession return nil, err } + var targetAlias *talias.Alias + var err error if ctxAlias := alias.FromContext(ctx); ctxAlias != nil { - a, err := s.resolveAlias(ctx, ctxAlias.PublicId) + targetAlias, err = s.resolveAlias(ctx, ctxAlias.PublicId) if err != nil { return nil, errors.Wrap(ctx, err, op) } - if a.HostId != "" { - if req.GetHostId() != "" && req.GetHostId() != a.HostId { + if targetAlias.HostId != "" { + if req.GetHostId() != "" && req.GetHostId() != targetAlias.HostId { return nil, handlers.InvalidArgumentErrorf("Errors in provided fields.", map[string]string{ "host_id": "The host id specified in the request does not match the one provided by the alias. Consider omitting the host id in the request.", }) } - req.HostId = a.HostId + req.HostId = targetAlias.HostId } } @@ -803,7 +805,7 @@ func (s Service) AuthorizeSession(ctx context.Context, req *pbs.AuthorizeSession if err != nil { return nil, err } - t, err := repo.LookupTarget(ctx, roundTripTarget.GetPublicId()) + t, err := repo.LookupTargetForSessionAuthorization(ctx, roundTripTarget.GetPublicId(), target.WithAlias(targetAlias)) if err != nil { if errors.IsNotFoundError(err) { return nil, handlers.NotFoundErrorf("Target %q not found.", roundTripTarget.GetPublicId()) diff --git a/internal/target/exports_test.go b/internal/target/exports_test.go index 2adbd49fa..d1fc344a0 100644 --- a/internal/target/exports_test.go +++ b/internal/target/exports_test.go @@ -5,6 +5,12 @@ package target // Expose functions for use in tests. var ( - AllocTargetView = allocTargetView - TargetsViewDefaultTable = targetsViewDefaultTable + AllocTargetView = allocTargetView + TargetsViewDefaultTable = targetsViewDefaultTable + ProxyCertRewrapFn = proxyCertRewrapFn + ProxyAliasCertRewrapFn = proxyAliasCertRewrapFn + AllocTargetProxyCertificate = allocTargetProxyCertificate + AllocTargetAliasProxyCertificate = allocTargetAliasProxyCertificate + FetchTargetProxyServerCertificate = fetchTargetProxyServerCertificate + FetchTargetAliasProxyServerCertificate = fetchTargetAliasProxyServerCertificate ) diff --git a/internal/target/options.go b/internal/target/options.go index adde381eb..af97d3672 100644 --- a/internal/target/options.go +++ b/internal/target/options.go @@ -55,8 +55,8 @@ type options struct { WithEnableSessionRecording bool WithNetResolver intglobals.NetIpResolver WithStartPageAfterItem pagination.Item + WithAlias *talias.Alias withAliases []*talias.Alias - withAlias *talias.Alias withTargetId string } @@ -291,7 +291,7 @@ func WithAliases(in []*talias.Alias) Option { // WithAlias provides an option to provide a single alias. func WithAlias(in *talias.Alias) Option { return func(o *options) { - o.withAlias = in + o.WithAlias = in } } diff --git a/internal/target/options_test.go b/internal/target/options_test.go index b2994b43f..88b7d7fc9 100644 --- a/internal/target/options_test.go +++ b/internal/target/options_test.go @@ -273,7 +273,7 @@ func Test_GetOpts(t *testing.T) { al, err := talias.NewAlias(context.Background(), "global", "test") require.NoError(err) opts := GetOpts(WithAlias(al)) - assert.Equal(al, opts.withAlias) + assert.Equal(al, opts.WithAlias) }) t.Run("WithTargetId", func(t *testing.T) { assert := assert.New(t) diff --git a/internal/target/repository.go b/internal/target/repository.go index a79c3932f..21e455a7a 100644 --- a/internal/target/repository.go +++ b/internal/target/repository.go @@ -89,9 +89,90 @@ func NewRepository(ctx context.Context, r db.Reader, w db.Writer, kms *kms.Kms, }, nil } +// LookupTargetForSessionAuthorization will look up a target in the repository and return the target +// with its host source ids, credential source ids, and server certificate, if applicable. If the target is not +// found, it will return nil, nil. +// Supported option: WithAlias if the session authorization uses a target alias +func (r *Repository) LookupTargetForSessionAuthorization(ctx context.Context, publicId string, opt ...Option) (Target, error) { + const op = "target.(Repository).LookupTargetForSessionAuthorization" + opts := GetOpts(opt...) + + if publicId == "" { + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing public id") + } + + target := allocTargetView() + target.PublicId = publicId + var address string + var hostSources []HostSource + var credSources []CredentialSource + var cert *ServerCertificate + _, err := r.writer.DoTx( + ctx, + db.StdRetryCnt, + db.ExpBackoff{}, + func(read db.Reader, w db.Writer) error { + lookupErr := read.LookupById(ctx, &target) + if lookupErr != nil { + return errors.Wrap(ctx, lookupErr, op, errors.WithMsg(fmt.Sprintf("failed for %s", publicId))) + } + + var err error + if hostSources, err = fetchHostSources(ctx, read, target.PublicId); err != nil { + return errors.Wrap(ctx, err, op) + } + if credSources, err = fetchCredentialSources(ctx, read, target.PublicId); err != nil { + return errors.Wrap(ctx, err, op) + } + + targetAddress, err := fetchAddress(ctx, read, target.PublicId) + if err != nil && !errors.IsNotFoundError(err) { + return errors.Wrap(ctx, err, op) + } + if targetAddress != nil { + address = targetAddress.GetAddress() + } + + databaseWrapper, err := r.kms.GetWrapper(ctx, target.GetProjectId(), kms.KeyPurposeDatabase) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("unable to get database wrapper")) + } + + if opts.WithAlias != nil { + cert, err = fetchTargetAliasProxyServerCertificate(ctx, read, w, target.PublicId, target.ProjectId, opts.WithAlias, databaseWrapper, target.GetSessionMaxSeconds()) + if err != nil && !errors.IsNotFoundError(err) { + return errors.Wrap(ctx, err, op) + } + } else { + cert, err = fetchTargetProxyServerCertificate(ctx, read, w, target.PublicId, target.ProjectId, databaseWrapper, target.GetSessionMaxSeconds()) + if err != nil && !errors.IsNotFoundError(err) { + return errors.Wrap(ctx, err, op) + } + } + return nil + }, + ) + if err != nil { + if errors.IsNotFoundError(err) { + return nil, nil + } + return nil, errors.Wrap(ctx, err, op) + } + subtype, err := target.targetSubtype(ctx, address) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + subtype.SetHostSources(hostSources) + subtype.SetCredentialSources(credSources) + subtype.SetProxyServerCertificate(cert) + + return subtype, nil +} + // LookupTarget will look up a target in the repository and return the target // with its host source ids and credential source ids. If the target is not -// found, it will return nil, nil, nil, nil. No options are currently supported. +// found, it will return nil, nil. +// Supported options: WithName, WithProjectId, and WithProjectName func (r *Repository) LookupTarget(ctx context.Context, publicIdOrName string, opt ...Option) (Target, error) { const op = "target.(Repository).LookupTarget" opts := GetOpts(opt...) @@ -513,9 +594,10 @@ func (r *Repository) CreateTarget(ctx context.Context, target Target, opt ...Opt return nil, errors.New(ctx, errors.InvalidParameter, op, "missing target") } - vet, ok := subtypeRegistry.vetFunc(target.GetType()) + targetType := target.GetType() + vet, ok := subtypeRegistry.vetFunc(targetType) if !ok { - return nil, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("unsupported target type %s", target.GetType())) + return nil, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("unsupported target type %s", targetType)) } if err := vet(ctx, target); err != nil { return nil, err @@ -537,9 +619,9 @@ func (r *Repository) CreateTarget(ctx context.Context, target Target, opt ...Opt return nil, err } } else { - prefix, ok := subtypeRegistry.idPrefix(target.GetType()) + prefix, ok := subtypeRegistry.idPrefix(targetType) if !ok { - return nil, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("unsupported target type %s", target.GetType())) + return nil, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("unsupported target type %s", targetType)) } id, err := db.NewPublicId(ctx, prefix) if err != nil { @@ -562,11 +644,18 @@ func (r *Repository) CreateTarget(ctx context.Context, target Target, opt ...Opt } } + serverCert := t.GetProxyServerCertificate() + oplogWrapper, err := r.kms.GetWrapper(ctx, target.GetProjectId(), kms.KeyPurposeOplog) if err != nil { return nil, errors.Wrap(ctx, err, op, errors.WithMsg("unable to get oplog wrapper")) } + databaseWrapper, err := r.kms.GetWrapper(ctx, target.GetProjectId(), kms.KeyPurposeDatabase) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("unable to get database wrapper")) + } + metadata := t.Oplog(oplog.OpType_OP_TYPE_CREATE) var createdAliases []*talias.Alias var returnedTarget Target @@ -613,6 +702,29 @@ func (r *Repository) CreateTarget(ctx context.Context, target Target, opt ...Opt msgs = append(msgs, &targetAliasOplogMsg) } + if serverCert != nil { + proxyCert := allocTargetProxyCertificate() + err = proxyCert.fromServerCertificate(ctx, serverCert) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("unable to convert server certificate to target proxy certificate")) + } + proxyCert.TargetId = t.GetPublicId() + id, err := db.NewPublicId(ctx, globals.ProxyServerCertificatePrefix) + if err != nil { + return errors.Wrap(ctx, err, op) + } + proxyCert.PublicId = id + if err := proxyCert.Encrypt(ctx, databaseWrapper); err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("error encrypting target proxy certificate")) + } + + var targetProxyCertOplogMsg oplog.Message + if err := w.Create(ctx, proxyCert, db.NewOplogMsg(&targetProxyCertOplogMsg)); err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("unable to create target proxy certificate")) + } + msgs = append(msgs, &targetProxyCertOplogMsg) + } + if err := w.WriteOplogEntryWith(ctx, oplogWrapper, targetTicket, metadata, msgs); err != nil { return errors.Wrap(ctx, err, op, errors.WithMsg("unable to write oplog")) } diff --git a/internal/target/repository_proxy_server_certificate.go b/internal/target/repository_proxy_server_certificate.go new file mode 100644 index 000000000..99e9a501b --- /dev/null +++ b/internal/target/repository_proxy_server_certificate.go @@ -0,0 +1,167 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package target + +import ( + "context" + "time" + + "github.com/hashicorp/boundary/globals" + 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/util" + wrapping "github.com/hashicorp/go-kms-wrapping/v2" +) + +type proxyServerCertificate interface { + Decrypt(ctx context.Context, wrapper wrapping.Wrapper) error + Encrypt(ctx context.Context, wrapper wrapping.Wrapper) error + GetNotValidAfter() *timestamp.Timestamp + SetPrivateKey(privKeyBytes []byte) + SetPublicKey(pubKeyBytes []byte) + SetCertificate(certBytes []byte) + SetNotValidAfter(timestamp *timestamp.Timestamp) + ToServerCertificate(ctx context.Context) (*ServerCertificate, error) +} + +// Check if the certificate is close to expiration and regenerate/ update it if necessary. +func maybeRegenerateCert(ctx context.Context, t proxyServerCertificate, w db.Writer, wrapper wrapping.Wrapper, sessionMaxSeconds uint32, opt ...Option) (*ServerCertificate, error) { + const op = "target.maybeRegenerateCert" + switch { + case wrapper == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "wrapper is nil") + case t == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "target is nil") + case util.IsNil(w): + return nil, errors.New(ctx, errors.InvalidParameter, op, "writer is nil") + case sessionMaxSeconds == 0: + return nil, errors.New(ctx, errors.InvalidParameter, op, "session max seconds is 0") + } + + // Check if the certificate is close to expiration. We consider it close to expiration if it is less than 1 day + sessionMaxSeconds away from now. + if t.GetNotValidAfter().AsTime().Before(time.Now().Add((time.Hour * 24) + (time.Second * time.Duration(sessionMaxSeconds)))) { + notValidAfter := time.Now().AddDate(1, 0, 0) // 1 year from now + + privKey, pubKey, cert, err := generateKeysAndCert(ctx, notValidAfter, opt...) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("error generating keys and certificate for proxy server certificate")) + } + t.SetPrivateKey(privKey) + t.SetPublicKey(pubKey) + t.SetCertificate(cert) + t.SetNotValidAfter(timestamp.New(notValidAfter)) + + if err := t.Encrypt(ctx, wrapper); err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("error encrypting proxy certificate")) + } + + var rowsUpdated int + rowsUpdated, err = w.Update(ctx, t, []string{"PublicKey", "PrivateKey", "Certificate", "NotValidAfter"}, nil) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("error updating proxy server certificate")) + } + if rowsUpdated != 1 { + return nil, errors.New(ctx, errors.MultipleRecords, op, "more than 1 proxy server certificate would have been updated") + } + } + + if err := t.Decrypt(ctx, wrapper); err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("unable to decrypt proxy server certificate private key")) + } + + serverCert, err := t.ToServerCertificate(ctx) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("error converting proxy server certificate to PEM")) + } + + return serverCert, nil +} + +func fetchTargetProxyServerCertificate(ctx context.Context, r db.Reader, w db.Writer, targetId, scopeId string, wrapper wrapping.Wrapper, sessionMaxSeconds uint32) (*ServerCertificate, error) { + const op = "target.fetchTargetProxyServerCert" + switch { + case wrapper == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "wrapper is nil") + case util.IsNil(r): + return nil, errors.New(ctx, errors.InvalidParameter, op, "reader is nil") + case util.IsNil(w): + return nil, errors.New(ctx, errors.InvalidParameter, op, "writer is nil") + case targetId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "target id is empty") + case scopeId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "scope id is empty") + } + + targetCert := allocTargetProxyCertificate() + if err := r.SearchWhere(ctx, &targetCert, "target_id = ?", []any{targetId}); err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + // If a cert is not found, this target type does not support proxy server certificates/ it was not generated during target creation. + if targetCert.Certificate == nil { + return nil, errors.New(ctx, errors.RecordNotFound, op, "target proxy server certificate not found") + } + + return maybeRegenerateCert(ctx, targetCert, w, wrapper, sessionMaxSeconds) +} + +func fetchTargetAliasProxyServerCertificate(ctx context.Context, r db.Reader, w db.Writer, targetId, scopeId string, alias *talias.Alias, wrapper wrapping.Wrapper, sessionMaxSeconds uint32) (*ServerCertificate, error) { + const op = "target.fetchTargetProxyServerCert" + switch { + case wrapper == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "wrapper is nil") + case util.IsNil(r): + return nil, errors.New(ctx, errors.InvalidParameter, op, "reader is nil") + case util.IsNil(w): + return nil, errors.New(ctx, errors.InvalidParameter, op, "writer is nil") + case alias == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "alias is nil") + case alias.Value == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "alias value is empty") + case targetId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "target id is empty") + case scopeId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "scope id is empty") + } + + // We first check if a localhost cert exists for this target; if it does not, one was not generated during + // target creation because it is not applicable and we don't need an alias proxy cert. + targetCert := allocTargetProxyCertificate() + if err := r.SearchWhere(ctx, &targetCert, "target_id = ?", []any{targetId}); err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + if targetCert.Certificate == nil { + return nil, errors.New(ctx, errors.RecordNotFound, op, "target proxy server certificate not found") + } + + aliasCert := allocTargetAliasProxyCertificate() + if err := r.SearchWhere(ctx, &aliasCert, "target_id = ? and alias_id = ?", []any{targetId, alias.PublicId}); err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + // Create the cert, if not found- alias certs are not created as part of target creation. + var err error + if aliasCert.Certificate == nil { + aliasCert, err = NewTargetAliasProxyCertificate(ctx, targetId, alias) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("error creating new target alias proxy certificate")) + } + if err = aliasCert.Encrypt(ctx, wrapper); err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("error encrypting target alias proxy certificate")) + } + id, err := db.NewPublicId(ctx, globals.ProxyServerCertificatePrefix) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + aliasCert.PublicId = id + if err = w.Create(ctx, aliasCert); err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("unable to create target alias proxy certificate")) + } + } + + return maybeRegenerateCert(ctx, aliasCert, w, wrapper, sessionMaxSeconds, WithAlias(alias)) +} diff --git a/internal/target/repository_proxy_server_certificate_test.go b/internal/target/repository_proxy_server_certificate_test.go new file mode 100644 index 000000000..ea312403a --- /dev/null +++ b/internal/target/repository_proxy_server_certificate_test.go @@ -0,0 +1,385 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package target_test + +import ( + "context" + "testing" + + "github.com/hashicorp/boundary/globals" + talias "github.com/hashicorp/boundary/internal/alias/target" + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/iam" + "github.com/hashicorp/boundary/internal/kms" + "github.com/hashicorp/boundary/internal/target" + "github.com/hashicorp/boundary/internal/target/targettest" + wrapping "github.com/hashicorp/go-kms-wrapping/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFetchTargetProxyServerCertificate(t *testing.T) { + t.Parallel() + ctx := context.Background() + wrapper := db.TestWrapper(t) + conn, _ := db.TestSetup(t, "postgres") + kmsCache := kms.TestKms(t, conn, wrapper) + _, proj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + rw := db.New(conn) + databaseWrapper, err := kmsCache.GetWrapper(ctx, proj.PublicId, kms.KeyPurposeDatabase) + require.NoError(t, err) + + tar := targettest.TestNewTestTarget(ctx, t, conn, proj.PublicId, "test-target") + + // Create our default localhost target cert + cer, err := target.NewTargetProxyCertificate(ctx, target.WithTargetId(tar.GetPublicId())) + require.NoError(t, err) + require.NotNil(t, cer) + id, err := db.NewPublicId(ctx, globals.ProxyServerCertificatePrefix) + require.NoError(t, err) + cer.PublicId = id + err = cer.Encrypt(ctx, databaseWrapper) + require.NoError(t, err) + err = rw.Create(ctx, cer) + require.NoError(t, err) + + tests := []struct { + name string + reader db.Reader + writer db.Writer + targetId string + scopeId string + wrapper wrapping.Wrapper + sessionMaxSecs uint32 + wantErr bool + wantErrContains string + }{ + { + name: "successful-lookup", + reader: rw, + writer: rw, + targetId: tar.GetPublicId(), + scopeId: proj.PublicId, + wrapper: databaseWrapper, + sessionMaxSecs: 28800, + }, + { + name: "successful-max-secs-causes-cert-regen", + reader: rw, + writer: rw, + targetId: tar.GetPublicId(), + scopeId: proj.PublicId, + wrapper: databaseWrapper, + sessionMaxSecs: 60 * 60 * 24 * 600, // well over a year + }, + { + name: "missing-reader", + writer: rw, + targetId: tar.GetPublicId(), + scopeId: proj.PublicId, + wrapper: databaseWrapper, + sessionMaxSecs: 28800, + wantErr: true, + wantErrContains: "reader is nil", + }, + { + name: "missing-writer", + reader: rw, + targetId: tar.GetPublicId(), + scopeId: proj.PublicId, + wrapper: databaseWrapper, + sessionMaxSecs: 28800, + wantErr: true, + wantErrContains: "writer is nil", + }, + { + name: "missing-target-id", + reader: rw, + writer: rw, + scopeId: proj.PublicId, + wrapper: databaseWrapper, + sessionMaxSecs: 28800, + wantErr: true, + wantErrContains: "target id is empty", + }, + { + name: "missing-scope-id", + reader: rw, + writer: rw, + targetId: tar.GetPublicId(), + wrapper: databaseWrapper, + sessionMaxSecs: 28800, + wantErr: true, + wantErrContains: "scope id is empty", + }, + { + name: "missing-wrapper", + reader: rw, + writer: rw, + targetId: tar.GetPublicId(), + scopeId: proj.PublicId, + sessionMaxSecs: 28800, + wantErr: true, + wantErrContains: "wrapper is nil", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + got, err := target.FetchTargetProxyServerCertificate(ctx, tt.reader, tt.writer, tt.targetId, tt.scopeId, tt.wrapper, tt.sessionMaxSecs) + if tt.wantErr { + require.Error(err) + require.Contains(err.Error(), tt.wantErrContains) + return + } + require.NoError(err) + assert.NotNil(got) + }) + } +} + +func TestFetchTargetAliasProxyServerCertificate(t *testing.T) { + t.Parallel() + ctx := context.Background() + wrapper := db.TestWrapper(t) + conn, _ := db.TestSetup(t, "postgres") + kmsCache := kms.TestKms(t, conn, wrapper) + _, proj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + rw := db.New(conn) + databaseWrapper, err := kmsCache.GetWrapper(ctx, proj.PublicId, kms.KeyPurposeDatabase) + require.NoError(t, err) + + tar := targettest.TestNewTestTarget(ctx, t, conn, proj.PublicId, "test-target") + + // Create our default localhost target cert + cer, err := target.NewTargetProxyCertificate(ctx, target.WithTargetId(tar.GetPublicId())) + require.NoError(t, err) + require.NotNil(t, cer) + id, err := db.NewPublicId(ctx, globals.ProxyServerCertificatePrefix) + require.NoError(t, err) + cer.PublicId = id + err = cer.Encrypt(ctx, databaseWrapper) + require.NoError(t, err) + err = rw.Create(ctx, cer) + require.NoError(t, err) + + // Create an alias + aliasValue := "test-alias" + alias := talias.TestAlias(t, rw, aliasValue, talias.WithDestinationId(tar.GetPublicId())) + require.NoError(t, err) + require.NotNil(t, alias) + + tests := []struct { + name string + reader db.Reader + writer db.Writer + targetId string + scopeId string + alias *talias.Alias + wrapper wrapping.Wrapper + sessionMaxSecs uint32 + wantErr bool + wantErrContains string + }{ + { + name: "successful-lookup", + reader: rw, + writer: rw, + targetId: tar.GetPublicId(), + scopeId: proj.PublicId, + alias: alias, + wrapper: databaseWrapper, + sessionMaxSecs: 28800, + }, + { + name: "successful-max-secs-causes-cert-regen", + reader: rw, + writer: rw, + targetId: tar.GetPublicId(), + scopeId: proj.PublicId, + alias: alias, + wrapper: databaseWrapper, + sessionMaxSecs: 60 * 60 * 24 * 600, // well over a year + }, + { + name: "missing-reader", + writer: rw, + targetId: tar.GetPublicId(), + scopeId: proj.PublicId, + alias: alias, + wrapper: databaseWrapper, + sessionMaxSecs: 28800, + wantErr: true, + wantErrContains: "reader is nil", + }, + { + name: "missing-writer", + reader: rw, + targetId: tar.GetPublicId(), + scopeId: proj.PublicId, + alias: alias, + wrapper: databaseWrapper, + sessionMaxSecs: 28800, + wantErr: true, + wantErrContains: "writer is nil", + }, + { + name: "missing-target-id", + reader: rw, + writer: rw, + scopeId: proj.PublicId, + alias: alias, + wrapper: databaseWrapper, + sessionMaxSecs: 28800, + wantErr: true, + wantErrContains: "target id is empty", + }, + { + name: "missing-scope-id", + reader: rw, + writer: rw, + targetId: tar.GetPublicId(), + alias: alias, + wrapper: databaseWrapper, + sessionMaxSecs: 28800, + wantErr: true, + wantErrContains: "scope id is empty", + }, + { + name: "missing-wrapper", + reader: rw, + writer: rw, + targetId: tar.GetPublicId(), + scopeId: proj.PublicId, + alias: alias, + sessionMaxSecs: 28800, + wantErr: true, + wantErrContains: "wrapper is nil", + }, + { + name: "missing-alias", + reader: rw, + writer: rw, + targetId: tar.GetPublicId(), + scopeId: proj.PublicId, + wrapper: databaseWrapper, + sessionMaxSecs: 28800, + wantErr: true, + wantErrContains: "alias is nil", + }, + { + name: "no-existing-localhost-cert-for-target", + reader: rw, + writer: rw, + targetId: "fake-id", + alias: alias, + scopeId: proj.PublicId, + wrapper: databaseWrapper, + sessionMaxSecs: 28800, + wantErr: true, + wantErrContains: "target proxy server certificate not found", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + got, err := target.FetchTargetAliasProxyServerCertificate(ctx, tt.reader, tt.writer, tt.targetId, tt.scopeId, tt.alias, tt.wrapper, tt.sessionMaxSecs) + if tt.wantErr { + require.Error(err) + require.Contains(err.Error(), tt.wantErrContains) + return + } + require.NoError(err) + assert.NotNil(got) + }) + } +} + +func Test_FetchCertsWithinLookupTargetForSessionAuthorization(t *testing.T) { + t.Parallel() + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + testKms := kms.TestKms(t, conn, wrapper) + _, proj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + repo, err := target.NewRepository(context.Background(), rw, rw, testKms) + require.NoError(t, err) + databaseWrapper, err := testKms.GetWrapper(ctx, proj.PublicId, kms.KeyPurposeDatabase) + require.NoError(t, err) + + tar := targettest.TestNewTestTarget(ctx, t, conn, proj.PublicId, "test-target") + tar2 := targettest.TestNewTestTarget(ctx, t, conn, proj.PublicId, "test-target2") + + // Create an alias + aliasValue := "test-alias" + alias := talias.TestAlias(t, rw, aliasValue, talias.WithDestinationId(tar.GetPublicId())) + require.NoError(t, err) + require.NotNil(t, alias) + + // Create our default localhost target cert + cer, err := target.NewTargetProxyCertificate(ctx, target.WithTargetId(tar.GetPublicId())) + require.NoError(t, err) + require.NotNil(t, cer) + id, err := db.NewPublicId(ctx, globals.ProxyServerCertificatePrefix) + require.NoError(t, err) + cer.PublicId = id + err = cer.Encrypt(ctx, databaseWrapper) + require.NoError(t, err) + err = rw.Create(ctx, cer) + require.NoError(t, err) + + tests := []struct { + name string + publicId string + opt []target.Option + wantCert bool + }{ + { + name: "success-get-target-with-certificate", + publicId: tar.GetPublicId(), + wantCert: true, + }, + { + name: "success-get-target-with-alias-certificate", + publicId: tar.GetPublicId(), + opt: []target.Option{ + target.WithAlias(alias), + }, + wantCert: true, + }, + { + name: "success-get-target-no-cert", + publicId: tar2.GetPublicId(), + wantCert: false, + }, + { + name: "success-get-target-no-cert-with-alias", + publicId: tar2.GetPublicId(), + opt: []target.Option{ + target.WithAlias(alias), + }, + wantCert: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, err := repo.LookupTargetForSessionAuthorization(ctx, tt.publicId, tt.opt...) + require.NoError(err) + assert.NotNil(got) + if tt.wantCert { + assert.NotNil(got.GetProxyServerCertificate()) + } else { + assert.Nil(got.GetProxyServerCertificate()) + } + }) + } +} diff --git a/internal/target/rewrapping.go b/internal/target/rewrapping.go new file mode 100644 index 000000000..68fc22209 --- /dev/null +++ b/internal/target/rewrapping.go @@ -0,0 +1,92 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package target + +import ( + "context" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/kms" + "github.com/hashicorp/boundary/internal/util" +) + +func init() { + kms.RegisterTableRewrapFn("target_proxy_certificate", proxyCertRewrapFn) + kms.RegisterTableRewrapFn("target_alias_proxy_certificate", proxyAliasCertRewrapFn) +} + +func proxyCertRewrapFn(ctx context.Context, dataKeyVersionId, scopeId string, reader db.Reader, writer db.Writer, kmsRepo kms.GetWrapperer) error { + const op = "target.proxyCertRewrapFn" + switch { + case dataKeyVersionId == "": + return errors.New(ctx, errors.InvalidParameter, op, "missing data key version id") + case scopeId == "": + return errors.New(ctx, errors.InvalidParameter, op, "missing scope id") + case util.IsNil(reader): + return errors.New(ctx, errors.InvalidParameter, op, "missing database reader") + case util.IsNil(writer): + return errors.New(ctx, errors.InvalidParameter, op, "missing database writer") + case kmsRepo == nil: + return errors.New(ctx, errors.InvalidParameter, op, "missing kms repository") + } + + var certs []*TargetProxyCertificate + if err := reader.SearchWhere(ctx, &certs, "key_id=?", []any{dataKeyVersionId}, db.WithLimit(-1)); err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("failed to query sql for rows that need rewrapping")) + } + wrapper, err := kmsRepo.GetWrapper(ctx, scopeId, kms.KeyPurposeDatabase) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("failed to fetch kms wrapper for rewrapping")) + } + for _, ck := range certs { + if err := ck.Decrypt(ctx, wrapper); err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("failed to decrypt proxy certificate")) + } + if err := ck.Encrypt(ctx, wrapper); err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("failed to re-encrypt proxy certificate")) + } + if _, err := writer.Update(ctx, ck, []string{"PrivateKeyEncrypted", "KeyId"}, nil); err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("failed to rewrap proxy certificate")) + } + } + return nil +} + +func proxyAliasCertRewrapFn(ctx context.Context, dataKeyVersionId, scopeId string, reader db.Reader, writer db.Writer, kmsRepo kms.GetWrapperer) error { + const op = "target.proxyAliasCertRewrapFn" + switch { + case dataKeyVersionId == "": + return errors.New(ctx, errors.InvalidParameter, op, "missing data key version id") + case scopeId == "": + return errors.New(ctx, errors.InvalidParameter, op, "missing scope id") + case util.IsNil(reader): + return errors.New(ctx, errors.InvalidParameter, op, "missing database reader") + case util.IsNil(writer): + return errors.New(ctx, errors.InvalidParameter, op, "missing database writer") + case kmsRepo == nil: + return errors.New(ctx, errors.InvalidParameter, op, "missing kms repository") + } + + var certs []*TargetAliasProxyCertificate + if err := reader.SearchWhere(ctx, &certs, "key_id=?", []any{dataKeyVersionId}, db.WithLimit(-1)); err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("failed to query sql for rows that need rewrapping")) + } + wrapper, err := kmsRepo.GetWrapper(ctx, scopeId, kms.KeyPurposeDatabase) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("failed to fetch kms wrapper for rewrapping")) + } + for _, ck := range certs { + if err := ck.Decrypt(ctx, wrapper); err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("failed to decrypt proxy certificate")) + } + if err := ck.Encrypt(ctx, wrapper); err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("failed to re-encrypt proxy alias certificate")) + } + if _, err := writer.Update(ctx, ck, []string{"PrivateKeyEncrypted", "KeyId"}, nil); err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("failed to rewrap proxy alias certificate")) + } + } + return nil +} diff --git a/internal/target/rewrapping_test.go b/internal/target/rewrapping_test.go new file mode 100644 index 000000000..ef05cbedd --- /dev/null +++ b/internal/target/rewrapping_test.go @@ -0,0 +1,171 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package target_test + +import ( + "context" + "crypto/x509" + "errors" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/hashicorp/boundary/globals" + talias "github.com/hashicorp/boundary/internal/alias/target" + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/iam" + "github.com/hashicorp/boundary/internal/kms" + "github.com/hashicorp/boundary/internal/target" + "github.com/hashicorp/boundary/internal/target/targettest" + "github.com/hashicorp/go-kms-wrapping/extras/kms/v2/migrations" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRewrap_CertRewrapFn(t *testing.T) { + ctx := context.Background() + t.Run("errors-on-query-error", func(t *testing.T) { + conn, mock := db.TestSetupWithMock(t) + wrapper := db.TestWrapper(t) + mock.ExpectQuery( + `SELECT \* FROM "kms_schema_version" WHERE 1=1 ORDER BY "kms_schema_version"\."version" LIMIT \$1`, + ).WillReturnRows(sqlmock.NewRows([]string{"version", "create_time"}).AddRow(migrations.Version, time.Now())) + mock.ExpectQuery( + `SELECT \* FROM "kms_oplog_schema_version" WHERE 1=1 ORDER BY "kms_oplog_schema_version"."version" LIMIT \$1`, + ).WillReturnRows(sqlmock.NewRows([]string{"version", "create_time"}).AddRow(migrations.Version, time.Now())) + kmsCache := kms.TestKms(t, conn, wrapper) + rw := db.New(conn) + mock.ExpectQuery( + `SELECT \* FROM "target_proxy_certificate" WHERE key_id=\$1`, + ).WillReturnError(errors.New("Query error")) + err := target.ProxyCertRewrapFn(ctx, "some_id", "some_scope", rw, rw, kmsCache) + require.Error(t, err) + }) + t.Run("success", func(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + kmsCache := kms.TestKms(t, conn, wrapper) + iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) // despite not looking like it, this is necessary for some reason + _, proj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + kmsWrapper, err := kmsCache.GetWrapper(context.Background(), proj.PublicId, kms.KeyPurposeDatabase) + require.NoError(t, err) + + tar := targettest.TestNewTestTarget(ctx, t, conn, proj.PublicId, "test-target") + + certKey, err := target.NewTargetProxyCertificate(ctx) + require.NoError(t, err) + certKey.TargetId = tar.GetPublicId() + id, err := db.NewPublicId(ctx, globals.ProxyServerCertificatePrefix) + require.NoError(t, err) + certKey.PublicId = id + err = certKey.Encrypt(ctx, kmsWrapper) + require.NoError(t, err) + err = rw.Create(ctx, certKey) + require.NoError(t, err) + + // now things are stored in the db, we can rotate and rewrap + assert.NoError(t, kmsCache.RotateKeys(ctx, proj.PublicId)) + assert.NoError(t, target.ProxyCertRewrapFn(ctx, certKey.KeyId, proj.PublicId, rw, rw, kmsCache)) + + // now we pull it from the db, decrypt it with the new key, and ensure things match + got := target.AllocTargetProxyCertificate() + got.PublicId = certKey.GetPublicId() + assert.NoError(t, rw.LookupById(ctx, got)) + + kmsWrapper2, err := kmsCache.GetWrapper(context.Background(), proj.PublicId, kms.KeyPurposeDatabase, kms.WithKeyId(got.GetKeyId())) + assert.NoError(t, err) + newKeyVersionId, err := kmsWrapper2.KeyId(ctx) + assert.NoError(t, err) + + // decrypt with the new key version and check to make sure things match + assert.NoError(t, got.Decrypt(ctx, kmsWrapper2)) + assert.NotEmpty(t, got.GetKeyId()) + assert.NotEqual(t, certKey.KeyId, got.GetKeyId()) + assert.Equal(t, newKeyVersionId, got.GetKeyId()) + assert.NotEqual(t, certKey.UpdateTime, got.UpdateTime) + + certPrivKeyParsed, err := x509.ParseECPrivateKey(certKey.PrivateKey) + require.NoError(t, err) + retrievedCertKeyParsed, err := x509.ParseECPrivateKey(got.PrivateKey) + require.NoError(t, err) + keysAreEqual := certPrivKeyParsed.Equal(retrievedCertKeyParsed) + assert.True(t, keysAreEqual, "expected the ECDSA keys to be equal") + }) +} + +func TestRewrap_AliasCertRewrapFn(t *testing.T) { + ctx := context.Background() + t.Run("errors-on-query-error", func(t *testing.T) { + conn, mock := db.TestSetupWithMock(t) + wrapper := db.TestWrapper(t) + mock.ExpectQuery( + `SELECT \* FROM "kms_schema_version" WHERE 1=1 ORDER BY "kms_schema_version"\."version" LIMIT \$1`, + ).WillReturnRows(sqlmock.NewRows([]string{"version", "create_time"}).AddRow(migrations.Version, time.Now())) + mock.ExpectQuery( + `SELECT \* FROM "kms_oplog_schema_version" WHERE 1=1 ORDER BY "kms_oplog_schema_version"."version" LIMIT \$1`, + ).WillReturnRows(sqlmock.NewRows([]string{"version", "create_time"}).AddRow(migrations.Version, time.Now())) + kmsCache := kms.TestKms(t, conn, wrapper) + rw := db.New(conn) + mock.ExpectQuery( + `SELECT \* FROM "target_alias_proxy_certificate" WHERE key_id=\$1`, + ).WillReturnError(errors.New("Query error")) + err := target.ProxyCertRewrapFn(ctx, "some_id", "some_scope", rw, rw, kmsCache) + require.Error(t, err) + }) + t.Run("success", func(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + kmsCache := kms.TestKms(t, conn, wrapper) + iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) // despite not looking like it, this is necessary for some reason + _, proj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + kmsWrapper, err := kmsCache.GetWrapper(context.Background(), proj.PublicId, kms.KeyPurposeDatabase) + require.NoError(t, err) + + tar := targettest.TestNewTestTarget(ctx, t, conn, proj.PublicId, "test-target") + aliasValue := "test-alias" + alias := talias.TestAlias(t, rw, aliasValue, talias.WithDestinationId(tar.GetPublicId())) + require.NoError(t, err) + require.NotNil(t, alias) + + certKey, err := target.NewTargetAliasProxyCertificate(ctx, tar.GetPublicId(), alias) + require.NoError(t, err) + id, err := db.NewPublicId(ctx, globals.ProxyServerCertificatePrefix) + require.NoError(t, err) + certKey.PublicId = id + err = certKey.Encrypt(ctx, kmsWrapper) + require.NoError(t, err) + err = rw.Create(ctx, certKey) + require.NoError(t, err) + + // now things are stored in the db, we can rotate and rewrap + assert.NoError(t, kmsCache.RotateKeys(ctx, proj.PublicId)) + assert.NoError(t, target.ProxyAliasCertRewrapFn(ctx, certKey.KeyId, proj.PublicId, rw, rw, kmsCache)) + + // now we pull it from the db, decrypt it with the new key, and ensure things match + got := target.AllocTargetAliasProxyCertificate() + got.PublicId = certKey.GetPublicId() + assert.NoError(t, rw.LookupById(ctx, got)) + + kmsWrapper2, err := kmsCache.GetWrapper(context.Background(), proj.PublicId, kms.KeyPurposeDatabase, kms.WithKeyId(got.GetKeyId())) + assert.NoError(t, err) + newKeyVersionId, err := kmsWrapper2.KeyId(ctx) + assert.NoError(t, err) + + // decrypt with the new key version and check to make sure things match + assert.NoError(t, got.Decrypt(ctx, kmsWrapper2)) + assert.NotEmpty(t, got.GetKeyId()) + assert.NotEqual(t, certKey.KeyId, got.GetKeyId()) + assert.Equal(t, newKeyVersionId, got.GetKeyId()) + assert.NotEqual(t, certKey.UpdateTime, got.UpdateTime) + + certPrivKeyParsed, err := x509.ParseECPrivateKey(certKey.PrivateKey) + require.NoError(t, err) + retrievedCertKeyParsed, err := x509.ParseECPrivateKey(got.PrivateKey) + require.NoError(t, err) + keysAreEqual := certPrivKeyParsed.Equal(retrievedCertKeyParsed) + assert.True(t, keysAreEqual, "expected the ECDSA keys to be equal") + }) +} diff --git a/internal/target/target_certificate.go b/internal/target/target_certificate.go index d536bc995..fc691916f 100644 --- a/internal/target/target_certificate.go +++ b/internal/target/target_certificate.go @@ -10,6 +10,7 @@ import ( "crypto/rand" "crypto/x509" "crypto/x509/pkix" + "encoding/pem" "math/big" mathrand "math/rand" "net" @@ -47,7 +48,7 @@ func generatePrivAndPubKeys(ctx context.Context) (privKeyBytes []byte, pubKeyByt } // 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 +// 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 { @@ -77,8 +78,8 @@ func generateTargetCert(ctx context.Context, privKey *ecdsa.PrivateKey, exp time DNSNames: []string{"localhost"}, } - if opts.withAlias != nil { - template.DNSNames = append(template.DNSNames, opts.withAlias.Value) + if opts.WithAlias != nil { + template.DNSNames = append(template.DNSNames, opts.WithAlias.Value) } certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &privKey.PublicKey, privKey) @@ -88,6 +89,25 @@ func generateTargetCert(ctx context.Context, privKey *ecdsa.PrivateKey, exp time return certBytes, nil } +func generateKeysAndCert(ctx context.Context, notValidAfter time.Time, opt ...Option) (privKey []byte, pubKey []byte, cert []byte, err error) { + const op = "target.generateKeysAndCert" + + privKey, pubKey, err = generatePrivAndPubKeys(ctx) + if err != nil { + return nil, nil, nil, errors.Wrap(ctx, err, op) + } + parsedKey, err := x509.ParseECPrivateKey(privKey) + if err != nil { + return nil, nil, nil, errors.Wrap(ctx, err, op) + } + cert, err = generateTargetCert(ctx, parsedKey, notValidAfter, opt...) + if err != nil { + return nil, nil, nil, errors.Wrap(ctx, err, op) + } + + return privKey, pubKey, cert, nil +} + // TargetProxyCertificate represents a proxy certificate for a target type TargetProxyCertificate struct { *store.TargetProxyCertificate @@ -102,35 +122,26 @@ func NewTargetProxyCertificate(ctx context.Context, opt ...Option) (*TargetProxy 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) + privKey, pubKey, cert, err := generateKeysAndCert(ctx, notValidAfter) 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 nil, errors.Wrap(ctx, err, op, errors.WithMsg("error generating target proxy cert and keys")) } return &TargetProxyCertificate{ TargetProxyCertificate: &store.TargetProxyCertificate{ PrivateKey: privKey, PublicKey: pubKey, - Certificate: certBytes, + Certificate: cert, 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" +// 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") } @@ -145,9 +156,9 @@ func (t *TargetProxyCertificate) encrypt(ctx context.Context, cipher wrapping.Wr 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" +// 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") } @@ -197,6 +208,31 @@ func (t *TargetProxyCertificate) TableName() string { return "target_proxy_certificate" } +// SetTableName sets the table name +func (t *TargetProxyCertificate) SetTableName(name string) { + t.tableName = name +} + +func (t *TargetProxyCertificate) GetNotValidAfter() *timestamp.Timestamp { + return t.NotValidAfter +} + +func (t *TargetProxyCertificate) SetPrivateKey(privKeyBytes []byte) { + t.PrivateKey = privKeyBytes +} + +func (t *TargetProxyCertificate) SetPublicKey(pubKeyBytes []byte) { + t.PublicKey = pubKeyBytes +} + +func (t *TargetProxyCertificate) SetCertificate(certBytes []byte) { + t.Certificate = certBytes +} + +func (t *TargetProxyCertificate) SetNotValidAfter(timestamp *timestamp.Timestamp) { + t.NotValidAfter = timestamp +} + // TargetAliasProxyCertificate represents a certificate for a target accessed with an alias type TargetAliasProxyCertificate struct { *store.TargetAliasProxyCertificate @@ -213,18 +249,10 @@ func NewTargetAliasProxyCertificate(ctx context.Context, targetId string, alias 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) + privKey, pubKey, cert, err := generateKeysAndCert(ctx, notValidAfter, WithAlias(alias)) 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 nil, errors.Wrap(ctx, err, op, errors.WithMsg("error generating target proxy cert and keys")) } return &TargetAliasProxyCertificate{ @@ -233,15 +261,15 @@ func NewTargetAliasProxyCertificate(ctx context.Context, targetId string, alias PrivateKey: privKey, PublicKey: pubKey, AliasId: alias.PublicId, - Certificate: certBytes, + Certificate: cert, 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" +// 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") } @@ -257,8 +285,8 @@ func (t *TargetAliasProxyCertificate) encrypt(ctx context.Context, cipher wrappi } // 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" +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") } @@ -309,3 +337,166 @@ func (t *TargetAliasProxyCertificate) VetForWrite(ctx context.Context, _ db.Read func (t *TargetAliasProxyCertificate) TableName() string { return "target_alias_proxy_certificate" } + +// SetTableName sets the table name +func (t *TargetAliasProxyCertificate) SetTableName(name string) { + t.tableName = name +} + +func (t *TargetAliasProxyCertificate) GetNotValidAfter() *timestamp.Timestamp { + return t.NotValidAfter +} + +func (t *TargetAliasProxyCertificate) SetPrivateKey(privKeyBytes []byte) { + t.PrivateKey = privKeyBytes +} + +func (t *TargetAliasProxyCertificate) SetPublicKey(pubKeyBytes []byte) { + t.PublicKey = pubKeyBytes +} + +func (t *TargetAliasProxyCertificate) SetCertificate(certBytes []byte) { + t.Certificate = certBytes +} + +func (t *TargetAliasProxyCertificate) SetNotValidAfter(timestamp *timestamp.Timestamp) { + t.NotValidAfter = timestamp +} + +func pemsToServerCertificate(ctx context.Context, certPem, keyPem []byte) (*ServerCertificate, error) { + const op = "target.pemsToServerCertificate" + switch { + case certPem == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing certificate PEM") + case keyPem == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing private key PEM") + } + + cPem := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certPem, + }) + if certPem == nil { + return nil, errors.New(ctx, errors.InvalidParameter, op, "error encoding certificate to PEM") + } + + kPem := pem.EncodeToMemory(&pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: keyPem, + }) + if keyPem == nil { + return nil, errors.New(ctx, errors.InvalidParameter, op, "error encoding private key to PEM") + } + + return &ServerCertificate{ + CertificatePem: cPem, + PrivateKeyPem: kPem, + }, nil +} + +func (t *TargetProxyCertificate) fromServerCertificate(ctx context.Context, serverCert *ServerCertificate) error { + const op = "target.(TargetProxyCertificate).fromServerCertificate" + switch { + case serverCert == nil: + return errors.New(ctx, errors.InvalidParameter, op, "missing server certificate") + case len(serverCert.CertificatePem) == 0: + return errors.New(ctx, errors.InvalidParameter, op, "missing certificate PEM data") + case len(serverCert.PrivateKeyPem) == 0: + return errors.New(ctx, errors.InvalidParameter, op, "missing private key PEM data") + } + + decodedBlock, _ := pem.Decode(serverCert.CertificatePem) + if decodedBlock == nil || decodedBlock.Type != "CERTIFICATE" { + return errors.New(ctx, errors.InvalidParameter, op, "invalid PEM data for certificate") + } + + cert, err := x509.ParseCertificate(decodedBlock.Bytes) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("error parsing PEM data")) + } + t.Certificate = cert.Raw + t.NotValidAfter = timestamp.New(cert.NotAfter) + + decodedKeyBlock, _ := pem.Decode(serverCert.PrivateKeyPem) + if decodedKeyBlock == nil || decodedKeyBlock.Type != "EC PRIVATE KEY" { + return errors.New(ctx, errors.InvalidParameter, op, "invalid PEM data for EC private key") + } + + t.PrivateKey = decodedKeyBlock.Bytes + privKey, err := x509.ParseECPrivateKey(t.PrivateKey) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("error parsing decoded private key")) + } + t.PublicKey, err = x509.MarshalPKIXPublicKey(&privKey.PublicKey) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("error marshalling public key")) + } + return nil +} + +// ToServerCertificate converts the target proxy certificate to a server certificate +func (t *TargetProxyCertificate) ToServerCertificate(ctx context.Context) (*ServerCertificate, error) { + const op = "target.(TargetProxyCertificate).ToServerCertificate" + switch { + case len(t.PrivateKey) == 0: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing private key") + case len(t.Certificate) == 0: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing certificate") + } + + return pemsToServerCertificate(ctx, t.Certificate, t.PrivateKey) +} + +func (t *TargetAliasProxyCertificate) fromServerCertificate(ctx context.Context, serverCert *ServerCertificate) error { + const op = "target.(TargetAliasProxyCertificate).fromServerCertificate" + switch { + case serverCert == nil: + return errors.New(ctx, errors.InvalidParameter, op, "missing server certificate") + case len(serverCert.CertificatePem) == 0: + return errors.New(ctx, errors.InvalidParameter, op, "missing certificate PEM data") + case len(serverCert.PrivateKeyPem) == 0: + return errors.New(ctx, errors.InvalidParameter, op, "missing private keyPEM data") + } + + decodedBlock, _ := pem.Decode(serverCert.CertificatePem) + if decodedBlock == nil || decodedBlock.Type != "CERTIFICATE" { + return errors.New(ctx, errors.InvalidParameter, op, "invalid PEM data for certificate") + } + + cert, err := x509.ParseCertificate(decodedBlock.Bytes) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("error parsing PEM data")) + } + t.Certificate = cert.Raw + t.NotValidAfter = timestamp.New(cert.NotAfter) + + decodedKeyBlock, _ := pem.Decode(serverCert.PrivateKeyPem) + if decodedKeyBlock == nil || decodedKeyBlock.Type != "EC PRIVATE KEY" { + return errors.New(ctx, errors.InvalidParameter, op, "invalid PEM data for EC private key") + } + + t.PrivateKey = decodedKeyBlock.Bytes + privKey, err := x509.ParseECPrivateKey(t.PrivateKey) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("error parsing decoded private key")) + } + t.PublicKey, err = x509.MarshalPKIXPublicKey(&privKey.PublicKey) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("error marshalling public key")) + } + return nil +} + +// ToServerCertificate converts the target alias proxy certificate to a server certificate +func (t *TargetAliasProxyCertificate) ToServerCertificate(ctx context.Context) (*ServerCertificate, error) { + const op = "target.(TargetAliasProxyCertificate).ToServerCertificate" + switch { + case len(t.PrivateKey) == 0: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing private key") + case len(t.Certificate) == 0: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing certificate") + + } + + return pemsToServerCertificate(ctx, t.Certificate, t.PrivateKey) +} diff --git a/internal/target/target_certificate_test.go b/internal/target/target_certificate_test.go index 6b0c61921..2f2ae123f 100644 --- a/internal/target/target_certificate_test.go +++ b/internal/target/target_certificate_test.go @@ -105,7 +105,7 @@ func TestGenerateTargetCert(t *testing.T) { require.Contains(pCert.DNSNames, "localhost") if tt.opt != nil { opts := GetOpts(tt.opt...) - require.Contains(pCert.DNSNames, opts.withAlias.Value) + require.Contains(pCert.DNSNames, opts.WithAlias.Value) } }) } @@ -220,7 +220,7 @@ func Test_encrypt_decrypt_TargetCert(t *testing.T) { assert, require := assert.New(t), require.New(t) encryptedCert := tt.certKey.Clone() - err = encryptedCert.encrypt(ctx, tt.encryptWrapper) + 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) @@ -231,7 +231,7 @@ func Test_encrypt_decrypt_TargetCert(t *testing.T) { decryptedCert := encryptedCert.Clone() decryptedCert.PrivateKey = []byte("") - err = decryptedCert.decrypt(ctx, tt.decryptWrapper) + 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) @@ -378,7 +378,7 @@ func Test_encrypt_decrypt_TargetAliasCert(t *testing.T) { assert, require := assert.New(t), require.New(t) encryptedCert := tt.certKey.Clone() - err = encryptedCert.encrypt(ctx, tt.encryptWrapper) + 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) @@ -389,7 +389,7 @@ func Test_encrypt_decrypt_TargetAliasCert(t *testing.T) { decryptedCert := encryptedCert.Clone() decryptedCert.PrivateKey = []byte("") - err = decryptedCert.decrypt(ctx, tt.decryptWrapper) + 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) @@ -400,3 +400,49 @@ func Test_encrypt_decrypt_TargetAliasCert(t *testing.T) { }) } } + +func TestTargetProxyCertToAndFromServerCert(t *testing.T) { + t.Parallel() + ctx := context.Background() + + cert, err := NewTargetProxyCertificate(ctx) + require.NoError(t, err) + require.NotNil(t, cert) + serverCert, err := cert.ToServerCertificate(ctx) + require.NoError(t, err) + require.NotEmpty(t, serverCert.CertificatePem) + require.NotEmpty(t, serverCert.PrivateKeyPem) + + newCert := allocTargetProxyCertificate() + err = newCert.fromServerCertificate(ctx, serverCert) + require.NoError(t, err) + require.Equal(t, cert.Certificate, newCert.Certificate) +} + +func TestTargetAliasProxyCertToAndFromServerCert(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, + }, + } + + cert, err := NewTargetAliasProxyCertificate(ctx, tId, alias) + require.NoError(t, err) + require.NotNil(t, cert) + serverCert, err := cert.ToServerCertificate(ctx) + require.NoError(t, err) + require.NotEmpty(t, serverCert.CertificatePem) + require.NotEmpty(t, serverCert.PrivateKeyPem) + + newCert := allocTargetAliasProxyCertificate() + err = newCert.fromServerCertificate(ctx, serverCert) + require.NoError(t, err) + require.Equal(t, cert.Certificate, newCert.Certificate) +} diff --git a/internal/target/targettest/target.go b/internal/target/targettest/target.go index 9cf126697..f1a8cf796 100644 --- a/internal/target/targettest/target.go +++ b/internal/target/targettest/target.go @@ -36,6 +36,7 @@ type Target struct { HostSource []target.HostSource `gorm:"-"` CredentialSources []target.CredentialSource `gorm:"-"` Aliases []*talias.Alias `gorm:"-"` + ServerCert *target.ServerCertificate `gorm:"-"` } var ( @@ -162,7 +163,7 @@ func (t *Target) GetStorageBucketId() string { } func (t *Target) GetProxyServerCertificate() *target.ServerCertificate { - return nil + return t.ServerCert } func (t *Target) Clone() target.Target { @@ -257,7 +258,9 @@ func (t *Target) SetEnableSessionRecording(_ bool) {} func (t *Target) SetStorageBucketId(_ string) {} -func (t *Target) SetProxyServerCertificate(*target.ServerCertificate) {} +func (t *Target) SetProxyServerCertificate(sc *target.ServerCertificate) { + t.ServerCert = sc +} func (t *Target) Oplog(op oplog.OpType) oplog.Metadata { return oplog.Metadata{