diff --git a/spinifex/handlers/ec2/key/service_impl.go b/spinifex/handlers/ec2/key/service_impl.go index 131aff9b6..867de11d4 100644 --- a/spinifex/handlers/ec2/key/service_impl.go +++ b/spinifex/handlers/ec2/key/service_impl.go @@ -511,6 +511,20 @@ func (s *KeyServiceImpl) GetPublicKeyMaterial(accountID, keyName string) (string return material, nil } +// describedPublicKey returns the stored key as AWS reports it: one OpenSSH +// line whose comment is the key pair's name, whatever comment was imported. +func (s *KeyServiceImpl) describedPublicKey(accountID, keyName string) (string, error) { + material, err := s.GetPublicKeyMaterial(accountID, keyName) + if err != nil { + return "", err + } + publicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(material)) + if err != nil { + return "", fmt.Errorf("parse public key %s: %w", keyName, err) + } + return fmt.Sprintf("%s %s\n", bytes.TrimSpace(ssh.MarshalAuthorizedKey(publicKey)), keyName), nil +} + // DeleteKeyPair removes a key pair (both public key and metadata from S3). func (s *KeyServiceImpl) DeleteKeyPair(ctx context.Context, input *ec2.DeleteKeyPairInput, accountID string) (*ec2.DeleteKeyPairOutput, error) { if input == nil { @@ -735,6 +749,15 @@ func (s *KeyServiceImpl) DescribeKeyPairs(ctx context.Context, input *ec2.Descri continue } + if aws.BoolValue(input.IncludePublicKey) && metadata.KeyName != nil { + publicKey, err := s.describedPublicKey(accountID, *metadata.KeyName) + if err != nil { + slog.ErrorContext(ctx, "DescribeKeyPairs: failed to read public key", "keyName", *metadata.KeyName, "err", err) + return nil, errors.New(awserrors.ErrorServerInternal) + } + keyPairInfo.PublicKey = aws.String(publicKey) + } + keyPairs = append(keyPairs, keyPairInfo) } diff --git a/spinifex/handlers/ec2/key/service_impl_test.go b/spinifex/handlers/ec2/key/service_impl_test.go index e71caedc0..ebd4e04f0 100644 --- a/spinifex/handlers/ec2/key/service_impl_test.go +++ b/spinifex/handlers/ec2/key/service_impl_test.go @@ -649,6 +649,73 @@ func TestDescribeKeyPairs_LegacyED25519Record(t *testing.T) { testLegacyED25519Fingerprint) } +// AWS returns PublicKey only when asked, as one line ending in a newline whose +// comment is the key pair's name, replacing any comment the caller imported. +func TestDescribeKeyPairs_IncludePublicKey_Imported(t *testing.T) { + svc, _ := newTestKeyService() + + _, err := svc.ImportKeyPair(context.Background(), &ec2.ImportKeyPairInput{ + KeyName: aws.String("imported"), + PublicKeyMaterial: []byte(testED25519PubKey + " tf-user@julian-wattle"), + }, testAccountID) + require.NoError(t, err) + + out, err := svc.DescribeKeyPairs(context.Background(), &ec2.DescribeKeyPairsInput{ + IncludePublicKey: aws.Bool(true), + }, testAccountID) + require.NoError(t, err) + require.Len(t, out.KeyPairs, 1) + assert.Equal(t, testED25519PubKey+" imported\n", aws.StringValue(out.KeyPairs[0].PublicKey)) + + for _, input := range []*ec2.DescribeKeyPairsInput{{}, {IncludePublicKey: aws.Bool(false)}} { + out, err := svc.DescribeKeyPairs(context.Background(), input, testAccountID) + require.NoError(t, err) + require.Len(t, out.KeyPairs, 1) + assert.Nil(t, out.KeyPairs[0].PublicKey) + } +} + +func TestDescribeKeyPairs_IncludePublicKey_Created(t *testing.T) { + svc, _ := newTestKeyService() + + for _, keyType := range []string{"ed25519", "rsa"} { + created, err := svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{ + KeyName: aws.String(keyType + "-created"), + KeyType: aws.String(keyType), + }, testAccountID) + require.NoError(t, err) + signer, err := ssh.ParsePrivateKey([]byte(aws.StringValue(created.KeyMaterial))) + require.NoError(t, err) + + out, err := svc.DescribeKeyPairs(context.Background(), &ec2.DescribeKeyPairsInput{ + KeyNames: []*string{created.KeyName}, + IncludePublicKey: aws.Bool(true), + }, testAccountID) + require.NoError(t, err) + require.Len(t, out.KeyPairs, 1) + + want := strings.TrimSuffix(string(ssh.MarshalAuthorizedKey(signer.PublicKey())), "\n") + " " + keyType + "-created\n" + assert.Equal(t, want, aws.StringValue(out.KeyPairs[0].PublicKey)) + } +} + +// A listed key whose public half cannot be read fails the call rather than +// describing it without the PublicKey that was asked for. +func TestDescribeKeyPairs_IncludePublicKey_MissingMaterial(t *testing.T) { + svc, store := newTestKeyService() + importTestKey(t, svc, "half-deleted") + _, err := store.DeleteObject(context.Background(), &s3.DeleteObjectInput{ + Bucket: aws.String(testBucket), + Key: aws.String("keys/" + testAccountID + "/half-deleted"), + }) + require.NoError(t, err) + + _, err = svc.DescribeKeyPairs(context.Background(), &ec2.DescribeKeyPairsInput{ + IncludePublicKey: aws.Bool(true), + }, testAccountID) + require.EqualError(t, err, awserrors.ErrorServerInternal) +} + func TestDescribeKeyPairs_FilterByKeyName(t *testing.T) { svc, _ := newTestKeyService()