feat: use SSH host certificates (#3786)

This commit is contained in:
miampf 2025-07-01 12:47:04 +02:00 committed by GitHub
parent 95f17a6d06
commit 7ea5c41f9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 706 additions and 117 deletions

View file

@ -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",
],
)

View file

@ -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
}

View file

@ -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{