diff --git a/spinifex/handlers/ec2/key/create_tags_intake_test.go b/spinifex/handlers/ec2/key/create_tags_intake_test.go index 31852a5a2..96f346023 100644 --- a/spinifex/handlers/ec2/key/create_tags_intake_test.go +++ b/spinifex/handlers/ec2/key/create_tags_intake_test.go @@ -22,7 +22,6 @@ func keyPairTagSpecs(resourceType string) []*ec2.TagSpecification { } func TestCreateKeyPair_TagSpecifications(t *testing.T) { - requireSSHKeygen(t) svc, _ := newTestKeyService() out, err := svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{ diff --git a/spinifex/handlers/ec2/key/service_impl.go b/spinifex/handlers/ec2/key/service_impl.go index a822b7bdd..ee92384f4 100644 --- a/spinifex/handlers/ec2/key/service_impl.go +++ b/spinifex/handlers/ec2/key/service_impl.go @@ -3,20 +3,22 @@ package handlers_ec2_key import ( "bytes" "context" - "crypto/md5" //nolint:gosec // G501: MD5 is the digest EC2 puts on the wire, not a security choice + "crypto" + "crypto/ed25519" + "crypto/md5" //nolint:gosec // G501: MD5 is the digest EC2 puts on the wire, not a security choice + "crypto/rand" + "crypto/rsa" "crypto/sha1" //nolint:gosec // G505: SHA-1 is the digest EC2 puts on the wire, not a security choice "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/hex" "encoding/json" + "encoding/pem" "errors" "fmt" "io" "log/slog" - "os" - "os/exec" - "path/filepath" "strings" "github.com/aws/aws-sdk-go/aws" @@ -33,7 +35,8 @@ import ( // Ensure KeyServiceImpl implements KeyService. var _ KeyService = (*KeyServiceImpl)(nil) -// KeyServiceImpl handles key pair operations with ssh-keygen and S3 storage. +// KeyServiceImpl handles key pair operations with in-process key generation and +// S3 storage. type KeyServiceImpl struct { config *config.Config store objectstore.ObjectStore @@ -64,7 +67,8 @@ func NewKeyServiceImplWithStore(store objectstore.ObjectStore, bucketName string } } -// CreateKeyPair generates a new SSH key pair using ssh-keygen. +// CreateKeyPair generates a new SSH key pair, storing the public half and +// returning the private half to the caller once. func (s *KeyServiceImpl) CreateKeyPair(ctx context.Context, input *ec2.CreateKeyPairInput, accountID string) (*ec2.CreateKeyPairOutput, error) { if input == nil || input.KeyName == nil { return nil, errors.New(awserrors.ErrorMissingParameter) @@ -107,58 +111,19 @@ func (s *KeyServiceImpl) CreateKeyPair(ctx context.Context, input *ec2.CreateKey } } - // Create temporary directory for key generation - tmpDir, err := os.MkdirTemp("", "spinifex-keypair-*") + // Generate the key in process; the private half never reaches local disk. + privateKeyData, publicKey, err := generateKeyPair(keyType) if err != nil { - slog.ErrorContext(ctx, "Failed to create temp directory", "err", err) - return nil, errors.New(awserrors.ErrorServerInternal) - } - defer os.RemoveAll(tmpDir) - - privateKeyPath := filepath.Join(tmpDir, "id_key") - publicKeyPath := privateKeyPath + ".pub" - - // Generate key pair using ssh-keygen - var cmd *exec.Cmd - if keyType == "ed25519" { - // ED25519 has no PEM representation, so it stays in OpenSSH format. - cmd = exec.Command("ssh-keygen", "-t", "ed25519", "-f", privateKeyPath, "-N", "", "-C", "") - } else { - // RSA 2048-bit in PKCS#1 PEM, as AWS returns it. OpenSSH reads that format - // for SSH either way, but GetPasswordData's --priv-launch-key cannot read - // the OpenSSH container at all. - cmd = exec.Command("ssh-keygen", "-t", "rsa", "-b", "2048", "-m", "PEM", "-f", privateKeyPath, "-N", "", "-C", "") - } - - var stderr bytes.Buffer - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - slog.ErrorContext(ctx, "ssh-keygen failed", "err", err, "stderr", stderr.String()) + slog.ErrorContext(ctx, "Failed to generate key pair", "keyName", keyName, "keyType", keyType, "err", err) return nil, errors.New(awserrors.ErrorServerInternal) } - // Read private key - privateKeyData, err := os.ReadFile(privateKeyPath) - if err != nil { - slog.ErrorContext(ctx, "Failed to read private key", "err", err) - return nil, errors.New(awserrors.ErrorServerInternal) - } - - // Read public key - publicKeyData, err := os.ReadFile(publicKeyPath) - if err != nil { - slog.ErrorContext(ctx, "Failed to read public key", "err", err) - return nil, errors.New(awserrors.ErrorServerInternal) - } + // The key line plus a trailing newline, the form the store serves to guests + // as their authorized_keys. + publicKeyData := ssh.MarshalAuthorizedKey(publicKey) // Fingerprint the key we just generated; the digest algorithm follows the - // key algorithm, so it is derived from the parsed key rather than keyType. - publicKey, _, _, _, err := ssh.ParseAuthorizedKey(publicKeyData) - if err != nil { - slog.ErrorContext(ctx, "Failed to parse generated public key", "err", err) - return nil, errors.New(awserrors.ErrorServerInternal) - } + // key algorithm, so it is derived from the key rather than keyType. fingerprint, err := createdKeyFingerprint(privateKeyData, publicKey) if err != nil { slog.ErrorContext(ctx, "Failed to fingerprint generated key pair", "keyName", keyName, "err", err) @@ -224,6 +189,49 @@ func (s *KeyServiceImpl) CreateKeyPair(ctx context.Context, input *ec2.CreateKey return output, nil } +// generateKeyPair generates a key pair of the given EC2 key type, returning the +// private key in the PEM container AWS hands back for that type alongside the +// public half. +func generateKeyPair(keyType string) ([]byte, ssh.PublicKey, error) { + var block *pem.Block + var cryptoPub crypto.PublicKey + + switch keyType { + case "ed25519": + // ED25519 has no PEM representation, so it stays in OpenSSH format. + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("generate ed25519 key: %w", err) + } + if block, err = ssh.MarshalPrivateKey(priv, ""); err != nil { + return nil, nil, fmt.Errorf("marshal ed25519 private key: %w", err) + } + cryptoPub = pub + + case "rsa": + // RSA 2048-bit in PKCS#1 PEM, as AWS returns it. OpenSSH reads that format + // for SSH either way, but GetPasswordData's --priv-launch-key cannot read + // the OpenSSH container at all. + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, nil, fmt.Errorf("generate rsa key: %w", err) + } + block = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)} + cryptoPub = &priv.PublicKey + + default: + // The caller has already rejected every other type, so this only guards a + // future type being generated under a container AWS does not use. + return nil, nil, fmt.Errorf("unsupported key type %q", keyType) + } + + publicKey, err := ssh.NewPublicKey(cryptoPub) + if err != nil { + return nil, nil, fmt.Errorf("wrap generated public key: %w", err) + } + return pem.EncodeToMemory(block), publicKey, nil +} + // importedKeyFingerprint fingerprints a key supplied to ImportKeyPair: the MD5 // of the DER SubjectPublicKeyInfo for RSA, and the SHA-256 of the SSH wire blob // for ED25519, both matching AWS. diff --git a/spinifex/handlers/ec2/key/service_impl_test.go b/spinifex/handlers/ec2/key/service_impl_test.go index 54da25353..1848185a4 100644 --- a/spinifex/handlers/ec2/key/service_impl_test.go +++ b/spinifex/handlers/ec2/key/service_impl_test.go @@ -8,7 +8,6 @@ import ( "encoding/base64" "encoding/json" "io" - "os/exec" "strings" "testing" @@ -132,14 +131,6 @@ func putStoredObject(t *testing.T, store objectstore.ObjectStore, key, body stri require.NoError(t, err) } -// requireSSHKeygen skips the test if ssh-keygen is not available. -func requireSSHKeygen(t *testing.T) { - t.Helper() - if _, err := exec.LookPath("ssh-keygen"); err != nil { - t.Skip("ssh-keygen not available, skipping") - } -} - // importTestKey is a helper that imports an ed25519 key and returns the output. func importTestKey(t *testing.T, svc *KeyServiceImpl, keyName string) *ec2.ImportKeyPairOutput { t.Helper() @@ -157,7 +148,6 @@ func importTestKey(t *testing.T, svc *KeyServiceImpl, keyName string) *ec2.Impor // ============================================================ func TestCreateKeyPair_ED25519(t *testing.T) { - requireSSHKeygen(t) svc, store := newTestKeyService() out, err := svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{ @@ -191,7 +181,6 @@ func TestCreateKeyPair_ED25519(t *testing.T) { } func TestCreateKeyPair_RSA(t *testing.T) { - requireSSHKeygen(t) svc, _ := newTestKeyService() out, err := svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{ @@ -251,7 +240,6 @@ func TestCreateKeyPair_InvalidKeyName(t *testing.T) { } func TestCreateKeyPair_Duplicate(t *testing.T) { - requireSSHKeygen(t) svc, _ := newTestKeyService() _, err := svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{ @@ -601,7 +589,6 @@ func TestDescribeKeyPairs_AllKeys(t *testing.T) { // KeyType is now read back from the record rather than guessed at, across every // combination of path and algorithm that writes one. func TestDescribeKeyPairs_KeyType(t *testing.T) { - requireSSHKeygen(t) svc, _ := newTestKeyService() importTestKey(t, svc, "ed25519-imported")