fix(ec2): render ED25519 key fingerprints as EC2 does (#571)

This commit is contained in:
Julian Sommer
2026-07-22 16:42:38 +10:00
committed by GitHub
parent a3885db916
commit c054b0213e
5 changed files with 377 additions and 88 deletions
+75
View File
@@ -0,0 +1,75 @@
package handlers_ec2_key
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"strings"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
)
// openSSHSHA256Prefix is what ssh.FingerprintSHA256 puts in front of the digest.
// EC2 never emits it, so a stored fingerprint carrying it was written by the
// earlier code that rendered ED25519 keys the OpenSSH way.
const openSSHSHA256Prefix = "SHA256:"
// keyPairMetadata is the stored form of a key pair, written to
// keys/<accountID>/<keyPairID>.json. The JSON keys match the field names
// ec2.CreateKeyPairOutput marshalled under, which this record was serialised as
// before it had a type of its own, so records already on disk still decode. An
// empty KeyType is exactly that case, and is what triggers the legacy upgrade in
// decodeKeyPairMetadata.
type keyPairMetadata struct {
KeyFingerprint *string `json:"KeyFingerprint"`
KeyName *string `json:"KeyName"`
KeyPairId *string `json:"KeyPairId"`
KeyType string `json:"KeyType,omitempty"`
Tags []*ec2.Tag `json:"Tags"`
}
// decodeKeyPairMetadata unmarshals a stored record and upgrades it to current
// form. Records written before KeyType was persisted are typed by the "SHA256:"
// prefix their ED25519 fingerprints still carry, and that fingerprint is then
// re-rendered in the AWS form. Resolving the type before rewriting the value it
// was inferred from is what keeps the upgrade lossless.
func decodeKeyPairMetadata(body []byte) (*keyPairMetadata, error) {
var metadata keyPairMetadata
if err := json.Unmarshal(body, &metadata); err != nil {
return nil, err
}
// A record that names its own type was written by code that also renders
// fingerprints as EC2 does, so there is nothing to upgrade.
if metadata.KeyType != "" {
return &metadata, nil
}
// Only ED25519 was ever stored prefixed; every other legacy fingerprint is
// the colon hex of an RSA digest and is left exactly as it was found.
if metadata.KeyFingerprint != nil && strings.HasPrefix(*metadata.KeyFingerprint, openSSHSHA256Prefix) {
metadata.KeyType = "ed25519"
metadata.KeyFingerprint = aws.String(awsFingerprint(*metadata.KeyFingerprint))
} else {
metadata.KeyType = "rsa"
}
return &metadata, nil
}
// awsFingerprint re-renders an OpenSSH SHA-256 fingerprint the way EC2 spells
// it: the "SHA256:" prefix dropped and the base64 padding OpenSSH omits
// restored. Anything that is not a raw base64 SHA-256 digest is corruption
// rather than a legacy rendering, and is returned verbatim instead of guessed
// at. The length check is what makes that true of a truncated value: a short
// body still decodes cleanly, so without it a record holding "SHA256:" alone
// would re-render as the empty string and the next write would persist that
// over the stored value.
func awsFingerprint(fingerprint string) string {
digest, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(fingerprint, openSSHSHA256Prefix))
if err != nil || len(digest) != sha256.Size {
return fingerprint
}
return base64.StdEncoding.EncodeToString(digest)
}
+124
View File
@@ -0,0 +1,124 @@
package handlers_ec2_key
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The two ED25519 fingerprints AWS itself returned on 2026-07-22 (account
// 744090693897, us-east-1), one per write path. They are recorded observations,
// not derivations, and they pin the rendering without needing the key material
// that produced them.
const (
awsImportedED25519Fingerprint = "8ZVMcTKqdR5hxMjZI3t6b09EPIOE5QA6G9/Z63ejNBM="
awsCreatedED25519Fingerprint = "odzmoB4gyxi1jn9vGD4c+bOpt1XUX1JUyaHaWHHCO2w="
)
// A legacy ED25519 record is typed from the prefix and then re-rendered as the
// AWS values above spell it. Each stored value is the OpenSSH rendering of the
// very digest AWS reported for that key pair.
func TestDecodeKeyPairMetadata_LegacyED25519(t *testing.T) {
tests := []struct {
name string
stored string
want string
}{
{
name: "imported",
stored: "SHA256:8ZVMcTKqdR5hxMjZI3t6b09EPIOE5QA6G9/Z63ejNBM",
want: awsImportedED25519Fingerprint,
},
{
name: "created",
stored: "SHA256:odzmoB4gyxi1jn9vGD4c+bOpt1XUX1JUyaHaWHHCO2w",
want: awsCreatedED25519Fingerprint,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
metadata, err := decodeKeyPairMetadata([]byte(`{"KeyFingerprint":"` + tt.stored + `","KeyName":"k","KeyPairId":"key-1"}`))
require.NoError(t, err)
assert.Equal(t, "ed25519", metadata.KeyType)
assert.Equal(t, tt.want, *metadata.KeyFingerprint)
})
}
}
// A record that names its own type is left exactly as stored, whatever its
// fingerprint looks like: it was written by code that already renders as EC2
// does, so re-deriving either field could only lose information.
func TestDecodeKeyPairMetadata_KeyTypeWins(t *testing.T) {
tests := []struct {
name string
keyType string
fingerprint string
}{
{name: "ED25519", keyType: "ed25519", fingerprint: awsImportedED25519Fingerprint},
{name: "RSA", keyType: "rsa", fingerprint: testRSAImportedFingerprint},
{name: "PrefixedNotRewritten", keyType: "ed25519", fingerprint: testLegacyED25519Fingerprint},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
metadata, err := decodeKeyPairMetadata([]byte(
`{"KeyFingerprint":"` + tt.fingerprint + `","KeyName":"k","KeyPairId":"key-1","KeyType":"` + tt.keyType + `"}`))
require.NoError(t, err)
assert.Equal(t, tt.keyType, metadata.KeyType)
assert.Equal(t, tt.fingerprint, *metadata.KeyFingerprint)
})
}
}
// Legacy RSA records carry no prefix, so they type as rsa and their colon hex is
// left untouched -- at both digest widths, 16 bytes imported and 20 created.
func TestDecodeKeyPairMetadata_LegacyRSA(t *testing.T) {
for name, fingerprint := range map[string]string{
"imported": testRSAImportedFingerprint,
"created": testRSACreatedFingerprint,
} {
t.Run(name, func(t *testing.T) {
metadata, err := decodeKeyPairMetadata([]byte(`{"KeyFingerprint":"` + fingerprint + `","KeyName":"k","KeyPairId":"key-1"}`))
require.NoError(t, err)
assert.Equal(t, "rsa", metadata.KeyType)
assert.Equal(t, fingerprint, *metadata.KeyFingerprint)
})
}
}
// A prefixed value that is not a SHA-256 digest is corruption, not a legacy
// rendering, and is reported back verbatim so the corruption stays visible. The
// truncated cases are the ones that matter: they decode cleanly, so nothing but
// the digest length distinguishes them from a value worth re-rendering, and
// re-rendering them would persist the mangled result on the next tag write.
func TestDecodeKeyPairMetadata_CorruptFingerprint(t *testing.T) {
for name, fingerprint := range map[string]string{
"NotBase64": "SHA256:not valid base64!",
"AlreadyPadded": openSSHSHA256Prefix + testED25519Fingerprint,
"EmptyBody": openSSHSHA256Prefix,
"TruncatedBody": "SHA256:+DiY3wvv",
} {
t.Run(name, func(t *testing.T) {
metadata, err := decodeKeyPairMetadata([]byte(`{"KeyFingerprint":"` + fingerprint + `","KeyName":"k","KeyPairId":"key-1"}`))
require.NoError(t, err)
assert.Equal(t, "ed25519", metadata.KeyType)
assert.Equal(t, fingerprint, *metadata.KeyFingerprint)
})
}
}
// A record with no fingerprint at all must not be dereferenced on the way to a
// key type.
func TestDecodeKeyPairMetadata_NoFingerprint(t *testing.T) {
metadata, err := decodeKeyPairMetadata([]byte(`{"KeyName":"k","KeyPairId":"key-1"}`))
require.NoError(t, err)
assert.Equal(t, "rsa", metadata.KeyType)
assert.Nil(t, metadata.KeyFingerprint)
}
func TestDecodeKeyPairMetadata_InvalidJSON(t *testing.T) {
_, err := decodeKeyPairMetadata([]byte("not json{{{"))
require.Error(t, err)
}
+37 -11
View File
@@ -4,32 +4,31 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/filterutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The record is read back as ec2.CreateKeyPairOutput, the type it was
// serialised as before keyPairMetadata existed, so this doubles as a canary on
// the stored JSON keys not having moved.
func readKeyPairTags(t *testing.T, svc *KeyServiceImpl, accountID, keyPairID string) map[string]string {
t.Helper()
result, err := svc.store.GetObject(context.Background(), &s3.GetObjectInput{
Bucket: aws.String(testBucket),
Key: aws.String(fmt.Sprintf("keys/%s/%s.json", accountID, keyPairID)),
})
require.NoError(t, err)
defer result.Body.Close()
body, err := io.ReadAll(result.Body)
require.NoError(t, err)
var metadata ec2.CreateKeyPairOutput
require.NoError(t, json.Unmarshal(body, &metadata))
require.NoError(t, json.Unmarshal(readKeyPairRecord(t, svc, accountID, keyPairID), &metadata))
return filterutil.EC2TagsToMap(metadata.Tags)
}
// readKeyPairRecord returns the stored metadata object verbatim.
func readKeyPairRecord(t *testing.T, svc *KeyServiceImpl, accountID, keyPairID string) []byte {
t.Helper()
return readStoredObject(t, svc.store, fmt.Sprintf("keys/%s/%s.json", accountID, keyPairID))
}
func TestKeyPairRecordTagsMirror(t *testing.T) {
svc, _ := newTestKeyService()
out := importTestKey(t, svc, "tag-mirror-key")
@@ -77,6 +76,33 @@ func TestKeyPairRecordTagsMirror_CrossAccountNoMutation(t *testing.T) {
assert.Empty(t, tags)
}
// Tagging read-modify-writes the whole record, so a legacy one is rewritten in
// upgraded form. The two halves of that upgrade must travel together: writing
// the normalised fingerprint back without also persisting KeyType would strip
// the only evidence of the key's type and permanently downgrade it to rsa.
func TestKeyPairRecordTagsMirror_UpgradesLegacyRecord(t *testing.T) {
svc, store := newTestKeyService()
putStoredObject(t, store, "keys/"+testAccountID+"/key-legacy2.json",
`{"KeyFingerprint":"`+testLegacyED25519Fingerprint+`","KeyName":"legacy-tagged","KeyPairId":"key-legacy2","Tags":null}`)
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
Resources: []*string{aws.String("key-legacy2")},
Tags: []*ec2.Tag{{Key: aws.String("env"), Value: aws.String("prod")}},
}, testAccountID))
record := string(readKeyPairRecord(t, svc, testAccountID, "key-legacy2"))
assert.Contains(t, record, `"KeyType":"ed25519"`)
assert.Contains(t, record, `"KeyFingerprint":"`+testED25519Fingerprint+`"`)
out, err := svc.DescribeKeyPairs(context.Background(), &ec2.DescribeKeyPairsInput{}, testAccountID)
require.NoError(t, err)
require.Len(t, out.KeyPairs, 1)
assert.Equal(t, "ed25519", *out.KeyPairs[0].KeyType)
assert.Equal(t, testED25519Fingerprint, *out.KeyPairs[0].KeyFingerprint)
assert.Equal(t, "prod", filterutil.EC2TagsToMap(out.KeyPairs[0].Tags)["env"])
}
func TestKeyPairRecordTagsMirror_UnownedNoError(t *testing.T) {
svc, _ := newTestKeyService()
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
+50 -32
View File
@@ -5,7 +5,9 @@ import (
"context"
"crypto/md5" //nolint:gosec // G501: MD5 is the digest EC2 puts on the wire, not a security choice
"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"
"errors"
@@ -159,6 +161,18 @@ func (s *KeyServiceImpl) CreateKeyPair(ctx context.Context, input *ec2.CreateKey
return nil, errors.New(awserrors.ErrorServerInternal)
}
// From here keyType describes the key that exists rather than the one that
// was asked for, so that the type stored alongside the fingerprint is read
// off the same key the fingerprint was taken from. The import path types its
// keys the same way. createdKeyFingerprint has already rejected every
// algorithm this can refuse, so the error is unreachable and stated only so
// an unsupported key can never be stored under a type EC2 cannot report.
keyType, err = keyPairType(publicKey)
if err != nil {
slog.ErrorContext(ctx, "Generated key has an unsupported algorithm", "algorithm", publicKey.Type(), "keyName", keyName, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
// Upload public key to S3
_, err = s.store.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucketName),
@@ -181,11 +195,12 @@ func (s *KeyServiceImpl) CreateKeyPair(ctx context.Context, input *ec2.CreateKey
Tags: tags,
}
// Store metadata file (CreateKeyPairOutput without KeyMaterial) for keyPairId lookups
err = s.storeKeyPairMetadata(ctx, accountID, keyPairID, &ec2.CreateKeyPairOutput{
// Store metadata file (everything but the private key) for keyPairId lookups
err = s.storeKeyPairMetadata(ctx, accountID, keyPairID, &keyPairMetadata{
KeyFingerprint: aws.String(fingerprint),
KeyName: aws.String(keyName),
KeyPairId: aws.String(keyPairID),
KeyType: keyType,
Tags: tags,
})
if err != nil {
@@ -206,13 +221,12 @@ func (s *KeyServiceImpl) CreateKeyPair(ctx context.Context, input *ec2.CreateKey
}
// importedKeyFingerprint fingerprints a key supplied to ImportKeyPair: the MD5
// of the DER SubjectPublicKeyInfo for RSA, matching AWS, and the OpenSSH SHA-256
// rendering for ED25519, which does not -- AWS omits the "SHA256:" prefix and
// pads the base64.
// of the DER SubjectPublicKeyInfo for RSA, and the SHA-256 of the SSH wire blob
// for ED25519, both matching AWS.
func importedKeyFingerprint(publicKey ssh.PublicKey) (string, error) {
switch publicKey.Type() {
case ssh.KeyAlgoED25519:
return ssh.FingerprintSHA256(publicKey), nil
return ed25519Fingerprint(publicKey), nil
case ssh.KeyAlgoRSA:
// AWS hashes the DER SubjectPublicKeyInfo, not the RFC 4253 wire blob
@@ -241,14 +255,14 @@ func importedKeyFingerprint(publicKey ssh.PublicKey) (string, error) {
// createdKeyFingerprint fingerprints a key EC2 generated itself. For RSA that is
// the SHA-1 of the DER PKCS#8 private key, a 20-byte digest over material the
// caller receives once and the store never keeps. ED25519 hashes the public key,
// exactly as the import path does, and shares its divergence from AWS.
// exactly as the import path does.
func createdKeyFingerprint(privateKeyPEM []byte, publicKey ssh.PublicKey) (string, error) {
switch publicKey.Type() {
// Matched ahead of the PKCS#8 path deliberately: ParseRawPrivateKey hands
// back *ed25519.PrivateKey, a pointer type MarshalPKCS8PrivateKey rejects,
// so an ED25519 key reaching that path would fail at runtime.
case ssh.KeyAlgoED25519:
return ssh.FingerprintSHA256(publicKey), nil
return ed25519Fingerprint(publicKey), nil
case ssh.KeyAlgoRSA:
rawKey, err := ssh.ParseRawPrivateKey(privateKeyPEM)
@@ -268,6 +282,14 @@ func createdKeyFingerprint(privateKeyPEM []byte, publicKey ssh.PublicKey) (strin
}
}
// ed25519Fingerprint renders the SHA-256 of the SSH wire blob as EC2 spells it:
// bare padded base64, without the "SHA256:" prefix OpenSSH prepends and with the
// padding OpenSSH omits.
func ed25519Fingerprint(publicKey ssh.PublicKey) string {
sum := sha256.Sum256(publicKey.Marshal())
return base64.StdEncoding.EncodeToString(sum[:])
}
// colonHex renders digest bytes the way EC2 spells a fingerprint: lowercase hex
// byte pairs joined by colons.
func colonHex(digest []byte) string {
@@ -295,7 +317,7 @@ func keyPairType(publicKey ssh.PublicKey) (string, error) {
}
// storeKeyPairMetadata stores key pair metadata (without private key) to S3 for keyPairId lookups.
func (s *KeyServiceImpl) storeKeyPairMetadata(ctx context.Context, accountID, keyPairID string, metadata *ec2.CreateKeyPairOutput) error {
func (s *KeyServiceImpl) storeKeyPairMetadata(ctx context.Context, accountID, keyPairID string, metadata *keyPairMetadata) error {
// Store metadata with keyPairId as filename for efficient lookup when keyPairId is provided
metadataPath := fmt.Sprintf("keys/%s/%s.json", accountID, keyPairID)
@@ -343,8 +365,8 @@ func (s *KeyServiceImpl) getKeyNameFromKeyPairId(ctx context.Context, accountID,
return "", fmt.Errorf("failed to read metadata: %w", err)
}
var metadata ec2.CreateKeyPairOutput
if err := json.Unmarshal(body, &metadata); err != nil {
metadata, err := decodeKeyPairMetadata(body)
if err != nil {
slog.Error("Failed to unmarshal metadata", "err", err)
return "", fmt.Errorf("failed to unmarshal metadata: %w", err)
}
@@ -402,8 +424,8 @@ func (s *KeyServiceImpl) findKeyPairIdFromKeyName(ctx context.Context, accountID
continue
}
var metadata ec2.CreateKeyPairOutput
if err := json.Unmarshal(body, &metadata); err != nil {
metadata, err := decodeKeyPairMetadata(body)
if err != nil {
slog.Debug("Failed to unmarshal metadata", "key", *obj.Key, "err", err)
continue
}
@@ -628,8 +650,8 @@ func (s *KeyServiceImpl) DescribeKeyPairs(ctx context.Context, input *ec2.Descri
continue
}
var metadata ec2.CreateKeyPairOutput
if err := json.Unmarshal(body, &metadata); err != nil {
metadata, err := decodeKeyPairMetadata(body)
if err != nil {
slog.DebugContext(ctx, "Failed to unmarshal metadata", "key", *obj.Key, "err", err)
continue
}
@@ -662,20 +684,15 @@ func (s *KeyServiceImpl) DescribeKeyPairs(ctx context.Context, input *ec2.Descri
}
}
// Determine key type from fingerprint format
var keyType string
if metadata.KeyFingerprint != nil && strings.HasPrefix(*metadata.KeyFingerprint, "SHA256:") {
keyType = "ed25519"
} else {
keyType = "rsa"
}
// Build KeyPairInfo from metadata
// Build KeyPairInfo from metadata. Records predating the stored KeyType
// were typed by the decoder, which also normalised the fingerprint they
// were typed from; the object itself is left as it was found, so a list
// does not turn into a write per key pair.
keyPairInfo := &ec2.KeyPairInfo{
KeyPairId: metadata.KeyPairId,
KeyFingerprint: metadata.KeyFingerprint,
KeyName: metadata.KeyName,
KeyType: aws.String(keyType),
KeyType: aws.String(metadata.KeyType),
Tags: metadata.Tags,
}
@@ -836,14 +853,13 @@ func (s *KeyServiceImpl) ImportKeyPair(ctx context.Context, input *ec2.ImportKey
}
// Store metadata file (without public key material)
metadataOutput := &ec2.CreateKeyPairOutput{
err = s.storeKeyPairMetadata(ctx, accountID, keyPairID, &keyPairMetadata{
KeyFingerprint: aws.String(fingerprint),
KeyName: aws.String(keyName),
KeyPairId: aws.String(keyPairID),
KeyType: keyType,
Tags: tags,
}
err = s.storeKeyPairMetadata(ctx, accountID, keyPairID, metadataOutput)
})
if err != nil {
slog.ErrorContext(ctx, "Failed to store key pair metadata", "err", err, "keyPairId", keyPairID)
// Try to cleanup the public key we just uploaded
@@ -905,8 +921,10 @@ func (s *KeyServiceImpl) mirrorKeyPairTags(ctx context.Context, resources []*str
if err != nil {
return fmt.Errorf("failed to read key pair metadata: %w", err)
}
var metadata ec2.CreateKeyPairOutput
if err := json.Unmarshal(body, &metadata); err != nil {
// The whole record is rewritten, not just its Tags, so a legacy record is
// persisted here in the form the decoder upgraded it to.
metadata, err := decodeKeyPairMetadata(body)
if err != nil {
return fmt.Errorf("failed to unmarshal key pair metadata: %w", err)
}
tags := filterutil.EC2TagsToMap(metadata.Tags)
@@ -915,7 +933,7 @@ func (s *KeyServiceImpl) mirrorKeyPairTags(ctx context.Context, resources []*str
}
mut(tags)
metadata.Tags = utils.MapToEC2Tags(tags)
if err := s.storeKeyPairMetadata(ctx, accountID, *res, &metadata); err != nil {
if err := s.storeKeyPairMetadata(ctx, accountID, *res, metadata); err != nil {
return err
}
}
+91 -45
View File
@@ -3,8 +3,11 @@ package handlers_ec2_key
import (
"context"
"crypto/sha1"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"io"
"os/exec"
"strings"
"testing"
@@ -38,9 +41,16 @@ const testECDSAPubKey = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAA
const testDSAPubKey = "ssh-dss AAAAB3NzaC1kc3MAAACBAMfooAGeGkAp6syFsxNueaASpwMr5t+BiLTxAmf9grlH/7MPhXHxxtrrzEdq4bK0mheLl4irAtardgR5ghOVxKXqz4dLlcEyv3H3tOY8Hq+JT/6w9j4FLjen4obXcilh7vfRPdL/A7Dk3Th9NSkrp43FmXUL4EyuMbqi7LcQYpMpAAAAFQDpwLJZvztWN3pEeT/MMLsVSmJedwAAAIBZjhEyHHk1iomvueP6GkmdrXt4V9+6BHjG/rHzQRlO79muU5ImX/BFALCc0RjaPNAoo0lF6ptaPf2HPeu3dtEAWM9iXH8SLqcAVX7B5FUYKFb7zsyQmlT3pKo21V3mCakKHDma8kbHSC2sysl1NOD4IkGTQalP4MuzIvCXNKbCdAAAAIEAl7OdP7hBngX0CuM0+cJXonZvnvIo1NOWGVu+dCn93mvoGjKFyZmLSEMIfFbmckQF2J4F9cM9aU6ht76k73DFnnA/F7WiJ+hIKLhL7Y8F0eDtWkawswvwxvHB+C7drrqezD6t5INX4CYlNQD4zqhgWKBSKVn3sQzQI95gFE51rKE="
const (
// The OpenSSH SHA256 rendering, as reported by `ssh-keygen -lf`. This is not
// the value AWS returns: AWS drops the "SHA256:" prefix and pads the base64.
testED25519Fingerprint = "SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU"
// The SHA256 digest `ssh-keygen -lf` reports for testED25519PubKey, rendered
// as AWS renders it: no "SHA256:" prefix and the base64 padded. Derived from
// the fixture rather than captured, since the ED25519 key pairs AWS was
// sampled against have been deleted; the AWS captures pin the rendering in
// metadata_test.go instead, where no key material is needed.
testED25519Fingerprint = "+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU="
// The same digest as records already in the object store still spell it: the
// OpenSSH rendering `ssh.FingerprintSHA256` produced.
testLegacyED25519Fingerprint = "SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU"
// Recorded from real AWS: the value ImportKeyPair returned for testRSAPubKey
// above. Reproducible with
@@ -96,6 +106,32 @@ func newTestKeyService() (*KeyServiceImpl, *objectstore.MemoryObjectStore) {
return svc, store
}
// readStoredObject returns the body of an object the test asserts is present.
func readStoredObject(t *testing.T, store objectstore.ObjectStore, key string) []byte {
t.Helper()
result, err := store.GetObject(context.Background(), &s3.GetObjectInput{
Bucket: aws.String(testBucket),
Key: aws.String(key),
})
require.NoError(t, err)
defer result.Body.Close()
body, err := io.ReadAll(result.Body)
require.NoError(t, err)
return body
}
// putStoredObject writes a body verbatim, for seeding records in a form the
// service no longer writes itself.
func putStoredObject(t *testing.T, store objectstore.ObjectStore, key, body string) {
t.Helper()
_, err := store.PutObject(context.Background(), &s3.PutObjectInput{
Bucket: aws.String(testBucket),
Key: aws.String(key),
Body: strings.NewReader(body),
})
require.NoError(t, err)
}
// requireSSHKeygen skips the test if ssh-keygen is not available.
func requireSSHKeygen(t *testing.T) {
t.Helper()
@@ -133,28 +169,22 @@ func TestCreateKeyPair_ED25519(t *testing.T) {
assert.Equal(t, "my-ed25519-key", *out.KeyName)
assert.NotEmpty(t, *out.KeyPairId)
assert.True(t, strings.HasPrefix(*out.KeyPairId, "key-"))
assert.NotEmpty(t, *out.KeyFingerprint)
assert.True(t, strings.HasPrefix(*out.KeyFingerprint, "SHA256:"), "ed25519 fingerprint should be SHA256 format")
assert.NotEmpty(t, *out.KeyMaterial)
assert.Contains(t, *out.KeyMaterial, "PRIVATE KEY")
// Verify public key stored in S3
keyPath := "keys/" + testAccountID + "/my-ed25519-key"
getOut, err := store.GetObject(context.Background(), &s3.GetObjectInput{
Bucket: aws.String(testBucket),
Key: aws.String(keyPath),
})
require.NoError(t, err)
assert.NotNil(t, getOut)
publicKeyData := readStoredObject(t, store, keyPath)
// Verify metadata stored in S3
// The key is generated per-run, so the digest is not knowable in advance.
// Recompute it from the stored public key: that pins the rendering to an
// independent computation rather than to the shape of the string.
sum := sha256.Sum256(parseTestPubKey(t, string(publicKeyData)).Marshal())
assert.Equal(t, base64.StdEncoding.EncodeToString(sum[:]), *out.KeyFingerprint)
// Verify metadata stored in S3, carrying the key type it was created with.
metaPath := "keys/" + testAccountID + "/" + *out.KeyPairId + ".json"
metaOut, err := store.GetObject(context.Background(), &s3.GetObjectInput{
Bucket: aws.String(testBucket),
Key: aws.String(metaPath),
})
require.NoError(t, err)
assert.NotNil(t, metaOut)
assert.Contains(t, string(readStoredObject(t, store, metaPath)), `"KeyType":"ed25519"`)
}
func TestCreateKeyPair_RSA(t *testing.T) {
@@ -263,21 +293,11 @@ func TestImportKeyPair_Success_ED25519(t *testing.T) {
// Verify public key stored in S3
keyPath := "keys/" + testAccountID + "/imported-ed25519"
getOut, err := store.GetObject(context.Background(), &s3.GetObjectInput{
Bucket: aws.String(testBucket),
Key: aws.String(keyPath),
})
require.NoError(t, err)
assert.NotNil(t, getOut)
assert.Equal(t, testED25519PubKey, string(readStoredObject(t, store, keyPath)))
// Verify metadata stored in S3
// Verify metadata stored in S3, carrying the key type it was imported as.
metaPath := "keys/" + testAccountID + "/" + *out.KeyPairId + ".json"
metaOut, err := store.GetObject(context.Background(), &s3.GetObjectInput{
Bucket: aws.String(testBucket),
Key: aws.String(metaPath),
})
require.NoError(t, err)
assert.NotNil(t, metaOut)
assert.Contains(t, string(readStoredObject(t, store, metaPath)), `"KeyType":"ed25519"`)
}
func TestImportKeyPair_Success_RSA(t *testing.T) {
@@ -571,23 +591,27 @@ func TestDescribeKeyPairs_AllKeys(t *testing.T) {
assert.True(t, names["key-beta"])
}
// KeyType is inferred from the stored fingerprint's shape, so it has to survive
// RSA reporting two different digest widths: 16 bytes when imported, 20 when EC2
// generated the key. Only ED25519 carries the "SHA256:" prefix that marks it.
func TestDescribeKeyPairs_KeyTypeInference(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, "inferred-ed25519")
importTestKey(t, svc, "ed25519-imported")
_, err := svc.ImportKeyPair(context.Background(), &ec2.ImportKeyPairInput{
KeyName: aws.String("inferred-rsa-imported"),
KeyName: aws.String("rsa-imported"),
PublicKeyMaterial: []byte(testRSAPubKey),
}, testAccountID)
require.NoError(t, err)
_, err = svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{
KeyName: aws.String("inferred-rsa-created"),
KeyName: aws.String("ed25519-created"),
}, testAccountID)
require.NoError(t, err)
_, err = svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{
KeyName: aws.String("rsa-created"),
KeyType: aws.String("rsa"),
}, testAccountID)
require.NoError(t, err)
@@ -600,12 +624,35 @@ func TestDescribeKeyPairs_KeyTypeInference(t *testing.T) {
keyTypes[*kp.KeyName] = *kp.KeyType
}
assert.Equal(t, map[string]string{
"inferred-ed25519": "ed25519",
"inferred-rsa-imported": "rsa",
"inferred-rsa-created": "rsa",
"ed25519-imported": "ed25519",
"ed25519-created": "ed25519",
"rsa-imported": "rsa",
"rsa-created": "rsa",
}, keyTypes)
}
// A record written before KeyType was persisted still has to describe as
// ed25519, and its fingerprint has to reach the client in the AWS spelling. This
// is the case the removal of the "SHA256:" prefix would otherwise have broken.
func TestDescribeKeyPairs_LegacyED25519Record(t *testing.T) {
svc, store := newTestKeyService()
putStoredObject(t, store, "keys/"+testAccountID+"/key-legacy1.json",
`{"KeyFingerprint":"`+testLegacyED25519Fingerprint+`","KeyName":"legacy-ed25519","KeyPairId":"key-legacy1","Tags":null}`)
out, err := svc.DescribeKeyPairs(context.Background(), &ec2.DescribeKeyPairsInput{}, testAccountID)
require.NoError(t, err)
require.Len(t, out.KeyPairs, 1)
assert.Equal(t, "ed25519", *out.KeyPairs[0].KeyType)
assert.Equal(t, testED25519Fingerprint, *out.KeyPairs[0].KeyFingerprint)
// Describing normalises the response only: a list must not become a write
// per key pair.
assert.Contains(t, string(readStoredObject(t, store, "keys/"+testAccountID+"/key-legacy1.json")),
testLegacyED25519Fingerprint)
}
func TestDescribeKeyPairs_FilterByKeyName(t *testing.T) {
svc, _ := newTestKeyService()
@@ -818,8 +865,7 @@ func TestImportedKeyFingerprint(t *testing.T) {
pubKey string
expected string
}{
// The OpenSSH rendering of the SHA256 digest, which is not what AWS
// returns -- AWS drops the prefix and pads the base64.
// The SHA256 of the SSH wire blob, in AWS's bare padded base64.
{name: "ED25519", pubKey: testED25519PubKey, expected: testED25519Fingerprint},
// The MD5 of the DER SubjectPublicKeyInfo, matching AWS.
{name: "RSA", pubKey: testRSAPubKey, expected: testRSAImportedFingerprint},
@@ -874,8 +920,8 @@ func TestCreatedKeyFingerprint_RSA(t *testing.T) {
}
// ED25519 hashes the public key on both paths, so the created value matches the
// imported one -- and diverges from AWS the same way. The private key is passed
// deliberately mismatched: this branch must not read it at all.
// imported one. The private key is passed deliberately mismatched: this branch
// must not read it at all.
func TestCreatedKeyFingerprint_ED25519(t *testing.T) {
fingerprint, err := createdKeyFingerprint([]byte(testRSAPrivKey), parseTestPubKey(t, testED25519PubKey))
require.NoError(t, err)