From 7ea5c41f9ba97b5cbcb36dd07b5d085570f0dcc9 Mon Sep 17 00:00:00 2001 From: miampf Date: Tue, 1 Jul 2025 12:47:04 +0200 Subject: [PATCH] feat: use SSH host certificates (#3786) --- .github/actions/e2e_emergency_ssh/action.yml | 10 +- .github/actions/e2e_test/action.yml | 6 +- bootstrapper/internal/addresses/BUILD.bazel | 26 +++ bootstrapper/internal/addresses/addresses.go | 45 +++++ .../internal/addresses/addresses_test.go | 67 ++++++++ bootstrapper/internal/initserver/BUILD.bazel | 3 + .../internal/initserver/initserver.go | 99 ++++++----- .../internal/initserver/initserver_test.go | 61 +++++-- bootstrapper/internal/joinclient/BUILD.bazel | 4 + .../internal/joinclient/joinclient.go | 51 +++++- .../internal/joinclient/joinclient_test.go | 160 +++++++++++++++--- cli/internal/cmd/ssh.go | 7 +- docs/docs/workflows/troubleshooting.md | 10 +- .../system-preset/30-constellation.preset | 1 - .../system/constellation-bootstrapper.service | 3 +- .../system/create-host-ssh-key.service | 10 -- image/sysroot-tree/etc/ssh/sshd_config | 5 +- .../sshd-keygen@.service.d/override.conf | 3 + .../usr/lib/systemd/system/sshd-keygen.target | 3 + .../usr/libexec/openssh/sshd-keygen | 44 +++++ internal/constants/constants.go | 8 +- .../join-service/templates/daemonset.yaml | 5 + .../join-service/templates/daemonset.yaml | 5 + .../join-service/templates/daemonset.yaml | 5 + .../join-service/templates/daemonset.yaml | 5 + .../join-service/templates/daemonset.yaml | 5 + .../join-service/templates/daemonset.yaml | 5 + internal/crypto/crypto.go | 23 +++ joinservice/cmd/main.go | 1 + joinservice/internal/server/BUILD.bazel | 4 + joinservice/internal/server/server.go | 25 +++ joinservice/internal/server/server_test.go | 63 ++++++- joinservice/joinproto/join.pb.go | 45 ++++- joinservice/joinproto/join.proto | 6 + 34 files changed, 706 insertions(+), 117 deletions(-) create mode 100644 bootstrapper/internal/addresses/BUILD.bazel create mode 100644 bootstrapper/internal/addresses/addresses.go create mode 100644 bootstrapper/internal/addresses/addresses_test.go delete mode 100644 image/base/mkosi.skeleton/usr/lib/systemd/system/create-host-ssh-key.service create mode 100644 image/sysroot-tree/etc/systemd/system/sshd-keygen@.service.d/override.conf create mode 100644 image/sysroot-tree/usr/lib/systemd/system/sshd-keygen.target create mode 100644 image/sysroot-tree/usr/libexec/openssh/sshd-keygen diff --git a/.github/actions/e2e_emergency_ssh/action.yml b/.github/actions/e2e_emergency_ssh/action.yml index 81712956a..27b3e8b13 100644 --- a/.github/actions/e2e_emergency_ssh/action.yml +++ b/.github/actions/e2e_emergency_ssh/action.yml @@ -23,19 +23,21 @@ runs: lb="$(terraform output -raw loadbalancer_address)" popd + lb_ip="$(gethostip $lb | awk '{print $2}')" + echo "Resolved ip of load balancer: $lb_ip" + # write ssh config cat > ssh_config < root@ + ssh -o CertificateFile=constellation_cert.pub -o UserKnownHostsFile=./known_hosts -i root@ ``` Normally, you don't have access to the Constellation nodes since they reside in a private network. @@ -185,16 +185,18 @@ Emergency SSH access to nodes can be useful to diagnose issues or download impor For this, use something along the following SSH client configuration: ```text - Host + Host ProxyJump none Host * IdentityFile PreferredAuthentications publickey CertificateFile=constellation_cert.pub + UserKnownHostsFile=./known_hosts User root - ProxyJump + ProxyJump ``` With this configuration you can connect to a Constellation node using `ssh -F `. - You can obtain the private node IP and the domain name of the load balancer using your CSP's web UI. + You can obtain the private node IP and the public IP of the load balancer using your CSP's web UI. Note that if + you use the load balancers domain name, ssh host certificate verification doesn't work, so using the public IP is recommended. diff --git a/image/base/mkosi.skeleton/usr/lib/systemd/system-preset/30-constellation.preset b/image/base/mkosi.skeleton/usr/lib/systemd/system-preset/30-constellation.preset index 493434d54..dcabbedd9 100644 --- a/image/base/mkosi.skeleton/usr/lib/systemd/system-preset/30-constellation.preset +++ b/image/base/mkosi.skeleton/usr/lib/systemd/system-preset/30-constellation.preset @@ -10,4 +10,3 @@ enable measurements.service enable export_constellation_debug.service enable systemd-timesyncd enable udev-trigger.service -enable create-host-ssh-key.service diff --git a/image/base/mkosi.skeleton/usr/lib/systemd/system/constellation-bootstrapper.service b/image/base/mkosi.skeleton/usr/lib/systemd/system/constellation-bootstrapper.service index cf93df780..30ca0acfe 100644 --- a/image/base/mkosi.skeleton/usr/lib/systemd/system/constellation-bootstrapper.service +++ b/image/base/mkosi.skeleton/usr/lib/systemd/system/constellation-bootstrapper.service @@ -1,7 +1,8 @@ [Unit] Description=Constellation Bootstrapper Wants=network-online.target -After=network-online.target configure-constel-csp.service +Requires=sshd-keygen.target +After=network-online.target configure-constel-csp.service sshd-keygen.target After=export_constellation_debug.service [Service] diff --git a/image/base/mkosi.skeleton/usr/lib/systemd/system/create-host-ssh-key.service b/image/base/mkosi.skeleton/usr/lib/systemd/system/create-host-ssh-key.service deleted file mode 100644 index 28a0862e7..000000000 --- a/image/base/mkosi.skeleton/usr/lib/systemd/system/create-host-ssh-key.service +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=Create a host SSH key -Before=network-pre.target - -[Service] -Type=oneshot -ExecStart=/bin/bash -c "mkdir -p /run/ssh; ssh-keygen -t ecdsa -q -N '' -f /run/ssh/ssh_host_ecdsa_key" - -[Install] -WantedBy=network-pre.target diff --git a/image/sysroot-tree/etc/ssh/sshd_config b/image/sysroot-tree/etc/ssh/sshd_config index dec4fd51d..39016f323 100644 --- a/image/sysroot-tree/etc/ssh/sshd_config +++ b/image/sysroot-tree/etc/ssh/sshd_config @@ -1,4 +1,5 @@ -HostKey /run/ssh/ssh_host_ecdsa_key -TrustedUserCAKeys /run/ssh/ssh_ca.pub +HostKey /var/run/state/ssh/ssh_host_ed25519_key +HostCertificate /var/run/state/ssh/ssh_host_cert.pub +TrustedUserCAKeys /var/run/state/ssh/ssh_ca.pub PasswordAuthentication no ChallengeResponseAuthentication no diff --git a/image/sysroot-tree/etc/systemd/system/sshd-keygen@.service.d/override.conf b/image/sysroot-tree/etc/systemd/system/sshd-keygen@.service.d/override.conf new file mode 100644 index 000000000..1e956c08b --- /dev/null +++ b/image/sysroot-tree/etc/systemd/system/sshd-keygen@.service.d/override.conf @@ -0,0 +1,3 @@ +[Unit] +ConditionFileNotEmpty=|!/var/run/state/ssh/ssh_host_%i_key +Before=constellation-bootstrapper.service diff --git a/image/sysroot-tree/usr/lib/systemd/system/sshd-keygen.target b/image/sysroot-tree/usr/lib/systemd/system/sshd-keygen.target new file mode 100644 index 000000000..3c4dd2b1c --- /dev/null +++ b/image/sysroot-tree/usr/lib/systemd/system/sshd-keygen.target @@ -0,0 +1,3 @@ +[Unit] +Wants=sshd-keygen@ed25519.service +PartOf=sshd.service diff --git a/image/sysroot-tree/usr/libexec/openssh/sshd-keygen b/image/sysroot-tree/usr/libexec/openssh/sshd-keygen new file mode 100644 index 000000000..c366b0d0a --- /dev/null +++ b/image/sysroot-tree/usr/libexec/openssh/sshd-keygen @@ -0,0 +1,44 @@ +#!/usr/bin/bash +# Taken from the original openssh-server package and slightly modified + +set -x + +# Create the host keys for the OpenSSH server. +KEYTYPE=$1 +case $KEYTYPE in +"dsa") ;& # disabled in FIPS +"ed25519") + FIPS=/proc/sys/crypto/fips_enabled + if [[ -r $FIPS && $(cat $FIPS) == "1" ]]; then + exit 0 + fi + ;; +"rsa") ;; # always ok +"ecdsa") ;; +*) # wrong argument + exit 12 ;; +esac +mkdir -p /var/run/state/ssh +KEY=/var/run/state/ssh/ssh_host_${KEYTYPE}_key + +KEYGEN=/usr/bin/ssh-keygen +if [[ ! -x $KEYGEN ]]; then + exit 13 +fi + +# remove old keys +rm -f "$KEY"{,.pub} + +# create new keys +if ! $KEYGEN -q -t "$KEYTYPE" -f "$KEY" -C '' -N '' >&/dev/null; then + exit 1 +fi + +# sanitize permissions +/usr/bin/chmod 600 "$KEY" +/usr/bin/chmod 644 "$KEY".pub +if [[ -x /usr/sbin/restorecon ]]; then + /usr/sbin/restorecon "$KEY"{,.pub} +fi + +exit 0 diff --git a/internal/constants/constants.go b/internal/constants/constants.go index aecef23c8..c86a3883f 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -45,7 +45,13 @@ const ( // SSHCAKeySuffix is the suffix used together with the DEKPrefix to derive an SSH CA key for emergency ssh access. SSHCAKeySuffix = "ca_emergency_ssh" // SSHCAKeyPath is the path to the emergency SSH CA key on the node. - SSHCAKeyPath = "/run/ssh/ssh_ca.pub" + SSHCAKeyPath = "/var/run/state/ssh/ssh_ca.pub" + // SSHHostKeyPath is the path to the SSH host key of the node. + SSHHostKeyPath = "/var/run/state/ssh/ssh_host_ed25519_key" + // SSHHostCertificatePath is the path to the SSH host certificate. + SSHHostCertificatePath = "/var/run/state/ssh/ssh_host_cert.pub" + // SSHAdditionalPrincipalsPath stores additional principals (like the public IP of the load balancer) that get added to all host certificates. + SSHAdditionalPrincipalsPath = "/var/run/state/ssh/additional_principals.txt" // // Ports. diff --git a/internal/constellation/helm/charts/edgeless/constellation-services/charts/join-service/templates/daemonset.yaml b/internal/constellation/helm/charts/edgeless/constellation-services/charts/join-service/templates/daemonset.yaml index 5eed603c5..a7bfe1656 100644 --- a/internal/constellation/helm/charts/edgeless/constellation-services/charts/join-service/templates/daemonset.yaml +++ b/internal/constellation/helm/charts/edgeless/constellation-services/charts/join-service/templates/daemonset.yaml @@ -53,6 +53,8 @@ spec: - mountPath: /var/secrets/google name: gcekey readOnly: true + - mountPath: /var/run/state/ssh + name: ssh ports: - containerPort: {{ .Values.joinServicePort }} name: tcp @@ -74,4 +76,7 @@ spec: - name: kubeadm hostPath: path: /etc/kubernetes + - name: ssh + hostPath: + path: /var/run/state/ssh updateStrategy: {} diff --git a/internal/constellation/helm/testdata/AWS/constellation-services/charts/join-service/templates/daemonset.yaml b/internal/constellation/helm/testdata/AWS/constellation-services/charts/join-service/templates/daemonset.yaml index 7c65a887f..046ae08e6 100644 --- a/internal/constellation/helm/testdata/AWS/constellation-services/charts/join-service/templates/daemonset.yaml +++ b/internal/constellation/helm/testdata/AWS/constellation-services/charts/join-service/templates/daemonset.yaml @@ -53,6 +53,8 @@ spec: - mountPath: /var/secrets/google name: gcekey readOnly: true + - mountPath: /var/run/state/ssh + name: ssh ports: - containerPort: 9090 name: tcp @@ -74,4 +76,7 @@ spec: - name: kubeadm hostPath: path: /etc/kubernetes + - name: ssh + hostPath: + path: /var/run/state/ssh updateStrategy: {} diff --git a/internal/constellation/helm/testdata/Azure/constellation-services/charts/join-service/templates/daemonset.yaml b/internal/constellation/helm/testdata/Azure/constellation-services/charts/join-service/templates/daemonset.yaml index 361089daa..80e151886 100644 --- a/internal/constellation/helm/testdata/Azure/constellation-services/charts/join-service/templates/daemonset.yaml +++ b/internal/constellation/helm/testdata/Azure/constellation-services/charts/join-service/templates/daemonset.yaml @@ -53,6 +53,8 @@ spec: - mountPath: /var/secrets/google name: gcekey readOnly: true + - mountPath: /var/run/state/ssh + name: ssh ports: - containerPort: 9090 name: tcp @@ -74,4 +76,7 @@ spec: - name: kubeadm hostPath: path: /etc/kubernetes + - name: ssh + hostPath: + path: /var/run/state/ssh updateStrategy: {} diff --git a/internal/constellation/helm/testdata/GCP/constellation-services/charts/join-service/templates/daemonset.yaml b/internal/constellation/helm/testdata/GCP/constellation-services/charts/join-service/templates/daemonset.yaml index d50416871..c16d77b4f 100644 --- a/internal/constellation/helm/testdata/GCP/constellation-services/charts/join-service/templates/daemonset.yaml +++ b/internal/constellation/helm/testdata/GCP/constellation-services/charts/join-service/templates/daemonset.yaml @@ -53,6 +53,8 @@ spec: - mountPath: /var/secrets/google name: gcekey readOnly: true + - mountPath: /var/run/state/ssh + name: ssh ports: - containerPort: 9090 name: tcp @@ -74,4 +76,7 @@ spec: - name: kubeadm hostPath: path: /etc/kubernetes + - name: ssh + hostPath: + path: /var/run/state/ssh updateStrategy: {} diff --git a/internal/constellation/helm/testdata/OpenStack/constellation-services/charts/join-service/templates/daemonset.yaml b/internal/constellation/helm/testdata/OpenStack/constellation-services/charts/join-service/templates/daemonset.yaml index 96258cbe2..c77b395b1 100644 --- a/internal/constellation/helm/testdata/OpenStack/constellation-services/charts/join-service/templates/daemonset.yaml +++ b/internal/constellation/helm/testdata/OpenStack/constellation-services/charts/join-service/templates/daemonset.yaml @@ -53,6 +53,8 @@ spec: - mountPath: /var/secrets/google name: gcekey readOnly: true + - mountPath: /var/run/state/ssh + name: ssh ports: - containerPort: 9090 name: tcp @@ -74,4 +76,7 @@ spec: - name: kubeadm hostPath: path: /etc/kubernetes + - name: ssh + hostPath: + path: /var/run/state/ssh updateStrategy: {} diff --git a/internal/constellation/helm/testdata/QEMU/constellation-services/charts/join-service/templates/daemonset.yaml b/internal/constellation/helm/testdata/QEMU/constellation-services/charts/join-service/templates/daemonset.yaml index b1db9147f..7e8443eb4 100644 --- a/internal/constellation/helm/testdata/QEMU/constellation-services/charts/join-service/templates/daemonset.yaml +++ b/internal/constellation/helm/testdata/QEMU/constellation-services/charts/join-service/templates/daemonset.yaml @@ -53,6 +53,8 @@ spec: - mountPath: /var/secrets/google name: gcekey readOnly: true + - mountPath: /var/run/state/ssh + name: ssh ports: - containerPort: 9090 name: tcp @@ -74,4 +76,7 @@ spec: - name: kubeadm hostPath: path: /etc/kubernetes + - name: ssh + hostPath: + path: /var/run/state/ssh updateStrategy: {} diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index 0a88ec2f5..e367b67ab 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -17,6 +17,7 @@ import ( "fmt" "io" "math/big" + "time" "golang.org/x/crypto/hkdf" "golang.org/x/crypto/ssh" @@ -77,6 +78,28 @@ func GenerateEmergencySSHCAKey(seed []byte) (ssh.Signer, error) { return ca, nil } +// GenerateSSHHostCertificate takes a given public key and CA to generate a host certificate. +func GenerateSSHHostCertificate(principals []string, publicKey ssh.PublicKey, ca ssh.Signer) (*ssh.Certificate, error) { + certificate := ssh.Certificate{ + CertType: ssh.HostCert, + ValidPrincipals: principals, + ValidAfter: uint64(time.Now().Unix()), + ValidBefore: ssh.CertTimeInfinity, + Reserved: []byte{}, + Key: publicKey, + KeyId: principals[0], + Permissions: ssh.Permissions{ + CriticalOptions: map[string]string{}, + Extensions: map[string]string{}, + }, + } + if err := certificate.SignCert(rand.Reader, ca); err != nil { + return nil, err + } + + return &certificate, nil +} + // PemToX509Cert takes a list of PEM-encoded certificates, parses the first one and returns it // as an x.509 certificate. func PemToX509Cert(raw []byte) (*x509.Certificate, error) { diff --git a/joinservice/cmd/main.go b/joinservice/cmd/main.go index 8aaab9654..6df8ca3d8 100644 --- a/joinservice/cmd/main.go +++ b/joinservice/cmd/main.go @@ -116,6 +116,7 @@ func main() { keyServiceClient, kubeClient, log.WithGroup("server"), + file.NewHandler(afero.NewOsFs()), ) if err != nil { log.With(slog.Any("error", err)).Error("Failed to create server") diff --git a/joinservice/internal/server/BUILD.bazel b/joinservice/internal/server/BUILD.bazel index eed06e663..c7835f7ef 100644 --- a/joinservice/internal/server/BUILD.bazel +++ b/joinservice/internal/server/BUILD.bazel @@ -10,6 +10,7 @@ go_library( "//internal/attestation", "//internal/constants", "//internal/crypto", + "//internal/file", "//internal/grpc/grpclog", "//internal/logger", "//internal/versions/components", @@ -30,12 +31,15 @@ go_test( deps = [ "//internal/attestation", "//internal/constants", + "//internal/file", "//internal/logger", "//internal/versions/components", "//joinservice/joinproto", + "@com_github_spf13_afero//:afero", "@com_github_stretchr_testify//assert", "@com_github_stretchr_testify//require", "@io_k8s_kubernetes//cmd/kubeadm/app/apis/kubeadm/v1beta3", + "@org_golang_x_crypto//ssh", "@org_uber_go_goleak//:goleak", ], ) diff --git a/joinservice/internal/server/server.go b/joinservice/internal/server/server.go index e6fc82b95..12d03e1ca 100644 --- a/joinservice/internal/server/server.go +++ b/joinservice/internal/server/server.go @@ -13,11 +13,13 @@ import ( "fmt" "log/slog" "net" + "strings" "time" "github.com/edgelesssys/constellation/v2/internal/attestation" "github.com/edgelesssys/constellation/v2/internal/constants" "github.com/edgelesssys/constellation/v2/internal/crypto" + "github.com/edgelesssys/constellation/v2/internal/file" "github.com/edgelesssys/constellation/v2/internal/grpc/grpclog" "github.com/edgelesssys/constellation/v2/internal/logger" "github.com/edgelesssys/constellation/v2/internal/versions/components" @@ -40,6 +42,7 @@ type Server struct { dataKeyGetter dataKeyGetter ca certificateAuthority kubeClient kubeClient + fileHandler file.Handler joinproto.UnimplementedAPIServer } @@ -47,6 +50,7 @@ type Server struct { func New( measurementSalt []byte, ca certificateAuthority, joinTokenGetter joinTokenGetter, dataKeyGetter dataKeyGetter, kubeClient kubeClient, log *slog.Logger, + fileHandler file.Handler, ) (*Server, error) { return &Server{ measurementSalt: measurementSalt, @@ -55,6 +59,7 @@ func New( dataKeyGetter: dataKeyGetter, ca: ca, kubeClient: kubeClient, + fileHandler: fileHandler, }, nil } @@ -114,6 +119,25 @@ func (s *Server) IssueJoinTicket(ctx context.Context, req *joinproto.IssueJoinTi return nil, status.Errorf(codes.Internal, "generating ssh emergency CA key: %s", err) } + principalList := req.HostCertificatePrincipals + additionalPrincipals, err := s.fileHandler.Read(constants.SSHAdditionalPrincipalsPath) + if err != nil { + log.With(slog.Any("error", err)).Error("Failed to read additional principals file") + return nil, status.Errorf(codes.Internal, "reading additional principals file: %s", err) + } + principalList = append(principalList, strings.Split(string(additionalPrincipals), ",")...) + + publicKey, err := ssh.ParsePublicKey(req.HostPublicKey) + if err != nil { + log.With(slog.Any("error", err)).Error("Failed to parse host public key") + return nil, status.Errorf(codes.Internal, "unmarshalling host public key: %s", err) + } + hostCertificate, err := crypto.GenerateSSHHostCertificate(principalList, publicKey, ca) + if err != nil { + log.With(slog.Any("error", err)).Error("Failed to generate and sign SSH host key") + return nil, status.Errorf(codes.Internal, "generating and signing SSH host key: %s", err) + } + log.Info("Creating Kubernetes join token") kubeArgs, err := s.joinTokenGetter.GetJoinToken(constants.KubernetesJoinTokenTTL) if err != nil { @@ -182,6 +206,7 @@ func (s *Server) IssueJoinTicket(ctx context.Context, req *joinproto.IssueJoinTi ControlPlaneFiles: controlPlaneFiles, KubernetesComponents: components, AuthorizedCaPublicKey: ssh.MarshalAuthorizedKey(ca.PublicKey()), + HostCertificate: ssh.MarshalAuthorizedKey(hostCertificate), }, nil } diff --git a/joinservice/internal/server/server_test.go b/joinservice/internal/server/server_test.go index 4fbf0c5b6..63e852d19 100644 --- a/joinservice/internal/server/server_test.go +++ b/joinservice/internal/server/server_test.go @@ -15,12 +15,15 @@ import ( "github.com/edgelesssys/constellation/v2/internal/attestation" "github.com/edgelesssys/constellation/v2/internal/constants" + "github.com/edgelesssys/constellation/v2/internal/file" "github.com/edgelesssys/constellation/v2/internal/logger" "github.com/edgelesssys/constellation/v2/internal/versions/components" "github.com/edgelesssys/constellation/v2/joinservice/joinproto" + "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" + "golang.org/x/crypto/ssh" kubeadmv1 "k8s.io/kubernetes/cmd/kubeadm/app/apis/kubeadm/v1beta3" ) @@ -36,6 +39,11 @@ func TestIssueJoinTicket(t *testing.T) { measurementSecret := []byte{0x7, 0x8, 0x9} uuid := "uuid" + pubkey, _, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + hostSSHPubKey, err := ssh.NewPublicKey(pubkey) + require.NoError(t, err) + testJoinToken := &kubeadmv1.BootstrapTokenDiscovery{ APIServerEndpoint: "192.0.2.1", CACertHashes: []string{"hash"}, @@ -52,13 +60,15 @@ func TestIssueJoinTicket(t *testing.T) { } testCases := map[string]struct { - isControlPlane bool - kubeadm stubTokenGetter - kms stubKeyGetter - ca stubCA - kubeClient stubKubeClient - missingComponentsReferenceFile bool - wantErr bool + isControlPlane bool + kubeadm stubTokenGetter + kms stubKeyGetter + ca stubCA + kubeClient stubKubeClient + missingComponentsReferenceFile bool + missingAdditionalPrincipalsFile bool + missingSSHHostKey bool + wantErr bool }{ "worker node": { kubeadm: stubTokenGetter{token: testJoinToken}, @@ -179,6 +189,30 @@ func TestIssueJoinTicket(t *testing.T) { kubeClient: stubKubeClient{getComponentsVal: clusterComponents, getK8sComponentsRefFromNodeVersionCRDVal: "k8s-components-ref"}, wantErr: true, }, + "Additional principals file is missing": { + kubeadm: stubTokenGetter{token: testJoinToken}, + kms: stubKeyGetter{dataKeys: map[string][]byte{ + uuid: testKey, + attestation.MeasurementSecretContext: measurementSecret, + constants.SSHCAKeySuffix: testCaKey, + }}, + ca: stubCA{cert: testCert, nodeName: "node"}, + kubeClient: stubKubeClient{getComponentsVal: clusterComponents, getK8sComponentsRefFromNodeVersionCRDVal: "k8s-components-ref"}, + missingAdditionalPrincipalsFile: true, + wantErr: true, + }, + "Host pubkey is missing": { + kubeadm: stubTokenGetter{token: testJoinToken}, + kms: stubKeyGetter{dataKeys: map[string][]byte{ + uuid: testKey, + attestation.MeasurementSecretContext: measurementSecret, + constants.SSHCAKeySuffix: testCaKey, + }}, + ca: stubCA{cert: testCert, nodeName: "node"}, + kubeClient: stubKubeClient{getComponentsVal: clusterComponents, getK8sComponentsRefFromNodeVersionCRDVal: "k8s-components-ref"}, + missingSSHHostKey: true, + wantErr: true, + }, } for name, tc := range testCases { @@ -188,6 +222,11 @@ func TestIssueJoinTicket(t *testing.T) { salt := []byte{0xA, 0xB, 0xC} + fh := file.NewHandler(afero.NewMemMapFs()) + if !tc.missingAdditionalPrincipalsFile { + require.NoError(fh.Write(constants.SSHAdditionalPrincipalsPath, []byte("*"), file.OptMkdirAll)) + } + api := Server{ measurementSalt: salt, ca: tc.ca, @@ -195,11 +234,20 @@ func TestIssueJoinTicket(t *testing.T) { dataKeyGetter: tc.kms, kubeClient: &tc.kubeClient, log: logger.NewTest(t), + fileHandler: fh, + } + + var keyToSend []byte + if tc.missingSSHHostKey { + keyToSend = nil + } else { + keyToSend = hostSSHPubKey.Marshal() } req := &joinproto.IssueJoinTicketRequest{ DiskUuid: "uuid", IsControlPlane: tc.isControlPlane, + HostPublicKey: keyToSend, } resp, err := api.IssueJoinTicket(t.Context(), req) if tc.wantErr { @@ -260,6 +308,7 @@ func TestIssueRejoinTicker(t *testing.T) { joinTokenGetter: stubTokenGetter{}, dataKeyGetter: tc.keyGetter, log: logger.NewTest(t), + fileHandler: file.NewHandler(afero.NewMemMapFs()), } req := &joinproto.IssueRejoinTicketRequest{ diff --git a/joinservice/joinproto/join.pb.go b/joinservice/joinproto/join.pb.go index 9c8af76d7..a620ccbd5 100644 --- a/joinservice/joinproto/join.pb.go +++ b/joinservice/joinproto/join.pb.go @@ -27,12 +27,14 @@ const ( ) type IssueJoinTicketRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - DiskUuid string `protobuf:"bytes,1,opt,name=disk_uuid,json=diskUuid,proto3" json:"disk_uuid,omitempty"` - CertificateRequest []byte `protobuf:"bytes,2,opt,name=certificate_request,json=certificateRequest,proto3" json:"certificate_request,omitempty"` - IsControlPlane bool `protobuf:"varint,3,opt,name=is_control_plane,json=isControlPlane,proto3" json:"is_control_plane,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + DiskUuid string `protobuf:"bytes,1,opt,name=disk_uuid,json=diskUuid,proto3" json:"disk_uuid,omitempty"` + CertificateRequest []byte `protobuf:"bytes,2,opt,name=certificate_request,json=certificateRequest,proto3" json:"certificate_request,omitempty"` + IsControlPlane bool `protobuf:"varint,3,opt,name=is_control_plane,json=isControlPlane,proto3" json:"is_control_plane,omitempty"` + HostPublicKey []byte `protobuf:"bytes,4,opt,name=host_public_key,json=hostPublicKey,proto3" json:"host_public_key,omitempty"` + HostCertificatePrincipals []string `protobuf:"bytes,5,rep,name=host_certificate_principals,json=hostCertificatePrincipals,proto3" json:"host_certificate_principals,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *IssueJoinTicketRequest) Reset() { @@ -86,6 +88,20 @@ func (x *IssueJoinTicketRequest) GetIsControlPlane() bool { return false } +func (x *IssueJoinTicketRequest) GetHostPublicKey() []byte { + if x != nil { + return x.HostPublicKey + } + return nil +} + +func (x *IssueJoinTicketRequest) GetHostCertificatePrincipals() []string { + if x != nil { + return x.HostCertificatePrincipals + } + return nil +} + type IssueJoinTicketResponse struct { state protoimpl.MessageState `protogen:"open.v1"` StateDiskKey []byte `protobuf:"bytes,1,opt,name=state_disk_key,json=stateDiskKey,proto3" json:"state_disk_key,omitempty"` @@ -99,6 +115,7 @@ type IssueJoinTicketResponse struct { KubernetesVersion string `protobuf:"bytes,9,opt,name=kubernetes_version,json=kubernetesVersion,proto3" json:"kubernetes_version,omitempty"` KubernetesComponents []*components.Component `protobuf:"bytes,10,rep,name=kubernetes_components,json=kubernetesComponents,proto3" json:"kubernetes_components,omitempty"` AuthorizedCaPublicKey []byte `protobuf:"bytes,11,opt,name=authorized_ca_public_key,json=authorizedCaPublicKey,proto3" json:"authorized_ca_public_key,omitempty"` + HostCertificate []byte `protobuf:"bytes,12,opt,name=host_certificate,json=hostCertificate,proto3" json:"host_certificate,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -210,6 +227,13 @@ func (x *IssueJoinTicketResponse) GetAuthorizedCaPublicKey() []byte { return nil } +func (x *IssueJoinTicketResponse) GetHostCertificate() []byte { + if x != nil { + return x.HostCertificate + } + return nil +} + type ControlPlaneCertOrKey struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -362,11 +386,13 @@ var File_joinservice_joinproto_join_proto protoreflect.FileDescriptor const file_joinservice_joinproto_join_proto_rawDesc = "" + "\n" + - " joinservice/joinproto/join.proto\x12\x04join\x1a-internal/versions/components/components.proto\"\x90\x01\n" + + " joinservice/joinproto/join.proto\x12\x04join\x1a-internal/versions/components/components.proto\"\xf8\x01\n" + "\x16IssueJoinTicketRequest\x12\x1b\n" + "\tdisk_uuid\x18\x01 \x01(\tR\bdiskUuid\x12/\n" + "\x13certificate_request\x18\x02 \x01(\fR\x12certificateRequest\x12(\n" + - "\x10is_control_plane\x18\x03 \x01(\bR\x0eisControlPlane\"\xc7\x04\n" + + "\x10is_control_plane\x18\x03 \x01(\bR\x0eisControlPlane\x12&\n" + + "\x0fhost_public_key\x18\x04 \x01(\fR\rhostPublicKey\x12>\n" + + "\x1bhost_certificate_principals\x18\x05 \x03(\tR\x19hostCertificatePrincipals\"\xf2\x04\n" + "\x17IssueJoinTicketResponse\x12$\n" + "\x0estate_disk_key\x18\x01 \x01(\fR\fstateDiskKey\x12)\n" + "\x10measurement_salt\x18\x02 \x01(\fR\x0fmeasurementSalt\x12-\n" + @@ -379,7 +405,8 @@ const file_joinservice_joinproto_join_proto_rawDesc = "" + "\x12kubernetes_version\x18\t \x01(\tR\x11kubernetesVersion\x12J\n" + "\x15kubernetes_components\x18\n" + " \x03(\v2\x15.components.ComponentR\x14kubernetesComponents\x127\n" + - "\x18authorized_ca_public_key\x18\v \x01(\fR\x15authorizedCaPublicKey\"C\n" + + "\x18authorized_ca_public_key\x18\v \x01(\fR\x15authorizedCaPublicKey\x12)\n" + + "\x10host_certificate\x18\f \x01(\fR\x0fhostCertificate\"C\n" + "\x19control_plane_cert_or_key\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + "\x04data\x18\x02 \x01(\fR\x04data\"7\n" + diff --git a/joinservice/joinproto/join.proto b/joinservice/joinproto/join.proto index 89c40b8a0..eed1163a6 100644 --- a/joinservice/joinproto/join.proto +++ b/joinservice/joinproto/join.proto @@ -20,6 +20,10 @@ message IssueJoinTicketRequest { bytes certificate_request = 2; // is_control_plane indicates whether the node is a control-plane node. bool is_control_plane = 3; + // host_public_key is the public host key that should be signed. + bytes host_public_key = 4; + // host_certificate_principals are principals that should be added to the host certificate. + repeated string host_certificate_principals = 5; } message IssueJoinTicketResponse { @@ -47,6 +51,8 @@ message IssueJoinTicketResponse { repeated components.Component kubernetes_components = 10; // authorized_ca_public_key is an ssh ca key that can be used to connect to a node in case of an emergency. bytes authorized_ca_public_key = 11; + // host_certificate is the certificate that can be used to verify a nodes host key. + bytes host_certificate = 12; } message control_plane_cert_or_key {