diff --git a/go.mod b/go.mod index 3a9582ab6..40be10c4b 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( cloud.google.com/go/firestore v1.22.0 cloud.google.com/go/functions v1.19.7 cloud.google.com/go/iam v1.11.0 + cloud.google.com/go/kms v1.31.0 cloud.google.com/go/longrunning v1.2.0 cloud.google.com/go/managedkafka v1.0.0 cloud.google.com/go/orchestration v1.11.10 diff --git a/go.sum b/go.sum index cdadf5682..370a0975b 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ cloud.google.com/go/functions v1.19.7 h1:7LcOD18euIVGRUPaeCmgO6vfWSLNIsi6STWRQcd cloud.google.com/go/functions v1.19.7/go.mod h1:xbcKfS7GoIcaXr2FSwmtn9NXal1JR4TV6iYZlgXffwA= cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= +cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA= cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM= diff --git a/server/gcp/kms/crypto.go b/server/gcp/kms/crypto.go new file mode 100644 index 000000000..cd6384805 --- /dev/null +++ b/server/gcp/kms/crypto.go @@ -0,0 +1,279 @@ +package kms + +import ( + "crypto" + "crypto/aes" + "crypto/cipher" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/binary" + "encoding/pem" + "strconv" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +const ( + // ciphertextMagic tags the emulator's symmetric ciphertext layout: + // magic(1) | version id (uint32 big-endian) | GCM nonce | sealed bytes. + // Carrying the version id lets decrypt pick the right key after rotation. + ciphertextMagic byte = 0x4b + ciphertextHeader = 1 + 4 + aes256KeyBytes = 32 + aes128KeyBytes = 16 + + algEd25519 = "EC_SIGN_ED25519" + algP256 = "EC_SIGN_P256_SHA256" + algP384 = "EC_SIGN_P384_SHA384" +) + +// errUnsupportedAlg reports an algorithm the emulator cannot back with real +// Go stdlib crypto (for example secp256k1 or external keys). +func errUnsupportedAlg(alg string) error { + return cerrors.Newf(cerrors.FailedPrecondition, + "algorithm %s is not supported by the emulator's data plane", alg) +} + +// errInvalidCiphertext is the error real Cloud KMS returns when a ciphertext +// fails to authenticate (tampered bytes, wrong key or AAD mismatch). +func errInvalidCiphertext() error { + return cerrors.New(cerrors.InvalidArgument, "Decryption failed: the ciphertext is invalid.") +} + +// ensureMaterial generates the version's key on first use. Callers hold s.mu +// for writing. +func (v *versionModel) ensureMaterial() error { + if v.secret != nil || v.priv != nil { + return nil + } + + alg := v.algorithm + + switch { + case alg == algorithmSymmetric || strings.HasPrefix(alg, "AES_256"): + return v.randomSecret(aes256KeyBytes) + case strings.HasPrefix(alg, "AES_128"): + return v.randomSecret(aes128KeyBytes) + case strings.HasPrefix(alg, "HMAC_"): + return v.randomSecret(hashFor(alg).Size()) + case strings.HasPrefix(alg, "RSA_"): + return v.generateRSA(alg) + default: + return v.generateEC(alg) + } +} + +func (v *versionModel) randomSecret(n int) error { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return cerrors.Newf(cerrors.Internal, "generate key: %v", err) + } + + v.secret = b + + return nil +} + +// RSA modulus sizes Cloud KMS offers. Each is passed to rsa.GenerateKey as a +// constant, so no key is ever generated below 2048 bits. +const ( + rsaBits2048 = 2048 + rsaBits3072 = 3072 + rsaBits4096 = 4096 +) + +func (v *versionModel) generateRSA(alg string) error { + var ( + k *rsa.PrivateKey + err error + ) + + switch { + case strings.Contains(alg, "_4096"): + k, err = rsa.GenerateKey(rand.Reader, rsaBits4096) + case strings.Contains(alg, "_3072"): + k, err = rsa.GenerateKey(rand.Reader, rsaBits3072) + case strings.Contains(alg, "_2048"): + k, err = rsa.GenerateKey(rand.Reader, rsaBits2048) + default: + return errUnsupportedAlg(alg) + } + + if err != nil { + return cerrors.Newf(cerrors.Internal, "generate RSA key: %v", err) + } + + v.priv = k + + return nil +} + +func (v *versionModel) generateEC(alg string) error { + var ( + k crypto.Signer + err error + ) + + switch alg { + case algP256: + k, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + case algP384: + k, err = ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + case algEd25519: + _, k, err = ed25519.GenerateKey(rand.Reader) + default: + return errUnsupportedAlg(alg) + } + + if err != nil { + return cerrors.Newf(cerrors.Internal, "generate key: %v", err) + } + + v.priv = k + + return nil +} + +// hashFor returns the digest an algorithm name ends in (SHA256 by default). +func hashFor(alg string) crypto.Hash { + switch { + case strings.HasSuffix(alg, "SHA512"): + return crypto.SHA512 + case strings.HasSuffix(alg, "SHA384"): + return crypto.SHA384 + case strings.HasSuffix(alg, "SHA224"): + return crypto.SHA224 + case strings.HasSuffix(alg, "SHA1"): + return crypto.SHA1 + default: + return crypto.SHA256 + } +} + +func (v *versionModel) gcm() (cipher.AEAD, error) { + block, err := aes.NewCipher(v.secret) + if err != nil { + return nil, cerrors.Newf(cerrors.Internal, "aes: %v", err) + } + + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, cerrors.Newf(cerrors.Internal, "gcm: %v", err) + } + + return aead, nil +} + +// seal encrypts plaintext with AES-GCM, binding aad, and prefixes the version +// id so decrypt can find this version later. +func (v *versionModel) seal(plaintext, aad []byte) ([]byte, error) { + aead, err := v.gcm() + if err != nil { + return nil, err + } + + id, err := strconv.ParseUint(v.id, 10, 32) + if err != nil { + return nil, cerrors.Newf(cerrors.Internal, "version id %q: %v", v.id, err) + } + + out := make([]byte, 0, ciphertextHeader+aead.NonceSize()+len(plaintext)+aead.Overhead()) + out = append(out, ciphertextMagic) + out = binary.BigEndian.AppendUint32(out, uint32(id)) + + nonce := make([]byte, aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, cerrors.Newf(cerrors.Internal, "nonce: %v", err) + } + + out = append(out, nonce...) + + return aead.Seal(out, nonce, plaintext, aad), nil +} + +// ciphertextVersion reads the version id a ciphertext was sealed under. +func ciphertextVersion(ct []byte) (string, bool) { + if len(ct) < ciphertextHeader || ct[0] != ciphertextMagic { + return "", false + } + + return strconv.FormatUint(uint64(binary.BigEndian.Uint32(ct[1:ciphertextHeader])), 10), true +} + +func (v *versionModel) open(ct, aad []byte) ([]byte, error) { + aead, err := v.gcm() + if err != nil { + return nil, err + } + + body := ct[ciphertextHeader:] + if len(body) < aead.NonceSize() { + return nil, errInvalidCiphertext() + } + + pt, err := aead.Open(nil, body[:aead.NonceSize()], body[aead.NonceSize():], aad) + if err != nil { + return nil, errInvalidCiphertext() + } + + return pt, nil +} + +// sign produces an asymmetricSign signature. digest is the pre-hashed input; +// data is the raw input, required for Ed25519 and RSA_SIGN_RAW_PKCS1_*. +func (v *versionModel) sign(digest, data []byte) ([]byte, error) { + switch k := v.priv.(type) { + case ed25519.PrivateKey: + return ed25519.Sign(k, data), nil + case *rsa.PrivateKey: + if strings.HasPrefix(v.algorithm, "RSA_SIGN_RAW_PKCS1") { + return rsa.SignPKCS1v15(rand.Reader, k, 0, data) + } + + if strings.HasPrefix(v.algorithm, "RSA_SIGN_PSS") { + return rsa.SignPSS(rand.Reader, k, hashFor(v.algorithm), digest, + &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash}) + } + + return rsa.SignPKCS1v15(rand.Reader, k, hashFor(v.algorithm), digest) + case *ecdsa.PrivateKey: + return ecdsa.SignASN1(rand.Reader, k, digest) + default: + return nil, errUnsupportedAlg(v.algorithm) + } +} + +// decryptOAEP is asymmetricDecrypt: RSA-OAEP with the algorithm's hash and an +// empty label, as Cloud KMS specifies. +func (v *versionModel) decryptOAEP(ct []byte) ([]byte, error) { + k, ok := v.priv.(*rsa.PrivateKey) + if !ok { + return nil, errUnsupportedAlg(v.algorithm) + } + + pt, err := rsa.DecryptOAEP(hashFor(v.algorithm).New(), nil, k, ct, nil) + if err != nil { + return nil, errInvalidCiphertext() + } + + return pt, nil +} + +// publicKeyPEM encodes the version's public key as a PKIX "PUBLIC KEY" PEM. +func (v *versionModel) publicKeyPEM() (string, error) { + if v.priv == nil { + return "", errUnsupportedAlg(v.algorithm) + } + + der, err := x509.MarshalPKIXPublicKey(v.priv.Public()) + if err != nil { + return "", cerrors.Newf(cerrors.Internal, "marshal public key: %v", err) + } + + return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})), nil +} diff --git a/server/gcp/kms/dataplane.go b/server/gcp/kms/dataplane.go new file mode 100644 index 000000000..804c1d4ce --- /dev/null +++ b/server/gcp/kms/dataplane.go @@ -0,0 +1,322 @@ +package kms + +import ( + "crypto" + "crypto/hmac" + "crypto/rand" + "net/http" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire/gcprest" +) + +const ( + verbEncrypt = "encrypt" + verbDecrypt = "decrypt" + verbAsymmetricSign = "asymmetricSign" + verbAsymmetricDecrypt = "asymmetricDecrypt" + verbMacSign = "macSign" + verbMacVerify = "macVerify" + verbPublicKey = "publicKey" + verbGenerateRandomBytes = "generateRandomBytes" + + purposeAsymmetricSign = "ASYMMETRIC_SIGN" + purposeAsymmetricDecrypt = "ASYMMETRIC_DECRYPT" + purposeMAC = "MAC" + + // maxPayloadBytes is Cloud KMS's 64 KiB cap on plaintext, AAD and data. + maxPayloadBytes = 64 * 1024 + // maxRandomBytes is the generateRandomBytes lengthBytes upper bound. + maxRandomBytes = 1024 +) + +func tooLarge(field string, b []byte) error { + if len(b) > maxPayloadBytes { + return cerrors.Newf(cerrors.InvalidArgument, "%s exceeds the %d-byte limit", field, maxPayloadBytes) + } + + return nil +} + +func (h *Handler) encrypt(w http.ResponseWriter, r *http.Request, rt *route) { + var req encryptRequest + if !gcprest.DecodeJSON(w, r, &req) { + return + } + + resp, err := h.doEncrypt(rt, &req) + if err != nil { + writeKMSErr(w, err) + return + } + + gcprest.WriteJSON(w, http.StatusOK, resp) +} + +func (h *Handler) doEncrypt(rt *route, req *encryptRequest) (*encryptResponse, error) { + if err := tooLarge("plaintext", req.Plaintext); err != nil { + return nil, err + } + + if err := tooLarge("additionalAuthenticatedData", req.AdditionalAuthenticatedData); err != nil { + return nil, err + } + + ptOK, err := checkCRC("plaintext_crc32c", "plaintext", req.Plaintext, req.PlaintextCrc32c) + if err != nil { + return nil, err + } + + aadOK, err := checkCRC("additional_authenticated_data_crc32c", "additional_authenticated_data", + req.AdditionalAuthenticatedData, req.AdditionalAuthenticatedDataCrc32c) + if err != nil { + return nil, err + } + + res, err := h.store.encrypt(rt, req.Plaintext, req.AdditionalAuthenticatedData) + if err != nil { + return nil, err + } + + return &encryptResponse{ + Name: res.name, Ciphertext: res.out, CiphertextCrc32c: crcOf(res.out), + VerifiedPlaintextCrc32c: ptOK, VerifiedAdditionalAuthenticatedDataCrc32c: aadOK, + ProtectionLevel: res.protectionLevel, + }, nil +} + +func (h *Handler) decrypt(w http.ResponseWriter, r *http.Request, rt *route) { + var req decryptRequest + if !gcprest.DecodeJSON(w, r, &req) { + return + } + + _, err := checkCRC("ciphertext_crc32c", "ciphertext", req.Ciphertext, req.CiphertextCrc32c) + if err == nil { + _, err = checkCRC("additional_authenticated_data_crc32c", "additional_authenticated_data", + req.AdditionalAuthenticatedData, req.AdditionalAuthenticatedDataCrc32c) + } + + if err != nil { + writeKMSErr(w, err) + return + } + + res, err := h.store.decrypt(rt, req.Ciphertext, req.AdditionalAuthenticatedData) + if err != nil { + writeKMSErr(w, err) + return + } + + gcprest.WriteJSON(w, http.StatusOK, decryptResponse{ + Plaintext: res.out, PlaintextCrc32c: crcOf(res.out), + UsedPrimary: res.usedPrimary, ProtectionLevel: res.protectionLevel, + }) +} + +// signInput resolves the bytes the algorithm signs: the raw data for Ed25519 +// and RSA_SIGN_RAW_PKCS1_*, otherwise a digest (supplied, or computed from +// data) of the algorithm's hash. +func signInput(alg string, req *asymmetricSignRequest) (digest []byte, err error) { + if alg == algEd25519 || strings.HasPrefix(alg, "RSA_SIGN_RAW_PKCS1") { + if len(req.Data) == 0 { + return nil, cerrors.Newf(cerrors.InvalidArgument, "data is required for algorithm %s", alg) + } + + return nil, nil + } + + hash := hashFor(alg) + + if req.Digest == nil { + if len(req.Data) == 0 { + return nil, cerrors.New(cerrors.InvalidArgument, "one of digest or data is required") + } + + hh := hash.New() + hh.Write(req.Data) + + return hh.Sum(nil), nil + } + + digests := map[crypto.Hash][]byte{ + crypto.SHA256: req.Digest.Sha256, crypto.SHA384: req.Digest.Sha384, crypto.SHA512: req.Digest.Sha512, + } + + d := digests[hash] + if len(d) != hash.Size() { + return nil, cerrors.Newf(cerrors.InvalidArgument, + "digest must be a %d-byte %s digest for algorithm %s", hash.Size(), hash, alg) + } + + return d, nil +} + +func (h *Handler) asymmetricSign(w http.ResponseWriter, r *http.Request, rt *route) { + var req asymmetricSignRequest + if !gcprest.DecodeJSON(w, r, &req) { + return + } + + var resp asymmetricSignResponse + + err := h.store.withVersion(rt, func(name string, v *versionModel) error { + digest, err := signInput(v.algorithm, &req) + if err != nil { + return err + } + + if resp.VerifiedDigestCrc32c, err = checkCRC("digest_crc32c", "digest", digest, req.DigestCrc32c); err != nil { + return err + } + + if resp.VerifiedDataCrc32c, err = checkCRC("data_crc32c", "data", req.Data, req.DataCrc32c); err != nil { + return err + } + + sig, err := v.sign(digest, req.Data) + if err != nil { + return cerrors.Newf(cerrors.InvalidArgument, "sign: %v", err) + } + + resp.Signature, resp.SignatureCrc32c = sig, crcOf(sig) + resp.Name, resp.ProtectionLevel = name, v.protectionLevel + + return nil + }, purposeAsymmetricSign) + if err != nil { + writeKMSErr(w, err) + return + } + + gcprest.WriteJSON(w, http.StatusOK, resp) +} + +func (h *Handler) asymmetricDecrypt(w http.ResponseWriter, r *http.Request, rt *route) { + var req asymmetricDecryptRequest + if !gcprest.DecodeJSON(w, r, &req) { + return + } + + var resp asymmetricDecryptResponse + + err := h.store.withVersion(rt, func(_ string, v *versionModel) error { + var err error + if resp.VerifiedCiphertextCrc32c, err = checkCRC("ciphertext_crc32c", "ciphertext", + req.Ciphertext, req.CiphertextCrc32c); err != nil { + return err + } + + pt, err := v.decryptOAEP(req.Ciphertext) + if err != nil { + return err + } + + resp.Plaintext, resp.PlaintextCrc32c, resp.ProtectionLevel = pt, crcOf(pt), v.protectionLevel + + return nil + }, purposeAsymmetricDecrypt) + if err != nil { + writeKMSErr(w, err) + return + } + + gcprest.WriteJSON(w, http.StatusOK, resp) +} + +// getPublicKey serves GET .../cryptoKeyVersions/{v}/publicKey for either +// asymmetric purpose. +func (h *Handler) getPublicKey(w http.ResponseWriter, rt *route) { + var resp publicKeyResponse + + fn := func(name string, v *versionModel) error { + p, err := v.publicKeyPEM() + if err != nil { + return err + } + + resp = publicKeyResponse{ + Pem: p, Algorithm: v.algorithm, PemCrc32c: crcOf([]byte(p)), + Name: name, ProtectionLevel: v.protectionLevel, PublicKeyFormat: "PEM", + } + + return nil + } + + if err := h.store.withVersion(rt, fn, purposeAsymmetricSign, purposeAsymmetricDecrypt); err != nil { + writeKMSErr(w, err) + return + } + + gcprest.WriteJSON(w, http.StatusOK, resp) +} + +// macOp serves macSign and macVerify with HMAC under the version's key. +func (h *Handler) macOp(w http.ResponseWriter, r *http.Request, rt *route) { + var req macRequest + if !gcprest.DecodeJSON(w, r, &req) { + return + } + + var resp any + + err := h.store.withVersion(rt, func(name string, v *versionModel) error { + dataOK, err := checkCRC("data_crc32c", "data", req.Data, req.DataCrc32c) + if err != nil { + return err + } + + mac := hmac.New(hashFor(v.algorithm).New, v.secret) + mac.Write(req.Data) + sum := mac.Sum(nil) + + if rt.verb == verbMacSign { + resp = macSignResponse{ + Name: name, Mac: sum, MacCrc32c: crcOf(sum), + VerifiedDataCrc32c: dataOK, ProtectionLevel: v.protectionLevel, + } + + return nil + } + + macOK, err := checkCRC("mac_crc32c", "mac", req.Mac, req.MacCrc32c) + if err != nil { + return err + } + + resp = macVerifyResponse{ + Name: name, Success: hmac.Equal(sum, req.Mac), VerifiedDataCrc32c: dataOK, + VerifiedMacCrc32c: macOK, VerifiedSuccessIntegrity: true, ProtectionLevel: v.protectionLevel, + } + + return nil + }, purposeMAC) + if err != nil { + writeKMSErr(w, err) + return + } + + gcprest.WriteJSON(w, http.StatusOK, resp) +} + +// generateRandomBytes serves POST /v1/projects/{p}/locations/{l}:generateRandomBytes. +func (*Handler) generateRandomBytes(w http.ResponseWriter, r *http.Request) { + var req generateRandomBytesRequest + if !gcprest.DecodeJSON(w, r, &req) { + return + } + + if req.LengthBytes < 8 || req.LengthBytes > maxRandomBytes { + invalidArg(w, "lengthBytes must be between 8 and 1024") + return + } + + b := make([]byte, req.LengthBytes) + if _, err := rand.Read(b); err != nil { + gcprest.WriteCErr(w, cerrors.Newf(cerrors.Internal, "random: %v", err)) + return + } + + gcprest.WriteJSON(w, http.StatusOK, generateRandomBytesResponse{Data: b, DataCrc32c: crcOf(b)}) +} diff --git a/server/gcp/kms/dataplane_sdk_test.go b/server/gcp/kms/dataplane_sdk_test.go new file mode 100644 index 000000000..2e4d91584 --- /dev/null +++ b/server/gcp/kms/dataplane_sdk_test.go @@ -0,0 +1,289 @@ +package kms_test + +import ( + "bytes" + "context" + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/pem" + "errors" + "net/http/httptest" + "strings" + "testing" + "time" + + kms "cloud.google.com/go/kms/apiv1" + "cloud.google.com/go/kms/apiv1/kmspb" + "google.golang.org/api/googleapi" + "google.golang.org/api/option" + "google.golang.org/protobuf/types/known/fieldmaskpb" + + "github.com/stackshy/cloudemu/v2/config" + gcpserver "github.com/stackshy/cloudemu/v2/server/gcp" +) + +// newGapicKMS returns a real cloud.google.com/go/kms REST client pointed at a +// fresh GCP wire server, with the test key ring already created. +func newGapicKMS(t *testing.T) *kms.KeyManagementClient { + t.Helper() + + srv := gcpserver.New(gcpserver.Drivers{Clock: config.NewFakeClock(time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC))}) + ts := httptest.NewServer(srv) + t.Cleanup(ts.Close) + + c, err := kms.NewKeyManagementRESTClient(context.Background(), + option.WithEndpoint(ts.URL), option.WithoutAuthentication()) + if err != nil { + t.Fatalf("NewKeyManagementRESTClient: %v", err) + } + + t.Cleanup(func() { _ = c.Close() }) + + _, err = c.CreateKeyRing(context.Background(), &kmspb.CreateKeyRingRequest{ + Parent: testLocationParent, KeyRingId: testKeyRingID, + }) + if err != nil { + t.Fatalf("CreateKeyRing: %v", err) + } + + return c +} + +func mustGapicKey(t *testing.T, c *kms.KeyManagementClient, id string, purpose kmspb.CryptoKey_CryptoKeyPurpose, + alg kmspb.CryptoKeyVersion_CryptoKeyVersionAlgorithm, +) string { + t.Helper() + + ck, err := c.CreateCryptoKey(context.Background(), &kmspb.CreateCryptoKeyRequest{ + Parent: testKeyRingName, CryptoKeyId: id, + CryptoKey: &kmspb.CryptoKey{ + Purpose: purpose, + VersionTemplate: &kmspb.CryptoKeyVersionTemplate{Algorithm: alg}, + }, + }) + if err != nil { + t.Fatalf("CreateCryptoKey %s: %v", id, err) + } + + return ck.GetName() +} + +// wantStatus asserts an HTTP 400 whose body carries the canonical status +// (INVALID_ARGUMENT, FAILED_PRECONDITION) real Cloud KMS reports over REST. +func wantStatus(t *testing.T, err error, wantStatus, msgPart string) { + t.Helper() + + var ge *googleapi.Error + if !errors.As(err, &ge) { + t.Fatalf("want %s googleapi.Error, got %v", wantStatus, err) + } + + if ge.Code != 400 || !strings.Contains(ge.Body, `"status":"`+wantStatus+`"`) || + !strings.Contains(ge.Message, msgPart) { + t.Fatalf("got %d %s, want 400 %s mentioning %q", ge.Code, ge.Body, wantStatus, msgPart) + } +} + +func TestKMSEncryptDecryptRotation(t *testing.T) { + ctx := context.Background() + c := newGapicKMS(t) + key := mustGapicKey(t, c, "sym", kmspb.CryptoKey_ENCRYPT_DECRYPT, + kmspb.CryptoKeyVersion_GOOGLE_SYMMETRIC_ENCRYPTION) + aad := []byte("tenant=a") + + enc1, err := c.Encrypt(ctx, &kmspb.EncryptRequest{Name: key, Plaintext: []byte("hello"), AdditionalAuthenticatedData: aad}) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + if enc1.GetName() != key+"/cryptoKeyVersions/1" || bytes.Contains(enc1.GetCiphertext(), []byte("hello")) { + t.Fatalf("encrypt name %q, ciphertext %x", enc1.GetName(), enc1.GetCiphertext()) + } + + // Rotate: version 2 becomes primary; new ciphertexts use it. + if _, err = c.CreateCryptoKeyVersion(ctx, &kmspb.CreateCryptoKeyVersionRequest{Parent: key}); err != nil { + t.Fatalf("CreateCryptoKeyVersion: %v", err) + } + + if _, err = c.UpdateCryptoKeyPrimaryVersion(ctx, &kmspb.UpdateCryptoKeyPrimaryVersionRequest{ + Name: key, CryptoKeyVersionId: "2", + }); err != nil { + t.Fatalf("UpdateCryptoKeyPrimaryVersion: %v", err) + } + + enc2, err := c.Encrypt(ctx, &kmspb.EncryptRequest{Name: key, Plaintext: []byte("world")}) + if err != nil || enc2.GetName() != key+"/cryptoKeyVersions/2" { + t.Fatalf("Encrypt after rotation: name %q err %v", enc2.GetName(), err) + } + + tests := []struct { + name string + ct, aad []byte + want string + usedPrimary bool + }{ + {name: "old version after rotation", ct: enc1.GetCiphertext(), aad: aad, want: "hello"}, + {name: "primary version", ct: enc2.GetCiphertext(), want: "world", usedPrimary: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dec, err := c.Decrypt(ctx, &kmspb.DecryptRequest{Name: key, Ciphertext: tc.ct, AdditionalAuthenticatedData: tc.aad}) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + + if string(dec.GetPlaintext()) != tc.want || dec.GetUsedPrimary() != tc.usedPrimary { + t.Fatalf("plaintext %q usedPrimary %v, want %q %v", dec.GetPlaintext(), dec.GetUsedPrimary(), tc.want, tc.usedPrimary) + } + }) + } + + _, err = c.Decrypt(ctx, &kmspb.DecryptRequest{Name: key, Ciphertext: enc1.GetCiphertext(), AdditionalAuthenticatedData: []byte("tenant=b")}) + wantStatus(t, err, "INVALID_ARGUMENT", "ciphertext is invalid") + + // A disabled version keeps its material but refuses to decrypt. + _, err = c.UpdateCryptoKeyVersion(ctx, &kmspb.UpdateCryptoKeyVersionRequest{ + CryptoKeyVersion: &kmspb.CryptoKeyVersion{Name: key + "/cryptoKeyVersions/1", State: kmspb.CryptoKeyVersion_DISABLED}, + UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"state"}}, + }) + if err != nil { + t.Fatalf("disable version 1: %v", err) + } + + _, err = c.Decrypt(ctx, &kmspb.DecryptRequest{Name: key, Ciphertext: enc1.GetCiphertext(), AdditionalAuthenticatedData: aad}) + wantStatus(t, err, "FAILED_PRECONDITION", "DISABLED") +} + +func TestKMSAsymmetricSign(t *testing.T) { + ctx := context.Background() + c := newGapicKMS(t) + msg := []byte("sign me") + sum := sha256.Sum256(msg) + + tests := []struct { + id string + alg kmspb.CryptoKeyVersion_CryptoKeyVersionAlgorithm + verify func(pub any, sig []byte) bool + }{ + {id: "ec", alg: kmspb.CryptoKeyVersion_EC_SIGN_P256_SHA256, verify: func(pub any, sig []byte) bool { + return ecdsa.VerifyASN1(pub.(*ecdsa.PublicKey), sum[:], sig) + }}, + {id: "pss", alg: kmspb.CryptoKeyVersion_RSA_SIGN_PSS_2048_SHA256, verify: func(pub any, sig []byte) bool { + return rsa.VerifyPSS(pub.(*rsa.PublicKey), crypto.SHA256, sum[:], sig, + &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash}) == nil + }}, + {id: "pkcs1", alg: kmspb.CryptoKeyVersion_RSA_SIGN_PKCS1_2048_SHA256, verify: func(pub any, sig []byte) bool { + return rsa.VerifyPKCS1v15(pub.(*rsa.PublicKey), crypto.SHA256, sum[:], sig) == nil + }}, + {id: "ed", alg: kmspb.CryptoKeyVersion_EC_SIGN_ED25519, verify: func(pub any, sig []byte) bool { + return ed25519.Verify(pub.(ed25519.PublicKey), msg, sig) + }}, + } + + for _, tc := range tests { + t.Run(tc.id, func(t *testing.T) { + ver := mustGapicKey(t, c, tc.id, kmspb.CryptoKey_ASYMMETRIC_SIGN, tc.alg) + "/cryptoKeyVersions/1" + + req := &kmspb.AsymmetricSignRequest{Name: ver, Digest: &kmspb.Digest{Digest: &kmspb.Digest_Sha256{Sha256: sum[:]}}} + if tc.alg == kmspb.CryptoKeyVersion_EC_SIGN_ED25519 { + req = &kmspb.AsymmetricSignRequest{Name: ver, Data: msg} + } + + sig, err := c.AsymmetricSign(ctx, req) + if err != nil { + t.Fatalf("AsymmetricSign: %v", err) + } + + pk, err := c.GetPublicKey(ctx, &kmspb.GetPublicKeyRequest{Name: ver}) + if err != nil { + t.Fatalf("GetPublicKey: %v", err) + } + + block, _ := pem.Decode([]byte(pk.GetPem())) + if block == nil { + t.Fatalf("public key is not PEM: %q", pk.GetPem()) + } + + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + t.Fatalf("ParsePKIXPublicKey: %v", err) + } + + if pk.GetAlgorithm() != tc.alg || !tc.verify(pub, sig.GetSignature()) { + t.Fatalf("algorithm %v, signature does not verify with the version's public key", pk.GetAlgorithm()) + } + }) + } +} + +func TestKMSAsymmetricDecryptAndMAC(t *testing.T) { + ctx := context.Background() + c := newGapicKMS(t) + + ver := mustGapicKey(t, c, "oaep", kmspb.CryptoKey_ASYMMETRIC_DECRYPT, + kmspb.CryptoKeyVersion_RSA_DECRYPT_OAEP_2048_SHA256) + "/cryptoKeyVersions/1" + + pk, err := c.GetPublicKey(ctx, &kmspb.GetPublicKeyRequest{Name: ver}) + if err != nil { + t.Fatalf("GetPublicKey: %v", err) + } + + block, _ := pem.Decode([]byte(pk.GetPem())) + + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + t.Fatalf("ParsePKIXPublicKey: %v", err) + } + + ct, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, pub.(*rsa.PublicKey), []byte("secret"), nil) + if err != nil { + t.Fatalf("EncryptOAEP: %v", err) + } + + dec, err := c.AsymmetricDecrypt(ctx, &kmspb.AsymmetricDecryptRequest{Name: ver, Ciphertext: ct}) + if err != nil || string(dec.GetPlaintext()) != "secret" { + t.Fatalf("AsymmetricDecrypt = %q, %v", dec.GetPlaintext(), err) + } + + macVer := mustGapicKey(t, c, "mac", kmspb.CryptoKey_MAC, kmspb.CryptoKeyVersion_HMAC_SHA256) + "/cryptoKeyVersions/1" + + signed, err := c.MacSign(ctx, &kmspb.MacSignRequest{Name: macVer, Data: []byte("payload")}) + if err != nil { + t.Fatalf("MacSign: %v", err) + } + + for _, tc := range []struct { + name string + data []byte + want bool + }{ + {name: "same data", data: []byte("payload"), want: true}, + {name: "tampered data", data: []byte("payload!"), want: false}, + } { + got, err := c.MacVerify(ctx, &kmspb.MacVerifyRequest{Name: macVer, Data: tc.data, Mac: signed.GetMac()}) + if err != nil || got.GetSuccess() != tc.want { + t.Fatalf("%s: MacVerify success=%v err=%v, want %v", tc.name, got.GetSuccess(), err, tc.want) + } + } + + if len(signed.GetMac()) != sha256.Size { + t.Fatalf("MAC is %d bytes, want %d", len(signed.GetMac()), sha256.Size) + } + + // A symmetric key cannot sign. + _, err = c.AsymmetricSign(ctx, &kmspb.AsymmetricSignRequest{Name: macVer, Data: []byte("x")}) + wantStatus(t, err, "FAILED_PRECONDITION", "ASYMMETRIC_SIGN") + + rnd, err := c.GenerateRandomBytes(ctx, &kmspb.GenerateRandomBytesRequest{ + Location: testLocationParent, LengthBytes: 32, ProtectionLevel: kmspb.ProtectionLevel_SOFTWARE, + }) + if err != nil || len(rnd.GetData()) != 32 { + t.Fatalf("GenerateRandomBytes = %d bytes, %v", len(rnd.GetData()), err) + } +} diff --git a/server/gcp/kms/dataplane_store.go b/server/gcp/kms/dataplane_store.go new file mode 100644 index 000000000..d08a50c95 --- /dev/null +++ b/server/gcp/kms/dataplane_store.go @@ -0,0 +1,128 @@ +package kms + +import ( + "slices" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// usableVersion returns version id of ck after checking the key purpose and +// the ENABLED state, generating the key material on first use. Callers hold +// s.mu for writing. +func usableVersion(ck *cryptoKeyModel, id string, purposes ...string) (*versionModel, error) { + if !slices.Contains(purposes, ck.purpose) { + return nil, cerrors.Newf(cerrors.FailedPrecondition, + "%s has purpose %s; this operation requires %s", ck.name, ck.purpose, strings.Join(purposes, " or ")) + } + + v, ok := ck.versions[id] + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "CryptoKeyVersion %s/cryptoKeyVersions/%s not found", ck.name, id) + } + + if v.state != stateEnabled { + return nil, cerrors.Newf(cerrors.FailedPrecondition, + "%s/cryptoKeyVersions/%s is not enabled, current state is: %s", ck.name, id, v.state) + } + + if err := v.ensureMaterial(); err != nil { + return nil, err + } + + return v, nil +} + +// withVersion runs fn on the route's version once it is usable for one of +// purposes. +func (s *store) withVersion(rt *route, fn func(name string, v *versionModel) error, purposes ...string) error { + s.mu.Lock() + defer s.mu.Unlock() + + ck, err := s.findCryptoKey(rt) + if err != nil { + return err + } + + v, err := usableVersion(ck, rt.version, purposes...) + if err != nil { + return err + } + + return fn(versionName(ck, v.id), v) +} + +// encrypt seals plaintext under the route's version, or under the key's +// primary version when the route names the crypto key. +func (s *store) encrypt(rt *route, plaintext, aad []byte) (opResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + + ck, err := s.findCryptoKey(rt) + if err != nil { + return opResult{}, err + } + + id := rt.version + if rt.kind == kindCryptoKey { + id = ck.primaryID + } + + if id == "" { + return opResult{}, cerrors.Newf(cerrors.FailedPrecondition, "%s has no primary version", ck.name) + } + + v, err := usableVersion(ck, id, purposeEncryptDecrypt) + if err != nil { + return opResult{}, err + } + + ct, err := v.seal(plaintext, aad) + + return opResult{name: versionName(ck, id), protectionLevel: v.protectionLevel, out: ct}, err +} + +// decrypt opens a ciphertext produced by encrypt. It uses the version id the +// ciphertext carries, so versions rotated out of primary still decrypt. +func (s *store) decrypt(rt *route, ct, aad []byte) (opResult, error) { + id, ok := ciphertextVersion(ct) + if !ok { + return opResult{}, errInvalidCiphertext() + } + + s.mu.Lock() + defer s.mu.Unlock() + + ck, err := s.findCryptoKey(rt) + if err != nil { + return opResult{}, err + } + + if _, exists := ck.versions[id]; !exists && ck.purpose == purposeEncryptDecrypt { + return opResult{}, errInvalidCiphertext() + } + + v, err := usableVersion(ck, id, purposeEncryptDecrypt) + if err != nil { + return opResult{}, err + } + + pt, err := v.open(ct, aad) + + return opResult{ + name: versionName(ck, id), protectionLevel: v.protectionLevel, + out: pt, usedPrimary: id == ck.primaryID, + }, err +} + +// opResult is what a data-plane call hands back to the wire layer. +type opResult struct { + name string + protectionLevel string + out []byte + usedPrimary bool +} + +func versionName(ck *cryptoKeyModel, id string) string { + return ck.name + "/cryptoKeyVersions/" + id +} diff --git a/server/gcp/kms/dataplane_types.go b/server/gcp/kms/dataplane_types.go new file mode 100644 index 000000000..935a2bd45 --- /dev/null +++ b/server/gcp/kms/dataplane_types.go @@ -0,0 +1,166 @@ +package kms + +import ( + "hash/crc32" + "strconv" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// jsonNull is the JSON literal an omitted optional field may carry. +const jsonNull = "null" + +// crcValue is a google.protobuf.Int64Value CRC32C checksum. proto3 JSON sends +// it as a quoted string, but a bare number is accepted too. +type crcValue struct { + v uint32 + set bool +} + +// UnmarshalJSON accepts "123", 123 or null. +func (c *crcValue) UnmarshalJSON(b []byte) error { + s := strings.Trim(strings.TrimSpace(string(b)), `"`) + if s == "" || s == jsonNull { + return nil + } + + n, err := strconv.ParseUint(s, 10, 32) + if err != nil { + return err + } + + c.v, c.set = uint32(n), true + + return nil +} + +//nolint:gochecknoglobals // immutable CRC32C table shared by every checksum +var castagnoli = crc32.MakeTable(crc32.Castagnoli) + +// crcOf returns the CRC32C of b in its proto3 JSON (decimal string) form. +func crcOf(b []byte) string { + return strconv.FormatUint(uint64(crc32.Checksum(b, castagnoli)), 10) +} + +// checkCRC verifies a client-supplied checksum. verified is true when the +// checksum was supplied and matched; a mismatch is INVALID_ARGUMENT, as in +// real Cloud KMS. +func checkCRC(crcField, dataField string, data []byte, c crcValue) (verified bool, err error) { + if !c.set { + return false, nil + } + + if crc32.Checksum(data, castagnoli) != c.v { + return false, cerrors.Newf(cerrors.InvalidArgument, + "The checksum in field %s did not match the data in field %s.", crcField, dataField) + } + + return true, nil +} + +type encryptRequest struct { + Plaintext []byte `json:"plaintext"` + AdditionalAuthenticatedData []byte `json:"additionalAuthenticatedData"` + PlaintextCrc32c crcValue `json:"plaintextCrc32c"` + AdditionalAuthenticatedDataCrc32c crcValue `json:"additionalAuthenticatedDataCrc32c"` +} + +type encryptResponse struct { + Name string `json:"name"` + Ciphertext []byte `json:"ciphertext"` + CiphertextCrc32c string `json:"ciphertextCrc32c"` + VerifiedPlaintextCrc32c bool `json:"verifiedPlaintextCrc32c,omitempty"` + VerifiedAdditionalAuthenticatedDataCrc32c bool `json:"verifiedAdditionalAuthenticatedDataCrc32c,omitempty"` + ProtectionLevel string `json:"protectionLevel,omitempty"` +} + +type decryptRequest struct { + Ciphertext []byte `json:"ciphertext"` + AdditionalAuthenticatedData []byte `json:"additionalAuthenticatedData"` + CiphertextCrc32c crcValue `json:"ciphertextCrc32c"` + AdditionalAuthenticatedDataCrc32c crcValue `json:"additionalAuthenticatedDataCrc32c"` +} + +type decryptResponse struct { + Plaintext []byte `json:"plaintext"` + PlaintextCrc32c string `json:"plaintextCrc32c"` + UsedPrimary bool `json:"usedPrimary,omitempty"` + ProtectionLevel string `json:"protectionLevel,omitempty"` +} + +type digestJSON struct { + Sha256 []byte `json:"sha256"` + Sha384 []byte `json:"sha384"` + Sha512 []byte `json:"sha512"` +} + +type asymmetricSignRequest struct { + Digest *digestJSON `json:"digest"` + DigestCrc32c crcValue `json:"digestCrc32c"` + Data []byte `json:"data"` + DataCrc32c crcValue `json:"dataCrc32c"` +} + +type asymmetricSignResponse struct { + Signature []byte `json:"signature"` + SignatureCrc32c string `json:"signatureCrc32c"` + VerifiedDigestCrc32c bool `json:"verifiedDigestCrc32c,omitempty"` + VerifiedDataCrc32c bool `json:"verifiedDataCrc32c,omitempty"` + Name string `json:"name"` + ProtectionLevel string `json:"protectionLevel,omitempty"` +} + +type asymmetricDecryptRequest struct { + Ciphertext []byte `json:"ciphertext"` + CiphertextCrc32c crcValue `json:"ciphertextCrc32c"` +} + +type asymmetricDecryptResponse struct { + Plaintext []byte `json:"plaintext"` + PlaintextCrc32c string `json:"plaintextCrc32c"` + VerifiedCiphertextCrc32c bool `json:"verifiedCiphertextCrc32c,omitempty"` + ProtectionLevel string `json:"protectionLevel,omitempty"` +} + +type publicKeyResponse struct { + Pem string `json:"pem"` + Algorithm string `json:"algorithm"` + PemCrc32c string `json:"pemCrc32c"` + Name string `json:"name"` + ProtectionLevel string `json:"protectionLevel,omitempty"` + PublicKeyFormat string `json:"publicKeyFormat,omitempty"` +} + +type macRequest struct { + Data []byte `json:"data"` + DataCrc32c crcValue `json:"dataCrc32c"` + Mac []byte `json:"mac"` + MacCrc32c crcValue `json:"macCrc32c"` +} + +type macSignResponse struct { + Name string `json:"name"` + Mac []byte `json:"mac"` + MacCrc32c string `json:"macCrc32c"` + VerifiedDataCrc32c bool `json:"verifiedDataCrc32c,omitempty"` + ProtectionLevel string `json:"protectionLevel,omitempty"` +} + +type macVerifyResponse struct { + Name string `json:"name"` + Success bool `json:"success"` + VerifiedDataCrc32c bool `json:"verifiedDataCrc32c,omitempty"` + VerifiedMacCrc32c bool `json:"verifiedMacCrc32c,omitempty"` + VerifiedSuccessIntegrity bool `json:"verifiedSuccessIntegrity,omitempty"` + ProtectionLevel string `json:"protectionLevel,omitempty"` +} + +type generateRandomBytesRequest struct { + LengthBytes int `json:"lengthBytes"` +} + +type generateRandomBytesResponse struct { + Data []byte `json:"data"` + DataCrc32c string `json:"dataCrc32c"` +} diff --git a/server/gcp/kms/enums.go b/server/gcp/kms/enums.go index c3ab89cf1..351dd79bb 100644 --- a/server/gcp/kms/enums.go +++ b/server/gcp/kms/enums.go @@ -110,7 +110,7 @@ type rawEnum struct { // UnmarshalJSON accepts a quoted enum name or a bare integer. func (e *rawEnum) UnmarshalJSON(b []byte) error { s := strings.TrimSpace(string(b)) - if s == "" || s == "null" { + if s == "" || s == jsonNull { return nil } diff --git a/server/gcp/kms/handler.go b/server/gcp/kms/handler.go index 32f23d6cc..5f9a25fd0 100644 --- a/server/gcp/kms/handler.go +++ b/server/gcp/kms/handler.go @@ -29,9 +29,20 @@ // ENCRYPT_DECRYPT keys), matching real Cloud KMS. Version destruction is a // state transition to DESTROY_SCHEDULED. The version, key and ring persist. // -// The data plane (Encrypt/Decrypt/Sign/Verify/MAC/GenerateRandomBytes), import -// jobs, EKM/external keys and Autokey are out of scope for this control-plane -// build; new versions go straight to ENABLED (no async PENDING_GENERATION). +// Data plane: +// +// POST .../cryptoKeys/{k}:encrypt and .../cryptoKeyVersions/{v}:encrypt : AES-GCM encrypt +// POST .../cryptoKeys/{k}:decrypt : AES-GCM decrypt +// POST .../cryptoKeyVersions/{v}:asymmetricSign and :asymmetricDecrypt : RSA, ECDSA, Ed25519 +// GET .../cryptoKeyVersions/{v}/publicKey : PKIX PEM public key +// POST .../cryptoKeyVersions/{v}:macSign and :macVerify : HMAC +// POST /v1/projects/{p}/locations/{l}:generateRandomBytes : Random bytes +// +// Each version gets real key material from the Go stdlib on first data-plane +// use and keeps it, so ciphertexts and signatures stay valid after rotation. +// The symmetric ciphertext carries its version id, so decrypt always picks the +// version that sealed it. Import jobs, EKM/external keys and Autokey are out of +// scope; new versions go straight to ENABLED (no async PENDING_GENERATION). package kms import ( @@ -70,6 +81,7 @@ const ( kindCryptoKey kindVersionColl kindVersion + kindLocation // locations/{l}:generateRandomBytes ) type route struct { @@ -98,6 +110,7 @@ const ( depthCryptoKey = 3 // keyRings/{r}/cryptoKeys/{k} depthVersionColl = 4 // .../cryptoKeys/{k}/cryptoKeyVersions depthVersion = 5 // .../cryptoKeyVersions/{v} + depthPublicKey = 6 // .../cryptoKeyVersions/{v}/publicKey ) // parseRoute decomposes a Cloud KMS v1 path. The trailing segment may carry a @@ -114,6 +127,15 @@ func parseRoute(urlPath string) (*route, bool) { } parts := strings.Split(strings.TrimPrefix(urlPath, "/v1/"), "/") + if len(parts) == minHeadParts-1 && parts[0] == projectsSeg && parts[2] == locationsSeg { + loc, verb, _ := strings.Cut(parts[3], ":") + if verb != verbGenerateRandomBytes { + return nil, false + } + + return &route{project: parts[1], location: loc, verb: verb, kind: kindLocation}, true + } + if len(parts) < minHeadParts || parts[0] != projectsSeg || parts[2] != locationsSeg || parts[keyRingsHeadIndex] != keyRingsSeg { return nil, false @@ -166,6 +188,13 @@ func fillRoute(rt *route, rest []string) bool { rt.keyRing, rt.cryptoKey = rest[0], rest[2] rt.version, rt.verb, _ = strings.Cut(rest[4], ":") rt.kind = kindVersion + case depthPublicKey: + if rest[1] != cryptoKeysSeg || rest[3] != versionsSeg || rest[5] != verbPublicKey { + return false + } + + rt.keyRing, rt.cryptoKey, rt.version = rest[0], rest[2], rest[4] + rt.verb, rt.kind = verbPublicKey, kindVersion default: return false } @@ -205,6 +234,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.serveVersionCollection(w, r, rt) case kindVersion: h.serveVersion(w, r, rt) + case kindLocation: + postOnly(w, r, func() { h.generateRandomBytes(w, r) }) } } @@ -250,6 +281,10 @@ func (h *Handler) serveCryptoKey(w http.ResponseWriter, r *http.Request, rt *rou h.serveCryptoKeyNoVerb(w, r, rt) case verbUpdatePrimary: postOnly(w, r, func() { h.updatePrimaryVersion(w, r, rt) }) + case verbEncrypt: + postOnly(w, r, func() { h.encrypt(w, r, rt) }) + case verbDecrypt: + postOnly(w, r, func() { h.decrypt(w, r, rt) }) default: h.serveIamVerb(w, r, rt) } @@ -285,6 +320,25 @@ func (h *Handler) serveVersion(w http.ResponseWriter, r *http.Request, rt *route postOnly(w, r, func() { h.destroyVersion(w, rt) }) case verbRestore: postOnly(w, r, func() { h.restoreVersion(w, rt) }) + default: + h.serveVersionDataPlane(w, r, rt) + } +} + +// serveVersionDataPlane dispatches the cryptographic custom methods that +// address a specific CryptoKeyVersion. +func (h *Handler) serveVersionDataPlane(w http.ResponseWriter, r *http.Request, rt *route) { + switch rt.verb { + case verbEncrypt: + postOnly(w, r, func() { h.encrypt(w, r, rt) }) + case verbAsymmetricSign: + postOnly(w, r, func() { h.asymmetricSign(w, r, rt) }) + case verbAsymmetricDecrypt: + postOnly(w, r, func() { h.asymmetricDecrypt(w, r, rt) }) + case verbMacSign, verbMacVerify: + postOnly(w, r, func() { h.macOp(w, r, rt) }) + case verbPublicKey: + getOnly(w, r, func() { h.getPublicKey(w, rt) }) default: writeUnsupported(w) } diff --git a/server/gcp/kms/store.go b/server/gcp/kms/store.go index 000b76341..a4886f6b8 100644 --- a/server/gcp/kms/store.go +++ b/server/gcp/kms/store.go @@ -1,6 +1,7 @@ package kms import ( + "crypto" "sort" "strconv" "sync" @@ -63,6 +64,11 @@ type versionModel struct { createTime time.Time destroyTime string destroyEventTime string + // secret is the AES-256 (symmetric) or HMAC key; priv is the RSA/EC/Ed25519 + // private key. Both are generated on first data-plane use and then kept for + // the version's lifetime so old ciphertexts and signatures stay valid. + secret []byte + priv crypto.Signer } // store is the in-memory Cloud KMS control-plane backing state. Cloud KMS has