refactor: use crypto/ssh for key gen

This commit is contained in:
Julian Sommer
2026-09-16 15:24:45 +10:00
parent 2dfc9e1efb
commit 9bc3ea8658
3 changed files with 60 additions and 66 deletions
@@ -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{
+60 -52
View File
@@ -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.
@@ -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")