diff --git a/docs/coverage/README.md b/docs/coverage/README.md index 6fee07c68..552b93b2b 100644 --- a/docs/coverage/README.md +++ b/docs/coverage/README.md @@ -109,7 +109,7 @@ code does not implement. Machine-readable: [`coverage.json`](./coverage.json). | `kinesis` | [Kinesis](./aws/kinesis.md) | - | - | - | 39 | | `kinesisvideo` | [KinesisVideo](./aws/kinesisvideo.md) | - | - | - | 17 | | `kms` | [KMS](./aws/kms.md) | - | - | - | 46 | -| `kms-gcp` | - | - | [KMS](./gcp/kms.md) | - | 17 | +| `kms-gcp` | - | - | [KMS](./gcp/kms.md) | - | 22 | | `kusto` | - | [Kusto](./azure/kusto.md) | - | - | 14 | | `loadbalancer` | [ELB](./aws/elb.md) | [LB](./azure/lb.md) | [LB](./gcp/lb.md) | - | 19 | | `loadtesting` | - | [LoadTesting](./azure/loadtesting.md) | - | - | 8 | diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index d76b15c7c..5503dd0ad 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -9827,6 +9827,12 @@ "service": "kms-gcp", "interface": "provider-native", "operations": [ + { + "name": "AsymmetricDecrypt" + }, + { + "name": "AsymmetricSign" + }, { "name": "CreateCryptoKey" }, @@ -9836,9 +9842,18 @@ { "name": "CreateKeyRing" }, + { + "name": "Decrypt" + }, { "name": "DestroyCryptoKeyVersion" }, + { + "name": "Encrypt" + }, + { + "name": "GenerateRandomBytes" + }, { "name": "GetCryptoKey" }, @@ -9846,10 +9861,10 @@ "name": "GetCryptoKeyVersion" }, { - "name": "GetIamPolicy" + "name": "GetKeyRing" }, { - "name": "GetKeyRing" + "name": "GetPublicKey" }, { "name": "ListCryptoKeyVersions" @@ -9861,13 +9876,13 @@ "name": "ListKeyRings" }, { - "name": "RestoreCryptoKeyVersion" + "name": "MacSign" }, { - "name": "SetIamPolicy" + "name": "MacVerify" }, { - "name": "TestIamPermissions" + "name": "RestoreCryptoKeyVersion" }, { "name": "UpdateCryptoKey" diff --git a/docs/coverage/gcp/README.md b/docs/coverage/gcp/README.md index 9cb0c5db6..4b7e97373 100644 --- a/docs/coverage/gcp/README.md +++ b/docs/coverage/gcp/README.md @@ -42,7 +42,7 @@ Services cloudemu emulates for GCP, by native name. Back to the [cross-provider | [GKEBackup](./gkebackup.md) | `gkebackup` | 11 | | [GKEHub](./gkehub.md) | `gkehub` | 16 | | [IAM](./iam.md) | `iam` | 40 | -| [KMS](./kms.md) | (provider-native) | 17 | +| [KMS](./kms.md) | (provider-native) | 22 | | [LB](./lb.md) | `loadbalancer` | 19 | | [LRO](./lro.md) | (provider-native) | 1 | | [ManagedKafka](./managedkafka.md) | `managedkafka` | 11 | diff --git a/docs/coverage/gcp/kms.md b/docs/coverage/gcp/kms.md index b3861c968..fc48d629f 100644 --- a/docs/coverage/gcp/kms.md +++ b/docs/coverage/gcp/kms.md @@ -3,24 +3,29 @@ provider-native `kms-gcp` wire service (GCP-only) · no portable driver · [GCP index](./README.md) -## Operations (17) +## Operations (22) | Operation | Description | | --- | --- | +| `AsymmetricDecrypt` | | +| `AsymmetricSign` | | | `CreateCryptoKey` | | | `CreateCryptoKeyVersion` | | | `CreateKeyRing` | | +| `Decrypt` | | | `DestroyCryptoKeyVersion` | | +| `Encrypt` | | +| `GenerateRandomBytes` | | | `GetCryptoKey` | | | `GetCryptoKeyVersion` | | -| `GetIamPolicy` | | | `GetKeyRing` | | +| `GetPublicKey` | | | `ListCryptoKeyVersions` | | | `ListCryptoKeys` | | | `ListKeyRings` | | +| `MacSign` | | +| `MacVerify` | | | `RestoreCryptoKeyVersion` | | -| `SetIamPolicy` | | -| `TestIamPermissions` | | | `UpdateCryptoKey` | | | `UpdateCryptoKeyPrimaryVersion` | | | `UpdateCryptoKeyVersion` | | diff --git a/internal/coveragegen/wireops.go b/internal/coveragegen/wireops.go index 49abed02c..206c264bf 100644 --- a/internal/coveragegen/wireops.go +++ b/internal/coveragegen/wireops.go @@ -146,14 +146,6 @@ var nativeWireOperations = map[string][]string{ //nolint:gochecknoglobals // gen "gcp/resourcemanager": { "GetIamPolicy", "SetIamPolicy", "TestIamPermissions", }, - "gcp/kms": { - "CreateCryptoKey", "CreateCryptoKeyVersion", "CreateKeyRing", - "DestroyCryptoKeyVersion", "GetCryptoKey", "GetCryptoKeyVersion", - "GetIamPolicy", "GetKeyRing", "ListCryptoKeyVersions", "ListCryptoKeys", - "ListKeyRings", "RestoreCryptoKeyVersion", "SetIamPolicy", - "TestIamPermissions", "UpdateCryptoKey", "UpdateCryptoKeyPrimaryVersion", - "UpdateCryptoKeyVersion", - }, "gcp/cloudbilling": { "CreateBillingAccount", "CreateBudget", "DeleteBudget", "GetBillingAccount", "GetBudget", "GetProjectBillingInfo", "ListBillingAccounts", "ListBudgets", diff --git a/persist/kms_persist_test.go b/persist/kms_persist_test.go new file mode 100644 index 000000000..beb4fdeb3 --- /dev/null +++ b/persist/kms_persist_test.go @@ -0,0 +1,103 @@ +package persist_test + +import ( + "crypto/ecdsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "net/http" + "testing" + + cloudemu "github.com/stackshy/cloudemu/v2" + gcpserver "github.com/stackshy/cloudemu/v2/server/gcp" +) + +func kmsCall(t *testing.T, h http.Handler, c wireCall) map[string]any { + t.Helper() + + code, body := doWire(t, h, c) + if code != http.StatusOK { + t.Fatalf("%s %s = %d %s", c.method, c.path, code, body) + } + + var out map[string]any + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Fatalf("decode %s: %v", body, err) + } + + return out +} + +// TestKMSKeysSurviveRestore covers GKMS-N1: key rings, crypto keys, versions +// and their key material used to live only in the wire handler, so a +// serve --persist restart lost them and stored ciphertexts became +// undecryptable. +func TestKMSKeysSurviveRestore(t *testing.T) { + const ( + loc = "/v1/projects/p1/locations/us-central1" + ring = loc + "/keyRings/r1" + sym = ring + "/cryptoKeys/sym" + sig = ring + "/cryptoKeys/sig/cryptoKeyVersions/1" + ) + + src := cloudemu.NewGCP() + srcSrv := gcpserver.NewFromProvider(src) + + mustWire(t, srcSrv, []wireCall{ + {http.MethodPost, loc + "/keyRings?keyRingId=r1", `{}`}, + {http.MethodPost, ring + "/cryptoKeys?cryptoKeyId=sym", `{"purpose":"ENCRYPT_DECRYPT"}`}, + {http.MethodPost, ring + "/cryptoKeys?cryptoKeyId=sig", + `{"purpose":"ASYMMETRIC_SIGN","versionTemplate":{"algorithm":"EC_SIGN_P256_SHA256"}}`}, + {http.MethodPost, ring + ":setIamPolicy", + `{"policy":{"bindings":[{"role":"roles/cloudkms.admin","members":["user:a@example.com"]}]}}`}, + }) + + aad := base64.StdEncoding.EncodeToString([]byte("ctx")) + enc := kmsCall(t, srcSrv, wireCall{http.MethodPost, sym + ":encrypt", + `{"plaintext":"` + base64.StdEncoding.EncodeToString([]byte("hello")) + `","additionalAuthenticatedData":"` + aad + `"}`}) + + digest := sha256.Sum256([]byte("msg")) + signed := kmsCall(t, srcSrv, wireCall{http.MethodPost, sig + ":asymmetricSign", + `{"digest":{"sha256":"` + base64.StdEncoding.EncodeToString(digest[:]) + `"}}`}) + pubBefore := kmsCall(t, srcSrv, wireCall{http.MethodGet, sig + "/publicKey", ""}) + + dst := cloudemu.NewGCP() + roundTrip(t, "gcp", src.SnapshotServices(), dst.SnapshotServices()) + dstSrv := gcpserver.NewFromProvider(dst) + + assertSameReads(t, srcSrv, dstSrv, []string{ring, sym, loc + "/keyRings", sym + "/cryptoKeyVersions", ring + ":getIamPolicy"}) + + dec := kmsCall(t, dstSrv, wireCall{http.MethodPost, sym + ":decrypt", + `{"ciphertext":"` + enc["ciphertext"].(string) + `","additionalAuthenticatedData":"` + aad + `"}`}) + if got, _ := base64.StdEncoding.DecodeString(dec["plaintext"].(string)); string(got) != "hello" { + t.Fatalf("decrypt after restore = %q, want hello", got) + } + + pubAfter := kmsCall(t, dstSrv, wireCall{http.MethodGet, sig + "/publicKey", ""}) + if pubAfter["pem"] != pubBefore["pem"] { + t.Fatalf("public key changed across restore") + } + + block, _ := pem.Decode([]byte(pubAfter["pem"].(string))) + + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + t.Fatalf("ParsePKIXPublicKey: %v", err) + } + + sigBytes, _ := base64.StdEncoding.DecodeString(signed["signature"].(string)) + if !ecdsa.VerifyASN1(pub.(*ecdsa.PublicKey), digest[:], sigBytes) { + t.Fatal("signature made before restore does not verify with the restored public key") + } + + // A key restored from a snapshot keeps signing with the same private key. + again := kmsCall(t, dstSrv, wireCall{http.MethodPost, sig + ":asymmetricSign", + `{"digest":{"sha256":"` + base64.StdEncoding.EncodeToString(digest[:]) + `"}}`}) + + sigAgain, _ := base64.StdEncoding.DecodeString(again["signature"].(string)) + if !ecdsa.VerifyASN1(pub.(*ecdsa.PublicKey), digest[:], sigAgain) { + t.Fatal("signature made after restore does not verify with the original public key") + } +} diff --git a/providers/gcp/gcp.go b/providers/gcp/gcp.go index 789c1925b..c602b2c66 100644 --- a/providers/gcp/gcp.go +++ b/providers/gcp/gcp.go @@ -41,6 +41,7 @@ import ( gkebackupprov "github.com/stackshy/cloudemu/v2/providers/gcp/gkebackup" gkehubprov "github.com/stackshy/cloudemu/v2/providers/gcp/gkehub" "github.com/stackshy/cloudemu/v2/providers/gcp/iam" + kmsprov "github.com/stackshy/cloudemu/v2/providers/gcp/kms" "github.com/stackshy/cloudemu/v2/providers/gcp/loadbalancer" managedkafkaprov "github.com/stackshy/cloudemu/v2/providers/gcp/managedkafka" "github.com/stackshy/cloudemu/v2/providers/gcp/memorystore" @@ -119,6 +120,7 @@ type Provider struct { CertificateManager *certmanagerprov.Mock AccessContextManager *acmprov.Mock PrivateCA *privatecaprov.Mock + KMS *kmsprov.Mock Dataplex *dataplexprov.Mock Metastore *metastoreprov.Mock VPCAccess *vpcaccessprov.Mock @@ -194,6 +196,7 @@ func New(opts ...config.Option) *Provider { CertificateManager: certmanagerprov.New(o), AccessContextManager: acmprov.New(o), PrivateCA: privatecaprov.New(o), + KMS: kmsprov.New(o), Dataplex: dataplexprov.New(o), Metastore: metastoreprov.New(o), VPCAccess: vpcaccessprov.New(o), diff --git a/server/gcp/kms/crypto.go b/providers/gcp/kms/crypto.go similarity index 68% rename from server/gcp/kms/crypto.go rename to providers/gcp/kms/crypto.go index cd6384805..f5808eb65 100644 --- a/server/gcp/kms/crypto.go +++ b/providers/gcp/kms/crypto.go @@ -7,6 +7,7 @@ import ( "crypto/ecdsa" "crypto/ed25519" "crypto/elliptic" + "crypto/hmac" "crypto/rand" "crypto/rsa" "crypto/x509" @@ -27,9 +28,10 @@ const ( aes256KeyBytes = 32 aes128KeyBytes = 16 - algEd25519 = "EC_SIGN_ED25519" - algP256 = "EC_SIGN_P256_SHA256" - algP384 = "EC_SIGN_P384_SHA384" + algEd25519 = "EC_SIGN_ED25519" + algSymmetric = "GOOGLE_SYMMETRIC_ENCRYPTION" + algP256 = "EC_SIGN_P256_SHA256" + algP384 = "EC_SIGN_P384_SHA384" ) // errUnsupportedAlg reports an algorithm the emulator cannot back with real @@ -45,17 +47,17 @@ 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 { +// ensureMaterial generates the version's key on first use. Callers hold m.mu +// and store v afterwards. +func (v *Version) ensureMaterial() error { + if v.Secret != nil || v.PrivateKey != nil { return nil } - alg := v.algorithm + alg := v.Algorithm switch { - case alg == algorithmSymmetric || strings.HasPrefix(alg, "AES_256"): + case alg == algSymmetric || strings.HasPrefix(alg, "AES_256"): return v.randomSecret(aes256KeyBytes) case strings.HasPrefix(alg, "AES_128"): return v.randomSecret(aes128KeyBytes) @@ -68,13 +70,13 @@ func (v *versionModel) ensureMaterial() error { } } -func (v *versionModel) randomSecret(n int) error { +func (v *Version) 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 + v.Secret = b return nil } @@ -87,7 +89,7 @@ const ( rsaBits4096 = 4096 ) -func (v *versionModel) generateRSA(alg string) error { +func (v *Version) generateRSA(alg string) error { var ( k *rsa.PrivateKey err error @@ -108,12 +110,10 @@ func (v *versionModel) generateRSA(alg string) error { return cerrors.Newf(cerrors.Internal, "generate RSA key: %v", err) } - v.priv = k - - return nil + return v.setPrivate(k) } -func (v *versionModel) generateEC(alg string) error { +func (v *Version) generateEC(alg string) error { var ( k crypto.Signer err error @@ -134,9 +134,7 @@ func (v *versionModel) generateEC(alg string) error { return cerrors.Newf(cerrors.Internal, "generate key: %v", err) } - v.priv = k - - return nil + return v.setPrivate(k) } // hashFor returns the digest an algorithm name ends in (SHA256 by default). @@ -155,8 +153,8 @@ func hashFor(alg string) crypto.Hash { } } -func (v *versionModel) gcm() (cipher.AEAD, error) { - block, err := aes.NewCipher(v.secret) +func (v *Version) gcm() (cipher.AEAD, error) { + block, err := aes.NewCipher(v.Secret) if err != nil { return nil, cerrors.Newf(cerrors.Internal, "aes: %v", err) } @@ -171,15 +169,15 @@ func (v *versionModel) gcm() (cipher.AEAD, error) { // 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) { +func (v *Version) seal(plaintext, aad []byte) ([]byte, error) { aead, err := v.gcm() if err != nil { return nil, err } - id, err := strconv.ParseUint(v.id, 10, 32) + id, err := strconv.ParseUint(v.ID, 10, 32) if err != nil { - return nil, cerrors.Newf(cerrors.Internal, "version id %q: %v", v.id, err) + return nil, cerrors.Newf(cerrors.Internal, "version id %q: %v", v.ID, err) } out := make([]byte, 0, ciphertextHeader+aead.NonceSize()+len(plaintext)+aead.Overhead()) @@ -205,7 +203,7 @@ func ciphertextVersion(ct []byte) (string, bool) { return strconv.FormatUint(uint64(binary.BigEndian.Uint32(ct[1:ciphertextHeader])), 10), true } -func (v *versionModel) open(ct, aad []byte) ([]byte, error) { +func (v *Version) open(ct, aad []byte) ([]byte, error) { aead, err := v.gcm() if err != nil { return nil, err @@ -226,37 +224,47 @@ func (v *versionModel) open(ct, aad []byte) ([]byte, error) { // 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) { +func (v *Version) sign(digest, data []byte) ([]byte, error) { + priv, err := v.signer() + if err != nil { + return nil, err + } + + switch k := priv.(type) { case ed25519.PrivateKey: return ed25519.Sign(k, data), nil case *rsa.PrivateKey: - if strings.HasPrefix(v.algorithm, "RSA_SIGN_RAW_PKCS1") { + 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, + 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) + 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) + 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) +func (v *Version) decryptOAEP(ct []byte) ([]byte, error) { + priv, err := v.signer() + if err != nil { + return nil, err + } + + k, ok := priv.(*rsa.PrivateKey) if !ok { - return nil, errUnsupportedAlg(v.algorithm) + return nil, errUnsupportedAlg(v.Algorithm) } - pt, err := rsa.DecryptOAEP(hashFor(v.algorithm).New(), nil, k, ct, nil) + pt, err := rsa.DecryptOAEP(hashFor(v.Algorithm).New(), nil, k, ct, nil) if err != nil { return nil, errInvalidCiphertext() } @@ -265,15 +273,55 @@ func (v *versionModel) decryptOAEP(ct []byte) ([]byte, error) { } // 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) +func (v *Version) publicKeyPEM() (string, error) { + priv, err := v.signer() + if err != nil { + return "", err } - der, err := x509.MarshalPKIXPublicKey(v.priv.Public()) + der, err := x509.MarshalPKIXPublicKey(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 } + +// mac is macSign: HMAC of data under the version's secret. +func (v *Version) mac(data []byte) []byte { + mac := hmac.New(hashFor(v.Algorithm).New, v.Secret) + mac.Write(data) + + return mac.Sum(nil) +} + +// setPrivate stores k as PKCS#8 DER, the form snapshots persist. +func (v *Version) setPrivate(k crypto.Signer) error { + der, err := x509.MarshalPKCS8PrivateKey(k) + if err != nil { + return cerrors.Newf(cerrors.Internal, "marshal private key: %v", err) + } + + v.PrivateKey = der + + return nil +} + +// signer parses the version's PKCS#8 private key. +func (v *Version) signer() (crypto.Signer, error) { + if v.PrivateKey == nil { + return nil, errUnsupportedAlg(v.Algorithm) + } + + k, err := x509.ParsePKCS8PrivateKey(v.PrivateKey) + if err != nil { + return nil, cerrors.Newf(cerrors.Internal, "parse private key: %v", err) + } + + s, ok := k.(crypto.Signer) + if !ok { + return nil, errUnsupportedAlg(v.Algorithm) + } + + return s, nil +} diff --git a/providers/gcp/kms/dataplane.go b/providers/gcp/kms/dataplane.go new file mode 100644 index 000000000..b12707552 --- /dev/null +++ b/providers/gcp/kms/dataplane.go @@ -0,0 +1,275 @@ +package kms + +import ( + "crypto" + "crypto/hmac" + "crypto/rand" + "slices" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +const ( + purposeAsymmetricSign = "ASYMMETRIC_SIGN" + purposeAsymmetricDecrypt = "ASYMMETRIC_DECRYPT" + purposeMAC = "MAC" + + minRandomBytes = 8 + maxRandomBytes = 1024 +) + +// Result is what a data-plane call hands back to the wire layer. +type Result struct { + Name string + ProtectionLevel string + Out []byte + UsedPrimary bool +} + +// usableVersion returns version id of ck after checking the key purpose and +// the ENABLED state, generating and storing the key material on first use. +// Callers hold m.mu. +func (m *Mock) usableVersion(ck *CryptoKey, id string, purposes ...string) (Version, error) { + if !slices.Contains(purposes, ck.Purpose) { + return Version{}, cerrors.Newf(cerrors.FailedPrecondition, + "%s has purpose %s; this operation requires %s", ck.Name, ck.Purpose, strings.Join(purposes, " or ")) + } + + name := versionName(ck.Name, id) + + v, ok := m.versions.Get(name) + if !ok { + return Version{}, cerrors.Newf(cerrors.NotFound, "CryptoKeyVersion %s not found", name) + } + + if v.State != StateEnabled { + return Version{}, cerrors.Newf(cerrors.FailedPrecondition, + "%s is not enabled, current state is: %s", name, v.State) + } + + if v.Secret == nil && v.PrivateKey == nil { + if err := v.ensureMaterial(); err != nil { + return Version{}, err + } + + m.versions.Set(name, v) + } + + return v, nil +} + +// usable resolves ref's version for one of purposes. Callers hold m.mu. +func (m *Mock) usable(ref *Ref, purposes ...string) (string, Version, error) { + ck, err := m.findKey(ref) + if err != nil { + return "", Version{}, err + } + + v, err := m.usableVersion(&ck, ref.Version, purposes...) + + return versionName(ck.Name, ref.Version), v, err +} + +// Digest is an asymmetricSign request digest; the field matching the +// algorithm's hash is used. +type Digest struct { + Sha256, Sha384, Sha512 []byte +} + +// signInput resolves the bytes alg 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, digest *Digest, data []byte) ([]byte, error) { + if alg == algEd25519 || strings.HasPrefix(alg, "RSA_SIGN_RAW_PKCS1") { + if len(data) == 0 { + return nil, cerrors.Newf(cerrors.InvalidArgument, "data is required for algorithm %s", alg) + } + + return nil, nil + } + + hash := hashFor(alg) + + if digest == nil { + if len(data) == 0 { + return nil, cerrors.New(cerrors.InvalidArgument, "one of digest or data is required") + } + + hh := hash.New() + hh.Write(data) + + return hh.Sum(nil), nil + } + + d := map[crypto.Hash][]byte{crypto.SHA256: digest.Sha256, crypto.SHA384: digest.Sha384, crypto.SHA512: digest.Sha512}[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 +} + +// AsymmetricSign signs digest (or data) with ref's ASYMMETRIC_SIGN version. +func (m *Mock) AsymmetricSign(ref *Ref, digest *Digest, data []byte) (Result, error) { + m.mu.Lock() + defer m.mu.Unlock() + + name, v, err := m.usable(ref, purposeAsymmetricSign) + if err != nil { + return Result{}, err + } + + in, err := signInput(v.Algorithm, digest, data) + if err != nil { + return Result{}, err + } + + sig, err := v.sign(in, data) + if err != nil { + return Result{}, cerrors.Newf(cerrors.InvalidArgument, "sign: %v", err) + } + + return Result{Name: name, ProtectionLevel: v.ProtectionLevel, Out: sig}, nil +} + +// AsymmetricDecrypt opens an RSA-OAEP ciphertext with ref's ASYMMETRIC_DECRYPT +// version. +func (m *Mock) AsymmetricDecrypt(ref *Ref, ct []byte) (Result, error) { + m.mu.Lock() + defer m.mu.Unlock() + + name, v, err := m.usable(ref, purposeAsymmetricDecrypt) + if err != nil { + return Result{}, err + } + + pt, err := v.decryptOAEP(ct) + + return Result{Name: name, ProtectionLevel: v.ProtectionLevel, Out: pt}, err +} + +// PublicKey is a getPublicKey result. +type PublicKey struct { + Name, Pem, Algorithm, ProtectionLevel string +} + +// GetPublicKey returns the PEM public key of ref's asymmetric version. +func (m *Mock) GetPublicKey(ref *Ref) (PublicKey, error) { + m.mu.Lock() + defer m.mu.Unlock() + + name, v, err := m.usable(ref, purposeAsymmetricSign, purposeAsymmetricDecrypt) + if err != nil { + return PublicKey{}, err + } + + p, err := v.publicKeyPEM() + + return PublicKey{Name: name, Pem: p, Algorithm: v.Algorithm, ProtectionLevel: v.ProtectionLevel}, err +} + +// MacSign returns the HMAC of data under ref's MAC version. +func (m *Mock) MacSign(ref *Ref, data []byte) (Result, error) { + m.mu.Lock() + defer m.mu.Unlock() + + name, v, err := m.usable(ref, purposeMAC) + if err != nil { + return Result{}, err + } + + return Result{Name: name, ProtectionLevel: v.ProtectionLevel, Out: v.mac(data)}, nil +} + +// MacVerify reports whether mac is the HMAC of data under ref's MAC version. +func (m *Mock) MacVerify(ref *Ref, data, mac []byte) (Result, bool, error) { + res, err := m.MacSign(ref, data) + if err != nil { + return Result{}, false, err + } + + ok := hmac.Equal(res.Out, mac) + res.Out = nil + + return res, ok, nil +} + +// GenerateRandomBytes returns n random bytes; real Cloud KMS allows 8 to 1024. +func (*Mock) GenerateRandomBytes(n int) ([]byte, error) { + if n < minRandomBytes || n > maxRandomBytes { + return nil, cerrors.Newf(cerrors.InvalidArgument, + "lengthBytes must be between %d and %d", minRandomBytes, maxRandomBytes) + } + + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return nil, cerrors.Newf(cerrors.Internal, "random: %v", err) + } + + return b, nil +} + +// Encrypt seals plaintext under ref's version when ref names one, or under +// the key's primary version otherwise. +func (m *Mock) Encrypt(ref *Ref, plaintext, aad []byte) (Result, error) { + m.mu.Lock() + defer m.mu.Unlock() + + ck, err := m.findKey(ref) + if err != nil { + return Result{}, err + } + + id := ref.Version + if id == "" { + id = ck.PrimaryID + } + + if id == "" { + return Result{}, cerrors.Newf(cerrors.FailedPrecondition, "%s has no primary version", ck.Name) + } + + v, err := m.usableVersion(&ck, id, PurposeEncryptDecrypt) + if err != nil { + return Result{}, err + } + + ct, err := v.seal(plaintext, aad) + + return Result{Name: versionName(ck.Name, 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 (m *Mock) Decrypt(ref *Ref, ct, aad []byte) (Result, error) { + id, ok := ciphertextVersion(ct) + if !ok { + return Result{}, errInvalidCiphertext() + } + + m.mu.Lock() + defer m.mu.Unlock() + + ck, err := m.findKey(ref) + if err != nil { + return Result{}, err + } + + if ck.Purpose == PurposeEncryptDecrypt && !m.versions.Has(versionName(ck.Name, id)) { + return Result{}, errInvalidCiphertext() + } + + v, err := m.usableVersion(&ck, id, PurposeEncryptDecrypt) + if err != nil { + return Result{}, err + } + + pt, err := v.open(ct, aad) + + return Result{ + Name: versionName(ck.Name, id), ProtectionLevel: v.ProtectionLevel, + Out: pt, UsedPrimary: id == ck.PrimaryID, + }, err +} diff --git a/providers/gcp/kms/kms.go b/providers/gcp/kms/kms.go new file mode 100644 index 000000000..fa9fbbd2b --- /dev/null +++ b/providers/gcp/kms/kms.go @@ -0,0 +1,351 @@ +// Package kms is the in-memory Cloud KMS (cloudkms.googleapis.com v1) mock: +// key rings, crypto keys and crypto-key versions, plus each version's real key +// material (AES and HMAC secrets, RSA/EC/Ed25519 private keys as PKCS#8 DER). +// All state lives in memstores and is Snapshottable, so serve --persist keeps +// keys and old ciphertexts stay decryptable across a restart. +package kms + +import ( + "maps" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/memstore" +) + +const ( + // PurposeEncryptDecrypt is the only purpose whose keys carry a primary + // version (cryptoKeys.encrypt uses it). + PurposeEncryptDecrypt = "ENCRYPT_DECRYPT" + + StateEnabled = "ENABLED" + StateDisabled = "DISABLED" + StateDestroyed = "DESTROYED" + StateDestroyScheduled = "DESTROY_SCHEDULED" +) + +// Ref addresses a key ring, crypto key or version by its path ids. +type Ref struct { + Project, Location, KeyRing, CryptoKey, Version string +} + +// RingName is the full key ring resource name. +func RingName(r *Ref) string { + return "projects/" + r.Project + "/locations/" + r.Location + "/keyRings/" + r.KeyRing +} + +// KeyName is the full crypto key resource name. +func KeyName(r *Ref) string { + return RingName(r) + "/cryptoKeys/" + r.CryptoKey +} + +// KeyRing is a Cloud KMS key ring. +type KeyRing struct { + Name string + CreateTime time.Time +} + +// CryptoKey is a Cloud KMS crypto key. Primary is filled on reads from the +// stored primary version and is not persisted. +type CryptoKey struct { + Name string + Purpose string + CreateTime time.Time + NextRotationTime string + RotationPeriod string + ProtectionLevel string + Algorithm string + Labels map[string]string + ImportOnly bool + DestroyScheduledDuration string + CryptoKeyBackend string + PrimaryID string + NextVersion int + Primary *Version `json:"-"` +} + +// Version is a crypto-key version with its key material. Secret is the AES or +// HMAC key; PrivateKey is a PKCS#8 DER private key for asymmetric algorithms. +// Both are generated on first data-plane use. +type Version struct { + ID string + State string + ProtectionLevel string + Algorithm string + CreateTime time.Time + DestroyTime string + DestroyEventTime string + Secret []byte `json:",omitempty"` + PrivateKey []byte `json:",omitempty"` +} + +// KeyConfig is the validated input for creating a crypto key. +type KeyConfig struct { + ID string + Purpose string + RotationPeriod string + NextRotationTime string + ProtectionLevel string + Algorithm string + Labels map[string]string + ImportOnly bool + DestroyScheduledDuration string + CryptoKeyBackend string +} + +// KeyPatch is a crypto key update. Only non-nil fields are applied. +type KeyPatch struct { + Labels *map[string]string + RotationPeriod *string + NextRotationTime *string + ProtectionLevel *string + Algorithm *string +} + +// Mock is the Cloud KMS state. +type Mock struct { + mu sync.Mutex + clock config.Clock + keyRings *memstore.Store[KeyRing] + keys *memstore.Store[CryptoKey] + versions *memstore.Store[Version] +} + +// New returns an empty Cloud KMS mock using opts.Clock (real clock when nil). +func New(opts *config.Options) *Mock { + var clock config.Clock = config.RealClock{} + if opts != nil && opts.Clock != nil { + clock = opts.Clock + } + + return &Mock{ + clock: clock, + keyRings: memstore.New[KeyRing](), + keys: memstore.New[CryptoKey](), + versions: memstore.New[Version](), + } +} + +func notFound(kind, name string) error { + return cerrors.Newf(cerrors.NotFound, "%s %s not found", kind, name) +} + +// CreateKeyRing creates ref's key ring. +func (m *Mock) CreateKeyRing(ref *Ref) (KeyRing, error) { + kr := KeyRing{Name: RingName(ref), CreateTime: m.clock.Now()} + if !m.keyRings.SetIfAbsent(kr.Name, kr) { + return KeyRing{}, cerrors.Newf(cerrors.AlreadyExists, "KeyRing %s already exists", kr.Name) + } + + return kr, nil +} + +// GetKeyRing returns ref's key ring. +func (m *Mock) GetKeyRing(ref *Ref) (KeyRing, error) { + kr, ok := m.keyRings.Get(RingName(ref)) + if !ok { + return KeyRing{}, notFound("KeyRing", ref.KeyRing) + } + + return kr, nil +} + +// ListKeyRings returns the key rings of ref's project and location by name. +func (m *Mock) ListKeyRings(ref *Ref) []KeyRing { + prefix := strings.TrimSuffix(RingName(ref), ref.KeyRing) + + return sortedByName(m.keyRings.Filter(func(k string, _ KeyRing) bool { + return strings.HasPrefix(k, prefix) + })) +} + +func sortedByName[V any](in map[string]V) []V { + names := make([]string, 0, len(in)) + for k := range in { + names = append(names, k) + } + + sort.Strings(names) + + out := make([]V, 0, len(names)) + for _, k := range names { + out = append(out, in[k]) + } + + return out +} + +// withPrimary returns a read copy of ck with Primary filled. Callers hold m.mu. +func (m *Mock) withPrimary(in *CryptoKey) CryptoKey { + ck := *in + ck.Labels = maps.Clone(ck.Labels) + ck.Primary = nil + + if ck.PrimaryID != "" { + if v, ok := m.versions.Get(ck.Name + "/cryptoKeyVersions/" + ck.PrimaryID); ok { + v.Secret, v.PrivateKey = nil, nil + ck.Primary = &v + } + } + + return ck +} + +// CreateCryptoKey creates a crypto key under ref's ring. Unless skipInitial or +// importOnly, version 1 is created ENABLED (and made primary for +// ENCRYPT_DECRYPT), matching real Cloud KMS. +func (m *Mock) CreateCryptoKey(ref *Ref, cfg *KeyConfig, skipInitial bool) (CryptoKey, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.keyRings.Has(RingName(ref)) { + return CryptoKey{}, notFound("KeyRing", ref.KeyRing) + } + + name := RingName(ref) + "/cryptoKeys/" + cfg.ID + + if m.keys.Has(name) { + return CryptoKey{}, cerrors.Newf(cerrors.AlreadyExists, "CryptoKey %s already exists", name) + } + + now := m.clock.Now() + ck := CryptoKey{ + Name: name, Purpose: cfg.Purpose, CreateTime: now, + RotationPeriod: cfg.RotationPeriod, NextRotationTime: cfg.NextRotationTime, + ProtectionLevel: cfg.ProtectionLevel, Algorithm: cfg.Algorithm, + Labels: maps.Clone(cfg.Labels), ImportOnly: cfg.ImportOnly, + DestroyScheduledDuration: cfg.DestroyScheduledDuration, + CryptoKeyBackend: cfg.CryptoKeyBackend, NextVersion: 1, + } + + // nextRotationTime is derived from the rotation period when a rotation is + // configured but the caller left the timestamp unset. + if ck.RotationPeriod != "" && ck.NextRotationTime == "" { + if d, ok := ParseDurationSeconds(ck.RotationPeriod); ok { + ck.NextRotationTime = RFC3339(now.Add(d)) + } + } + + if !skipInitial && !cfg.ImportOnly { + v := m.newVersion(&ck, now, StateEnabled) + if ck.Purpose == PurposeEncryptDecrypt { + ck.PrimaryID = v.ID + } + } + + m.keys.Set(name, ck) + + return m.withPrimary(&ck), nil +} + +func (m *Mock) findKey(ref *Ref) (CryptoKey, error) { + ck, ok := m.keys.Get(KeyName(ref)) + if !ok { + if !m.keyRings.Has(RingName(ref)) { + return CryptoKey{}, notFound("KeyRing", ref.KeyRing) + } + + return CryptoKey{}, notFound("CryptoKey", ref.CryptoKey) + } + + return ck, nil +} + +// GetCryptoKey returns ref's crypto key. +func (m *Mock) GetCryptoKey(ref *Ref) (CryptoKey, error) { + m.mu.Lock() + defer m.mu.Unlock() + + ck, err := m.findKey(ref) + if err != nil { + return CryptoKey{}, err + } + + return m.withPrimary(&ck), nil +} + +// ListCryptoKeys returns the crypto keys of ref's ring by name. +func (m *Mock) ListCryptoKeys(ref *Ref) ([]CryptoKey, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.keyRings.Has(RingName(ref)) { + return nil, notFound("KeyRing", ref.KeyRing) + } + + prefix := RingName(ref) + "/cryptoKeys/" + keys := sortedByName(m.keys.Filter(func(k string, _ CryptoKey) bool { return strings.HasPrefix(k, prefix) })) + + for i := range keys { + keys[i] = m.withPrimary(&keys[i]) + } + + return keys, nil +} + +// UpdateCryptoKey applies p to ref's crypto key. +func (m *Mock) UpdateCryptoKey(ref *Ref, p *KeyPatch) (CryptoKey, error) { + m.mu.Lock() + defer m.mu.Unlock() + + ck, err := m.findKey(ref) + if err != nil { + return CryptoKey{}, err + } + + if p.Labels != nil { + ck.Labels = maps.Clone(*p.Labels) + } + + for dst, src := range map[*string]*string{ + &ck.RotationPeriod: p.RotationPeriod, &ck.NextRotationTime: p.NextRotationTime, + &ck.ProtectionLevel: p.ProtectionLevel, &ck.Algorithm: p.Algorithm, + } { + if src != nil { + *dst = *src + } + } + + m.keys.Set(ck.Name, ck) + + return m.withPrimary(&ck), nil +} + +// newVersion stores a fresh version of ck (advancing ck.NextVersion; the +// caller stores ck). Callers hold m.mu. +func (m *Mock) newVersion(ck *CryptoKey, now time.Time, state string) Version { + v := Version{ + ID: strconv.Itoa(ck.NextVersion), State: state, + ProtectionLevel: ck.ProtectionLevel, Algorithm: ck.Algorithm, CreateTime: now, + } + ck.NextVersion++ + m.versions.Set(ck.Name+"/cryptoKeyVersions/"+v.ID, v) + + return v +} + +// RFC3339 formats t as the UTC timestamp Cloud KMS emits. +func RFC3339(t time.Time) string { + return t.UTC().Format(time.RFC3339Nano) +} + +// ParseDurationSeconds parses a protobuf Duration ("7776000s", "3.5s"). ok is +// false when d is not a valid seconds Duration. +func ParseDurationSeconds(d string) (time.Duration, bool) { + s, ok := strings.CutSuffix(strings.TrimSpace(d), "s") + if !ok || s == "" { + return 0, false + } + + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, false + } + + return time.Duration(f * float64(time.Second)), true +} diff --git a/providers/gcp/kms/snapshot.go b/providers/gcp/kms/snapshot.go new file mode 100644 index 000000000..d5a7c381c --- /dev/null +++ b/providers/gcp/kms/snapshot.go @@ -0,0 +1,79 @@ +package kms + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackshy/cloudemu/v2/internal/snapshot" +) + +var _ snapshot.Snapshottable = (*Mock)(nil) + +// kmsSnapshot is the serialized Cloud KMS state. Versions carry their key +// material (AES/HMAC secrets and PKCS#8 DER private keys), so ciphertexts and +// signatures made before a snapshot still verify after a restore. The clock +// and the mutex are not serialized. +type kmsSnapshot struct { + KeyRings json.RawMessage `json:"keyRings,omitempty"` + Keys json.RawMessage `json:"cryptoKeys,omitempty"` + Versions json.RawMessage `json:"cryptoKeyVersions,omitempty"` +} + +// Snapshot captures every key ring, crypto key and version. includeAssets is +// unused: key material is small and always needed to decrypt. +func (m *Mock) Snapshot(_ context.Context, _ bool) (json.RawMessage, error) { + m.mu.Lock() + defer m.mu.Unlock() + + var snap kmsSnapshot + + for _, d := range []struct { + dst *json.RawMessage + fn func() ([]byte, error) + }{ + {&snap.KeyRings, m.keyRings.Snapshot}, + {&snap.Keys, m.keys.Snapshot}, + {&snap.Versions, m.versions.Snapshot}, + } { + b, err := d.fn() + if err != nil { + return nil, fmt.Errorf("kms: snapshot store: %w", err) + } + + *d.dst = b + } + + return json.Marshal(snap) +} + +// Restore rebuilds the key rings, crypto keys and versions under their +// original names. A section missing from data is left empty. +func (m *Mock) Restore(_ context.Context, data json.RawMessage) error { + var snap kmsSnapshot + if err := json.Unmarshal(data, &snap); err != nil { + return fmt.Errorf("kms: parse snapshot: %w", err) + } + + m.mu.Lock() + defer m.mu.Unlock() + + for _, l := range []struct { + src json.RawMessage + fn func([]byte) error + }{ + {snap.KeyRings, m.keyRings.LoadSnapshot}, + {snap.Keys, m.keys.LoadSnapshot}, + {snap.Versions, m.versions.LoadSnapshot}, + } { + if len(l.src) == 0 { + continue + } + + if err := l.fn(l.src); err != nil { + return fmt.Errorf("kms: restore store: %w", err) + } + } + + return nil +} diff --git a/providers/gcp/kms/snapshot_test.go b/providers/gcp/kms/snapshot_test.go new file mode 100644 index 000000000..18743634a --- /dev/null +++ b/providers/gcp/kms/snapshot_test.go @@ -0,0 +1,99 @@ +package kms + +import ( + "bytes" + "context" + "testing" +) + +func TestSnapshotKeepsKeyMaterial(t *testing.T) { + ctx := context.Background() + src := New(nil) + ring := Ref{Project: "p", Location: "l", KeyRing: "r"} + + if _, err := src.CreateKeyRing(&ring); err != nil { + t.Fatalf("CreateKeyRing: %v", err) + } + + tests := []struct { + id, purpose, alg string + }{ + {id: "rsa", purpose: purposeAsymmetricSign, alg: "RSA_SIGN_PKCS1_2048_SHA256"}, + {id: "ed", purpose: purposeAsymmetricSign, alg: algEd25519}, + {id: "oaep", purpose: purposeAsymmetricDecrypt, alg: "RSA_DECRYPT_OAEP_2048_SHA256"}, + {id: "mac", purpose: purposeMAC, alg: "HMAC_SHA256"}, + {id: "sym", purpose: PurposeEncryptDecrypt, alg: algSymmetric}, + } + + for _, tc := range tests { + if _, err := src.CreateCryptoKey(&ring, &KeyConfig{ID: tc.id, Purpose: tc.purpose, Algorithm: tc.alg}, false); err != nil { + t.Fatalf("CreateCryptoKey %s: %v", tc.id, err) + } + + ref := ring + ref.CryptoKey, ref.Version = tc.id, "1" + + src.mu.Lock() + _, _, err := src.usable(&ref, tc.purpose) + src.mu.Unlock() + + if err != nil { + t.Fatalf("generate material %s: %v", tc.id, err) + } + } + + symRef := &Ref{Project: "p", Location: "l", KeyRing: "r", CryptoKey: "sym"} + + ct, err := src.Encrypt(symRef, []byte("hi"), nil) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + data, err := src.Snapshot(ctx, false) + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + + dst := New(nil) + if err := dst.Restore(ctx, data); err != nil { + t.Fatalf("Restore: %v", err) + } + + for _, tc := range tests { + t.Run(tc.id, func(t *testing.T) { + name := RingName(&ring) + "/cryptoKeys/" + tc.id + "/cryptoKeyVersions/1" + want, _ := src.versions.Get(name) + got, _ := dst.versions.Get(name) + + if len(want.Secret)+len(want.PrivateKey) == 0 || + !bytes.Equal(got.Secret, want.Secret) || !bytes.Equal(got.PrivateKey, want.PrivateKey) { + t.Fatal("key material missing or changed across restore") + } + }) + } + + ref := ring + ref.CryptoKey, ref.Version = "ed", "1" + + if _, err := dst.AsymmetricSign(&ref, nil, []byte("m")); err != nil { + t.Fatalf("sign with restored Ed25519 key: %v", err) + } + + pt, err := dst.Decrypt(symRef, ct.Out, nil) + if err != nil || string(pt.Out) != "hi" { + t.Fatalf("Decrypt after restore = %q, %v", pt.Out, err) + } +} + +// TestRestoreEmptySnapshot covers snapshots written before KMS was persisted: +// a missing or empty section restores to an empty mock without error. +func TestRestoreEmptySnapshot(t *testing.T) { + m := New(nil) + if err := m.Restore(context.Background(), []byte(`{}`)); err != nil { + t.Fatalf("Restore: %v", err) + } + + if got := m.ListKeyRings(&Ref{Project: "p", Location: "l"}); len(got) != 0 { + t.Fatalf("rings = %v, want none", got) + } +} diff --git a/providers/gcp/kms/versions.go b/providers/gcp/kms/versions.go new file mode 100644 index 000000000..82c772195 --- /dev/null +++ b/providers/gcp/kms/versions.go @@ -0,0 +1,203 @@ +package kms + +import ( + "sort" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +func versionName(keyName, id string) string { + return keyName + "/cryptoKeyVersions/" + id +} + +// readCopy strips key material from a version handed to the wire layer. +func readCopy(in *Version) Version { + v := *in + v.Secret, v.PrivateKey = nil, nil + + return v +} + +func (m *Mock) findVersion(ref *Ref) (CryptoKey, Version, error) { + ck, err := m.findKey(ref) + if err != nil { + return CryptoKey{}, Version{}, err + } + + v, ok := m.versions.Get(versionName(ck.Name, ref.Version)) + if !ok { + return CryptoKey{}, Version{}, notFound("CryptoKeyVersion", ref.Version) + } + + return ck, v, nil +} + +// UpdateCryptoKeyPrimaryVersion makes versionID the primary of an ENCRYPT_DECRYPT key. +func (m *Mock) UpdateCryptoKeyPrimaryVersion(ref *Ref, versionID string) (CryptoKey, error) { + m.mu.Lock() + defer m.mu.Unlock() + + ck, err := m.findKey(ref) + if err != nil { + return CryptoKey{}, err + } + + if ck.Purpose != PurposeEncryptDecrypt { + return CryptoKey{}, cerrors.New(cerrors.InvalidArgument, + "UpdateCryptoKeyPrimaryVersion is only valid for keys with purpose ENCRYPT_DECRYPT") + } + + v, ok := m.versions.Get(versionName(ck.Name, versionID)) + if !ok { + return CryptoKey{}, notFound("CryptoKeyVersion", versionID) + } + + if v.State != StateEnabled { + return CryptoKey{}, cerrors.New(cerrors.FailedPrecondition, "the primary version must be ENABLED") + } + + ck.PrimaryID = versionID + m.keys.Set(ck.Name, ck) + + return m.withPrimary(&ck), nil +} + +// CreateCryptoKeyVersion adds a version to ref's key in state (ENABLED when empty). +func (m *Mock) CreateCryptoKeyVersion(ref *Ref, state string) (keyName string, v Version, err error) { + m.mu.Lock() + defer m.mu.Unlock() + + ck, err := m.findKey(ref) + if err != nil { + return "", Version{}, err + } + + if state == "" { + state = StateEnabled + } + + v = m.newVersion(&ck, m.clock.Now(), state) + m.keys.Set(ck.Name, ck) + + return ck.Name, v, nil +} + +// GetCryptoKeyVersion returns ref's version. +func (m *Mock) GetCryptoKeyVersion(ref *Ref) (keyName string, v Version, err error) { + m.mu.Lock() + defer m.mu.Unlock() + + ck, v, err := m.findVersion(ref) + + return ck.Name, readCopy(&v), err +} + +// ListCryptoKeyVersions returns ref's key versions newest first, matching real Cloud KMS. +func (m *Mock) ListCryptoKeyVersions(ref *Ref) (keyName string, out []Version, err error) { + m.mu.Lock() + defer m.mu.Unlock() + + ck, err := m.findKey(ref) + if err != nil { + return "", nil, err + } + + prefix := ck.Name + "/cryptoKeyVersions/" + all := m.versions.Filter(func(k string, _ Version) bool { return strings.HasPrefix(k, prefix) }) + + out = make([]Version, 0, len(all)) + + for k := range all { + v := all[k] + out = append(out, readCopy(&v)) + } + + sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) + + return ck.Name, out, nil +} + +// UpdateCryptoKeyVersion moves a version between ENABLED and DISABLED; empty state is a +// no-op. +func (m *Mock) UpdateCryptoKeyVersion(ref *Ref, state string) (keyName string, v Version, err error) { + m.mu.Lock() + defer m.mu.Unlock() + + ck, v, err := m.findVersion(ref) + if err != nil { + return "", Version{}, err + } + + switch state { + case StateEnabled, StateDisabled: + if v.State != StateEnabled && v.State != StateDisabled { + return "", Version{}, cerrors.Newf(cerrors.FailedPrecondition, + "cannot move version from %s to %s", v.State, state) + } + + v.State = state + case "": + default: + return "", Version{}, cerrors.Newf(cerrors.InvalidArgument, "state %s is not user-settable", state) + } + + m.versions.Set(versionName(ck.Name, v.ID), v) + + return ck.Name, readCopy(&v), nil +} + +// DestroyCryptoKeyVersion schedules destruction after the key's +// destroyScheduledDuration; a destroyed primary leaves the key without one. +func (m *Mock) DestroyCryptoKeyVersion(ref *Ref) (keyName string, v Version, err error) { + m.mu.Lock() + defer m.mu.Unlock() + + ck, v, err := m.findVersion(ref) + if err != nil { + return "", Version{}, err + } + + if v.State != StateEnabled && v.State != StateDisabled { + return "", Version{}, cerrors.Newf(cerrors.FailedPrecondition, + "CryptoKeyVersion in state %s cannot be destroyed", v.State) + } + + now := m.clock.Now() + v.State = StateDestroyScheduled + v.DestroyTime = RFC3339(now) + + if d, ok := ParseDurationSeconds(ck.DestroyScheduledDuration); ok { + v.DestroyTime = RFC3339(now.Add(d)) + } + + m.versions.Set(versionName(ck.Name, v.ID), v) + + if ck.PrimaryID == v.ID { + ck.PrimaryID = "" + m.keys.Set(ck.Name, ck) + } + + return ck.Name, readCopy(&v), nil +} + +// RestoreCryptoKeyVersion returns a DESTROY_SCHEDULED version to DISABLED. +func (m *Mock) RestoreCryptoKeyVersion(ref *Ref) (keyName string, v Version, err error) { + m.mu.Lock() + defer m.mu.Unlock() + + ck, v, err := m.findVersion(ref) + if err != nil { + return "", Version{}, err + } + + if v.State != StateDestroyScheduled { + return "", Version{}, cerrors.Newf(cerrors.FailedPrecondition, + "only a DESTROY_SCHEDULED version can be restored, not one in state %s", v.State) + } + + v.State, v.DestroyTime = StateDisabled, "" + m.versions.Set(versionName(ck.Name, v.ID), v) + + return ck.Name, readCopy(&v), nil +} diff --git a/server/gcp/from_provider.go b/server/gcp/from_provider.go index 5aaf4bee1..81244769a 100644 --- a/server/gcp/from_provider.go +++ b/server/gcp/from_provider.go @@ -28,6 +28,7 @@ func DriversFrom(p *gcpprovider.Provider) Drivers { CertificateManager: p.CertificateManager, AccessContextManager: p.AccessContextManager, PrivateCA: p.PrivateCA, + KMS: p.KMS, GKEBackup: p.GKEBackup, GKEHub: p.GKEHub, DataFusion: p.DataFusion, diff --git a/server/gcp/gcp.go b/server/gcp/gcp.go index 89fdede71..abb2ec5cc 100644 --- a/server/gcp/gcp.go +++ b/server/gcp/gcp.go @@ -11,6 +11,7 @@ import ( "github.com/stackshy/cloudemu/v2/config" gkeprov "github.com/stackshy/cloudemu/v2/providers/gcp/gke" + kmsprov "github.com/stackshy/cloudemu/v2/providers/gcp/kms" gcpmon "github.com/stackshy/cloudemu/v2/providers/gcp/monitoring" "github.com/stackshy/cloudemu/v2/providers/gcp/resourceiam" "github.com/stackshy/cloudemu/v2/server" @@ -199,6 +200,10 @@ type Drivers struct { // and never collide with certificatemanager's location-level certificates), and // its location-scoped operation polls are owned by the shared LRO poller. PrivateCA privatecadriver.PrivateCA + // KMS holds Cloud KMS key rings, crypto keys and versions with their key + // material. Nil gets a fresh in-memory mock, so the KMS handler is always + // registered; the provider-backed one is what serve --persist snapshots. + KMS *kmsprov.Mock // GKEBackup serves the gkebackup.googleapis.com v1 backup-plan and // restore-plan control plane against the gkebackup driver. Its paths live // under /v1/projects/{p}/locations/{l}/{backupPlans|restorePlans}[/…]; the @@ -1120,7 +1125,12 @@ func New(d Drivers) *server.Server { // handler is always registered; it must precede Firestore's permissive // /v1/projects/ prefix. d.Clock (may be nil) makes create/destroy timestamps // deterministic under a FakeClock. - srv.Register(kmssrv.New(d.Clock)) + kmsMock := d.KMS + if kmsMock == nil { + kmsMock = kmsprov.New(&config.Options{Clock: d.Clock}) + } + + srv.Register(kmssrv.New(kmsMock, iamStore)) if d.Firestore != nil { // The Firestore Admin API (projects.databases[.collectionGroups.indexes]) diff --git a/server/gcp/kms/dataplane.go b/server/gcp/kms/dataplane.go index 804c1d4ce..48ddba67d 100644 --- a/server/gcp/kms/dataplane.go +++ b/server/gcp/kms/dataplane.go @@ -1,13 +1,10 @@ package kms import ( - "crypto" - "crypto/hmac" - "crypto/rand" "net/http" - "strings" cerrors "github.com/stackshy/cloudemu/v2/errors" + kmsprov "github.com/stackshy/cloudemu/v2/providers/gcp/kms" "github.com/stackshy/cloudemu/v2/server/wire/gcprest" ) @@ -21,14 +18,8 @@ const ( 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 { @@ -74,15 +65,15 @@ func (h *Handler) doEncrypt(rt *route, req *encryptRequest) (*encryptResponse, e return nil, err } - res, err := h.store.encrypt(rt, req.Plaintext, req.AdditionalAuthenticatedData) + res, err := h.kms.Encrypt(rt.ref(), req.Plaintext, req.AdditionalAuthenticatedData) if err != nil { return nil, err } return &encryptResponse{ - Name: res.name, Ciphertext: res.out, CiphertextCrc32c: crcOf(res.out), + Name: res.Name, Ciphertext: res.Out, CiphertextCrc32c: crcOf(res.Out), VerifiedPlaintextCrc32c: ptOK, VerifiedAdditionalAuthenticatedDataCrc32c: aadOK, - ProtectionLevel: res.protectionLevel, + ProtectionLevel: res.ProtectionLevel, }, nil } @@ -103,54 +94,32 @@ func (h *Handler) decrypt(w http.ResponseWriter, r *http.Request, rt *route) { return } - res, err := h.store.decrypt(rt, req.Ciphertext, req.AdditionalAuthenticatedData) + res, err := h.kms.Decrypt(rt.ref(), 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, + 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 +// suppliedDigest returns whichever digest field the request set, the bytes +// digestCrc32c covers. +func suppliedDigest(d *digestJSON) []byte { + if d == nil { + return 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") + for _, b := range [][]byte{d.Sha256, d.Sha384, d.Sha512} { + if len(b) > 0 { + return b } - - 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 + return nil } func (h *Handler) asymmetricSign(w http.ResponseWriter, r *http.Request, rt *route) { @@ -159,37 +128,34 @@ func (h *Handler) asymmetricSign(w http.ResponseWriter, r *http.Request, rt *rou 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 - } + var ( + resp asymmetricSignResponse + err error + ) - if resp.VerifiedDataCrc32c, err = checkCRC("data_crc32c", "data", req.Data, req.DataCrc32c); err != nil { - return err - } + resp.VerifiedDigestCrc32c, err = checkCRC("digest_crc32c", "digest", suppliedDigest(req.Digest), req.DigestCrc32c) + if err == nil { + resp.VerifiedDataCrc32c, err = checkCRC("data_crc32c", "data", req.Data, req.DataCrc32c) + } - sig, err := v.sign(digest, req.Data) - if err != nil { - return cerrors.Newf(cerrors.InvalidArgument, "sign: %v", err) - } + var digest *kmsprov.Digest + if req.Digest != nil { + digest = &kmsprov.Digest{Sha256: req.Digest.Sha256, Sha384: req.Digest.Sha384, Sha512: req.Digest.Sha512} + } - resp.Signature, resp.SignatureCrc32c = sig, crcOf(sig) - resp.Name, resp.ProtectionLevel = name, v.protectionLevel + var res kmsprov.Result + if err == nil { + res, err = h.kms.AsymmetricSign(rt.ref(), digest, req.Data) + } - return nil - }, purposeAsymmetricSign) if err != nil { writeKMSErr(w, err) return } + resp.Signature, resp.SignatureCrc32c = res.Out, crcOf(res.Out) + resp.Name, resp.ProtectionLevel = res.Name, res.ProtectionLevel + gcprest.WriteJSON(w, http.StatusOK, resp) } @@ -199,57 +165,37 @@ func (h *Handler) asymmetricDecrypt(w http.ResponseWriter, r *http.Request, rt * 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 - } + verified, err := checkCRC("ciphertext_crc32c", "ciphertext", req.Ciphertext, req.CiphertextCrc32c) - resp.Plaintext, resp.PlaintextCrc32c, resp.ProtectionLevel = pt, crcOf(pt), v.protectionLevel + var res kmsprov.Result + if err == nil { + res, err = h.kms.AsymmetricDecrypt(rt.ref(), req.Ciphertext) + } - return nil - }, purposeAsymmetricDecrypt) if err != nil { writeKMSErr(w, err) return } - gcprest.WriteJSON(w, http.StatusOK, resp) + gcprest.WriteJSON(w, http.StatusOK, asymmetricDecryptResponse{ + Plaintext: res.Out, PlaintextCrc32c: crcOf(res.Out), + VerifiedCiphertextCrc32c: verified, ProtectionLevel: res.ProtectionLevel, + }) } // 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 { + pk, err := h.kms.GetPublicKey(rt.ref()) + if err != nil { writeKMSErr(w, err) return } - gcprest.WriteJSON(w, http.StatusOK, resp) + gcprest.WriteJSON(w, http.StatusOK, publicKeyResponse{ + Pem: pk.Pem, Algorithm: pk.Algorithm, PemCrc32c: crcOf([]byte(pk.Pem)), + Name: pk.Name, ProtectionLevel: pk.ProtectionLevel, PublicKeyFormat: "PEM", + }) } // macOp serves macSign and macVerify with HMAC under the version's key. @@ -259,62 +205,59 @@ func (h *Handler) macOp(w http.ResponseWriter, r *http.Request, rt *route) { return } - var resp any + dataOK, err := checkCRC("data_crc32c", "data", req.Data, req.DataCrc32c) + if err != nil { + writeKMSErr(w, err) + return + } - 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 + if rt.verb == verbMacSign { + res, signErr := h.kms.MacSign(rt.ref(), req.Data) + if signErr != nil { + writeKMSErr(w, signErr) + return } - mac := hmac.New(hashFor(v.algorithm).New, v.secret) - mac.Write(req.Data) - sum := mac.Sum(nil) + gcprest.WriteJSON(w, http.StatusOK, macSignResponse{ + Name: res.Name, Mac: res.Out, MacCrc32c: crcOf(res.Out), + VerifiedDataCrc32c: dataOK, ProtectionLevel: res.ProtectionLevel, + }) - if rt.verb == verbMacSign { - resp = macSignResponse{ - Name: name, Mac: sum, MacCrc32c: crcOf(sum), - VerifiedDataCrc32c: dataOK, ProtectionLevel: v.protectionLevel, - } + return + } - return nil - } + macOK, err := checkCRC("mac_crc32c", "mac", req.Mac, req.MacCrc32c) - macOK, err := checkCRC("mac_crc32c", "mac", req.Mac, req.MacCrc32c) - if err != nil { - return err - } + var ( + res kmsprov.Result + success bool + ) - resp = macVerifyResponse{ - Name: name, Success: hmac.Equal(sum, req.Mac), VerifiedDataCrc32c: dataOK, - VerifiedMacCrc32c: macOK, VerifiedSuccessIntegrity: true, ProtectionLevel: v.protectionLevel, - } + if err == nil { + res, success, err = h.kms.MacVerify(rt.ref(), req.Data, req.Mac) + } - return nil - }, purposeMAC) if err != nil { writeKMSErr(w, err) return } - gcprest.WriteJSON(w, http.StatusOK, resp) + gcprest.WriteJSON(w, http.StatusOK, macVerifyResponse{ + Name: res.Name, Success: success, VerifiedDataCrc32c: dataOK, + VerifiedMacCrc32c: macOK, VerifiedSuccessIntegrity: true, ProtectionLevel: res.ProtectionLevel, + }) } // generateRandomBytes serves POST /v1/projects/{p}/locations/{l}:generateRandomBytes. -func (*Handler) generateRandomBytes(w http.ResponseWriter, r *http.Request) { +func (h *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)) + b, err := h.kms.GenerateRandomBytes(req.LengthBytes) + if err != nil { + writeKMSErr(w, err) return } diff --git a/server/gcp/kms/dataplane_store.go b/server/gcp/kms/dataplane_store.go deleted file mode 100644 index d08a50c95..000000000 --- a/server/gcp/kms/dataplane_store.go +++ /dev/null @@ -1,128 +0,0 @@ -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/handler.go b/server/gcp/kms/handler.go index 5f9a25fd0..82f566dcb 100644 --- a/server/gcp/kms/handler.go +++ b/server/gcp/kms/handler.go @@ -49,7 +49,8 @@ import ( "net/http" "strings" - "github.com/stackshy/cloudemu/v2/config" + kmsprov "github.com/stackshy/cloudemu/v2/providers/gcp/kms" + "github.com/stackshy/cloudemu/v2/server/wire/gcpiam" "github.com/stackshy/cloudemu/v2/server/wire/gcprest" ) @@ -91,15 +92,25 @@ type route struct { kind routeKind } -// Handler serves cloudkms.googleapis.com v1 control-plane requests. +// Handler serves cloudkms.googleapis.com v1 requests against the persisted +// Cloud KMS provider mock. Key ring and crypto key IAM policies live in the +// shared GCP resource-policy store. type Handler struct { - store *store + kms *kmsprov.Mock + iam gcpiam.Store } -// New returns a Cloud KMS handler. clock stamps createTime/destroyTime; pass a -// config.FakeClock for deterministic tests, or nil for the real clock. -func New(clock config.Clock) *Handler { - return &Handler{store: newStore(clock)} +// New returns a Cloud KMS handler over m, keeping IAM policies in iam. +func New(m *kmsprov.Mock, iam gcpiam.Store) *Handler { + return &Handler{kms: m, iam: iam} +} + +// ref converts the parsed path to the provider's resource reference. +func (rt *route) ref() *kmsprov.Ref { + return &kmsprov.Ref{ + Project: rt.project, Location: rt.location, + KeyRing: rt.keyRing, CryptoKey: rt.cryptoKey, Version: rt.version, + } } // Path-tail depths after the [projects, {p}, locations, {l}, keyRings] head: @@ -355,19 +366,29 @@ func (h *Handler) serveVersionNoVerb(w http.ResponseWriter, r *http.Request, rt } } -// serveIamVerb dispatches the IAM policy custom methods shared by keyRings and -// cryptoKeys. +// serveIamVerb serves the IAM policy custom methods shared by keyRings and +// cryptoKeys once the addressed resource is known to exist. func (h *Handler) serveIamVerb(w http.ResponseWriter, r *http.Request, rt *route) { - switch rt.verb { - case verbGetIam: - getOnly(w, r, func() { h.getIamPolicy(w, rt) }) - case verbSetIam: - postOnly(w, r, func() { h.setIamPolicy(w, r, rt) }) - case verbTestIam: - postOnly(w, r, func() { h.testIamPermissions(w, r) }) - default: + if rt.verb != verbGetIam && rt.verb != verbSetIam && rt.verb != verbTestIam { writeUnsupported(w) + return } + + ref := rt.ref() + name := kmsprov.RingName(ref) + _, err := h.kms.GetKeyRing(ref) + + if rt.kind == kindCryptoKey { + name = kmsprov.KeyName(ref) + _, err = h.kms.GetCryptoKey(ref) + } + + if err != nil { + gcprest.WriteCErr(w, err) + return + } + + gcpiam.Serve(w, r, rt.verb, name, h.iam) } func writeUnsupported(w http.ResponseWriter) { diff --git a/server/gcp/kms/iam.go b/server/gcp/kms/iam.go deleted file mode 100644 index 46a651749..000000000 --- a/server/gcp/kms/iam.go +++ /dev/null @@ -1,81 +0,0 @@ -package kms - -import "github.com/stackshy/cloudemu/v2/providers/gcp/resourceiam" - -// Cloud KMS keyRings and cryptoKeys each carry an IAM policy. cloudemu does not -// enforce IAM; it stores the policy verbatim so a getIamPolicy → modify → -// setIamPolicy round-trips (what google_kms_*_iam_* Terraform resources rely -// on). Policies are keyed on the owning resource, not a separate store. Etags -// follow the shared resourceiam scheme so they match every other GCP policy. - -// iamState holds a resource's IAM policy, nil until the first set. -type iamState struct { - policy *iamPolicyJSON -} - -// iamHolder resolves the IAM state for the resource a route addresses (a -// keyRing or a cryptoKey). Callers hold s.mu. -func (s *store) iamHolder(rt *route) (*iamState, error) { - if rt.kind == kindCryptoKey { - ck, err := s.findCryptoKey(rt) - if err != nil { - return nil, err - } - - return &ck.iam, nil - } - - kr, err := s.findKeyRing(rt) - if err != nil { - return nil, err - } - - return &kr.iam, nil -} - -func (s *store) getIAMPolicy(rt *route) (iamPolicyJSON, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - h, err := s.iamHolder(rt) - if err != nil { - return iamPolicyJSON{}, err - } - - if h.policy == nil { - return iamPolicyJSON{Version: 1, Etag: resourceiam.InitialEtag()}, nil - } - - return *h.policy, nil -} - -// setIAMPolicy stores pol. An empty etag is a blind overwrite; an etag that no -// longer matches the stored policy is resourceiam.ErrAborted (real 409 -// ABORTED). -func (s *store) setIAMPolicy(rt *route, pol iamPolicyJSON) (iamPolicyJSON, error) { - s.mu.Lock() - defer s.mu.Unlock() - - h, err := s.iamHolder(rt) - if err != nil { - return iamPolicyJSON{}, err - } - - cur := resourceiam.InitialEtag() - if h.policy != nil { - cur = h.policy.Etag - } - - if pol.Etag != "" && pol.Etag != cur { - return iamPolicyJSON{}, resourceiam.ErrAborted - } - - if pol.Version == 0 { - pol.Version = 1 - } - - pol.Etag = resourceiam.NextEtag(cur) - h.policy = &pol - - return pol, nil -} diff --git a/server/gcp/kms/model_json.go b/server/gcp/kms/model_json.go index dca8f0e0f..507b22c5d 100644 --- a/server/gcp/kms/model_json.go +++ b/server/gcp/kms/model_json.go @@ -1,89 +1,65 @@ package kms -// cryptoKeyConfig is the validated, normalized input for creating a CryptoKey. -type cryptoKeyConfig struct { - id string - purpose string - rotationPeriod string - nextRotationTime string - protectionLevel string - algorithm string - labels map[string]string - importOnly bool - destroyScheduledDuration string - cryptoKeyBackend string -} +import ( + kmsprov "github.com/stackshy/cloudemu/v2/providers/gcp/kms" +) -// cryptoKeyPatch is a normalized CryptoKey patch: each set* flag marks a field -// the update mask named, so an omitted field is left untouched. -type cryptoKeyPatch struct { - labels map[string]string - setLabels bool - rotationPeriod string - setRotationPeriod bool - nextRotationTime string - setNextRotationTime bool - protectionLevel string - setProtectionLevel bool - algorithm string - setAlgorithm bool -} +// statePendingGeneration is the one version state with no key material yet. +const statePendingGeneration = "PENDING_GENERATION" -func toKeyRingJSON(kr *keyRingModel) keyRingJSON { - return keyRingJSON{Name: kr.name, CreateTime: rfc3339(kr.createTime)} +func toKeyRingJSON(kr *kmsprov.KeyRing) keyRingJSON { + return keyRingJSON{Name: kr.Name, CreateTime: kmsprov.RFC3339(kr.CreateTime)} } -func toCryptoKeyJSON(ck *cryptoKeyModel) cryptoKeyJSON { +func toCryptoKeyJSON(ck *kmsprov.CryptoKey) cryptoKeyJSON { out := cryptoKeyJSON{ - Name: ck.name, - Purpose: ck.purpose, - CreateTime: rfc3339(ck.createTime), - NextRotationTime: ck.nextRotationTime, - RotationPeriod: ck.rotationPeriod, - Labels: ck.labels, - ImportOnly: ck.importOnly, - DestroyScheduledDuration: ck.destroyScheduledDuration, - CryptoKeyBackend: ck.cryptoKeyBackend, + Name: ck.Name, + Purpose: ck.Purpose, + CreateTime: kmsprov.RFC3339(ck.CreateTime), + NextRotationTime: ck.NextRotationTime, + RotationPeriod: ck.RotationPeriod, + Labels: ck.Labels, + ImportOnly: ck.ImportOnly, + DestroyScheduledDuration: ck.DestroyScheduledDuration, + CryptoKeyBackend: ck.CryptoKeyBackend, } - if ck.algorithm != "" || ck.protectionLevel != "" { + if ck.Algorithm != "" || ck.ProtectionLevel != "" { out.VersionTemplate = &versionTemplateOut{ - ProtectionLevel: ck.protectionLevel, - Algorithm: ck.algorithm, + ProtectionLevel: ck.ProtectionLevel, + Algorithm: ck.Algorithm, } } - if ck.primaryID != "" { - if v, ok := ck.versions[ck.primaryID]; ok { - p := toVersionJSON(ck.name, v) - out.Primary = &p - } + if ck.Primary != nil { + p := toVersionJSON(ck.Name, ck.Primary) + out.Primary = &p } return out } -func toVersionJSON(parentKeyName string, v *versionModel) versionJSON { +func toVersionJSON(parentKeyName string, v *kmsprov.Version) versionJSON { out := versionJSON{ - Name: parentKeyName + "/cryptoKeyVersions/" + v.id, - State: v.state, - ProtectionLevel: v.protectionLevel, - Algorithm: v.algorithm, - CreateTime: rfc3339(v.createTime), + Name: parentKeyName + "/cryptoKeyVersions/" + v.ID, + State: v.State, + ProtectionLevel: v.ProtectionLevel, + Algorithm: v.Algorithm, + CreateTime: kmsprov.RFC3339(v.CreateTime), } // generateTime is present once key material exists; the emulator generates // synchronously, so it equals createTime for every non-pending version. - if v.state != "" && v.state != "PENDING_GENERATION" { - out.GenerateTime = rfc3339(v.createTime) + if v.State != "" && v.State != statePendingGeneration { + out.GenerateTime = out.CreateTime } - if v.state == stateDestroyScheduled { - out.DestroyTime = v.destroyTime + if v.State == kmsprov.StateDestroyScheduled { + out.DestroyTime = v.DestroyTime } - if v.state == stateDestroyed { - out.DestroyEventTime = v.destroyEventTime + if v.State == kmsprov.StateDestroyed { + out.DestroyEventTime = v.DestroyEventTime } return out diff --git a/server/gcp/kms/operations.go b/server/gcp/kms/operations.go index 4d8073151..c454129ed 100644 --- a/server/gcp/kms/operations.go +++ b/server/gcp/kms/operations.go @@ -5,7 +5,7 @@ import ( "strings" cerrors "github.com/stackshy/cloudemu/v2/errors" - "github.com/stackshy/cloudemu/v2/server/wire/gcpiam" + kmsprov "github.com/stackshy/cloudemu/v2/providers/gcp/kms" "github.com/stackshy/cloudemu/v2/server/wire/gcprest" ) @@ -16,6 +16,12 @@ const ( // algorithmSymmetric is the default versionTemplate.algorithm real Cloud KMS // assigns a symmetric ENCRYPT_DECRYPT key when the caller omits it. algorithmSymmetric = "GOOGLE_SYMMETRIC_ENCRYPTION" + purposeUnspecified = "CRYPTO_KEY_PURPOSE_UNSPECIFIED" + // defaultDestroyScheduledDuration is the DESTROY_SCHEDULED dwell time a + // CryptoKey carries when create omits destroyScheduledDuration (24h). + defaultDestroyScheduledDuration = "86400s" + // defaultProtectionLevel is applied when a versionTemplate omits it. + defaultProtectionLevel = "SOFTWARE" ) // writeKMSErr maps a canonical error to Cloud KMS's HTTP response. An illegal @@ -65,27 +71,34 @@ func (h *Handler) createKeyRing(w http.ResponseWriter, r *http.Request, rt *rout rt.keyRing = id - kr, err := h.store.createKeyRing(rt) + kr, err := h.kms.CreateKeyRing(rt.ref()) if err != nil { gcprest.WriteCErr(w, err) return } - gcprest.WriteJSON(w, http.StatusOK, kr) + gcprest.WriteJSON(w, http.StatusOK, toKeyRingJSON(&kr)) } func (h *Handler) getKeyRing(w http.ResponseWriter, rt *route) { - kr, err := h.store.getKeyRing(rt) + kr, err := h.kms.GetKeyRing(rt.ref()) if err != nil { gcprest.WriteCErr(w, err) return } - gcprest.WriteJSON(w, http.StatusOK, kr) + gcprest.WriteJSON(w, http.StatusOK, toKeyRingJSON(&kr)) } func (h *Handler) listKeyRings(w http.ResponseWriter, rt *route) { - gcprest.WriteJSON(w, http.StatusOK, h.store.listKeyRings(rt)) + rings := h.kms.ListKeyRings(rt.ref()) + + out := make([]keyRingJSON, 0, len(rings)) + for i := range rings { + out = append(out, toKeyRingJSON(&rings[i])) + } + + gcprest.WriteJSON(w, http.StatusOK, listKeyRingsResponse{KeyRings: out, TotalSize: len(out)}) } // --- crypto keys --- @@ -109,55 +122,55 @@ func (h *Handler) createCryptoKey(w http.ResponseWriter, r *http.Request, rt *ro skip := r.URL.Query().Get("skipInitialVersionCreation") == trueValue - ck, err := h.store.createCryptoKey(rt, &cfg, skip) + ck, err := h.kms.CreateCryptoKey(rt.ref(), &cfg, skip) if err != nil { gcprest.WriteCErr(w, err) return } - gcprest.WriteJSON(w, http.StatusOK, ck) + gcprest.WriteJSON(w, http.StatusOK, toCryptoKeyJSON(&ck)) } // buildCryptoKeyConfig validates and normalizes a create body. On any // validation failure it writes the error and returns ok=false. -func buildCryptoKeyConfig(w http.ResponseWriter, id string, req *createCryptoKeyRequest) (cryptoKeyConfig, bool) { +func buildCryptoKeyConfig(w http.ResponseWriter, id string, req *createCryptoKeyRequest) (kmsprov.KeyConfig, bool) { purpose, ok, present := req.Purpose.normalize(purposeNames) if !present || !ok || purpose == purposeUnspecified { invalidArg(w, "purpose is required and must be a valid CryptoKeyPurpose") - return cryptoKeyConfig{}, false + return kmsprov.KeyConfig{}, false } algo, prot, ok := normalizeVersionTemplate(w, req.VersionTemplate, purpose) if !ok { - return cryptoKeyConfig{}, false + return kmsprov.KeyConfig{}, false } dsd := req.DestroyScheduledDuration if dsd == "" { dsd = defaultDestroyScheduledDuration - } else if _, ok := parseDurationSeconds(dsd); !ok { + } else if _, ok := kmsprov.ParseDurationSeconds(dsd); !ok { invalidArg(w, "invalid destroyScheduledDuration") - return cryptoKeyConfig{}, false + return kmsprov.KeyConfig{}, false } if req.RotationPeriod != "" { - if _, ok := parseDurationSeconds(req.RotationPeriod); !ok { + if _, ok := kmsprov.ParseDurationSeconds(req.RotationPeriod); !ok { invalidArg(w, "invalid rotationPeriod") - return cryptoKeyConfig{}, false + return kmsprov.KeyConfig{}, false } } - return cryptoKeyConfig{ - id: id, - purpose: purpose, - rotationPeriod: req.RotationPeriod, - nextRotationTime: req.NextRotationTime, - protectionLevel: prot, - algorithm: algo, - labels: req.Labels, - importOnly: req.ImportOnly, - destroyScheduledDuration: dsd, - cryptoKeyBackend: req.CryptoKeyBackend, + return kmsprov.KeyConfig{ + ID: id, + Purpose: purpose, + RotationPeriod: req.RotationPeriod, + NextRotationTime: req.NextRotationTime, + ProtectionLevel: prot, + Algorithm: algo, + Labels: req.Labels, + ImportOnly: req.ImportOnly, + DestroyScheduledDuration: dsd, + CryptoKeyBackend: req.CryptoKeyBackend, }, true } @@ -184,7 +197,7 @@ func normalizeVersionTemplate(w http.ResponseWriter, vt *versionTemplateJSON, pu if !present || algo == algorithmUnspecified { // Symmetric ENCRYPT_DECRYPT keys default the algorithm; every other purpose // still requires the caller to name one. - if purpose == purposeEncryptDecrypt { + if purpose == kmsprov.PurposeEncryptDecrypt { return algorithmSymmetric, prot, true } @@ -224,23 +237,28 @@ func resolveProtectionLevel(w http.ResponseWriter, vt *versionTemplateJSON) (str } func (h *Handler) getCryptoKey(w http.ResponseWriter, rt *route) { - ck, err := h.store.getCryptoKey(rt) + ck, err := h.kms.GetCryptoKey(rt.ref()) if err != nil { gcprest.WriteCErr(w, err) return } - gcprest.WriteJSON(w, http.StatusOK, ck) + gcprest.WriteJSON(w, http.StatusOK, toCryptoKeyJSON(&ck)) } func (h *Handler) listCryptoKeys(w http.ResponseWriter, rt *route) { - resp, err := h.store.listCryptoKeys(rt) + keys, err := h.kms.ListCryptoKeys(rt.ref()) if err != nil { gcprest.WriteCErr(w, err) return } - gcprest.WriteJSON(w, http.StatusOK, resp) + out := make([]cryptoKeyJSON, 0, len(keys)) + for i := range keys { + out = append(out, toCryptoKeyJSON(&keys[i])) + } + + gcprest.WriteJSON(w, http.StatusOK, listCryptoKeysResponse{CryptoKeys: out, TotalSize: len(out)}) } func (h *Handler) patchCryptoKey(w http.ResponseWriter, r *http.Request, rt *route) { @@ -254,41 +272,41 @@ func (h *Handler) patchCryptoKey(w http.ResponseWriter, r *http.Request, rt *rou return } - ck, err := h.store.patchCryptoKey(rt, &patch) + ck, err := h.kms.UpdateCryptoKey(rt.ref(), &patch) if err != nil { gcprest.WriteCErr(w, err) return } - gcprest.WriteJSON(w, http.StatusOK, ck) + gcprest.WriteJSON(w, http.StatusOK, toCryptoKeyJSON(&ck)) } -// buildCryptoKeyPatch turns a masked patch body into a normalized cryptoKeyPatch. -func buildCryptoKeyPatch(w http.ResponseWriter, mask string, req *createCryptoKeyRequest) (cryptoKeyPatch, bool) { - patch := cryptoKeyPatch{} +// buildCryptoKeyPatch turns a masked patch body into a KeyPatch. +func buildCryptoKeyPatch(w http.ResponseWriter, mask string, req *createCryptoKeyRequest) (kmsprov.KeyPatch, bool) { + patch := kmsprov.KeyPatch{} if maskHas(mask, "labels") { - patch.labels, patch.setLabels = req.Labels, true + patch.Labels = &req.Labels } if maskHas(mask, "rotationPeriod", "rotation_period") { - patch.rotationPeriod, patch.setRotationPeriod = req.RotationPeriod, true + patch.RotationPeriod = &req.RotationPeriod } if maskHas(mask, "nextRotationTime", "next_rotation_time") { - patch.nextRotationTime, patch.setNextRotationTime = req.NextRotationTime, true + patch.NextRotationTime = &req.NextRotationTime } if req.VersionTemplate != nil { if !applyVersionTemplatePatch(w, mask, req.VersionTemplate, &patch) { - return cryptoKeyPatch{}, false + return kmsprov.KeyPatch{}, false } } return patch, true } -func applyVersionTemplatePatch(w http.ResponseWriter, mask string, vt *versionTemplateJSON, patch *cryptoKeyPatch) bool { +func applyVersionTemplatePatch(w http.ResponseWriter, mask string, vt *versionTemplateJSON, patch *kmsprov.KeyPatch) bool { if maskHas(mask, "versionTemplate", "versionTemplate.algorithm", "version_template.algorithm") { algo, ok, present := vt.Algorithm.normalize(algorithmNames) if present && (!ok || algo == algorithmUnspecified) { @@ -297,7 +315,7 @@ func applyVersionTemplatePatch(w http.ResponseWriter, mask string, vt *versionTe } if present { - patch.algorithm, patch.setAlgorithm = algo, true + patch.Algorithm = &algo } } @@ -309,7 +327,7 @@ func applyVersionTemplatePatch(w http.ResponseWriter, mask string, vt *versionTe } if present { - patch.protectionLevel, patch.setProtectionLevel = prot, true + patch.ProtectionLevel = &prot } } @@ -322,13 +340,13 @@ func (h *Handler) updatePrimaryVersion(w http.ResponseWriter, r *http.Request, r return } - ck, err := h.store.updatePrimaryVersion(rt, req.CryptoKeyVersionID) + ck, err := h.kms.UpdateCryptoKeyPrimaryVersion(rt.ref(), req.CryptoKeyVersionID) if err != nil { writeKMSErr(w, err) return } - gcprest.WriteJSON(w, http.StatusOK, ck) + gcprest.WriteJSON(w, http.StatusOK, toCryptoKeyJSON(&ck)) } // --- versions --- @@ -342,7 +360,7 @@ func (h *Handler) createVersion(w http.ResponseWriter, r *http.Request, rt *rout state := "" if s, ok, present := req.State.normalize(stateNames); present { - if !ok || (s != stateEnabled && s != stateDisabled) { + if !ok || (s != kmsprov.StateEnabled && s != kmsprov.StateDisabled) { invalidArg(w, "state must be ENABLED or DISABLED on create") return } @@ -350,33 +368,38 @@ func (h *Handler) createVersion(w http.ResponseWriter, r *http.Request, rt *rout state = s } - v, err := h.store.createVersion(rt, state) + keyName, v, err := h.kms.CreateCryptoKeyVersion(rt.ref(), state) if err != nil { gcprest.WriteCErr(w, err) return } - gcprest.WriteJSON(w, http.StatusOK, v) + gcprest.WriteJSON(w, http.StatusOK, toVersionJSON(keyName, &v)) } func (h *Handler) getVersion(w http.ResponseWriter, rt *route) { - v, err := h.store.getVersion(rt) + keyName, v, err := h.kms.GetCryptoKeyVersion(rt.ref()) if err != nil { gcprest.WriteCErr(w, err) return } - gcprest.WriteJSON(w, http.StatusOK, v) + gcprest.WriteJSON(w, http.StatusOK, toVersionJSON(keyName, &v)) } func (h *Handler) listVersions(w http.ResponseWriter, rt *route) { - resp, err := h.store.listVersions(rt) + keyName, versions, err := h.kms.ListCryptoKeyVersions(rt.ref()) if err != nil { gcprest.WriteCErr(w, err) return } - gcprest.WriteJSON(w, http.StatusOK, resp) + out := make([]versionJSON, 0, len(versions)) + for i := range versions { + out = append(out, toVersionJSON(keyName, &versions[i])) + } + + gcprest.WriteJSON(w, http.StatusOK, listVersionsResponse{CryptoKeyVersions: out, TotalSize: len(out)}) } func (h *Handler) patchVersion(w http.ResponseWriter, r *http.Request, rt *route) { @@ -398,69 +421,31 @@ func (h *Handler) patchVersion(w http.ResponseWriter, r *http.Request, rt *route } } - v, err := h.store.patchVersion(rt, state) + keyName, v, err := h.kms.UpdateCryptoKeyVersion(rt.ref(), state) if err != nil { writeKMSErr(w, err) return } - gcprest.WriteJSON(w, http.StatusOK, v) + gcprest.WriteJSON(w, http.StatusOK, toVersionJSON(keyName, &v)) } func (h *Handler) destroyVersion(w http.ResponseWriter, rt *route) { - v, err := h.store.destroyVersion(rt) + keyName, v, err := h.kms.DestroyCryptoKeyVersion(rt.ref()) if err != nil { writeKMSErr(w, err) return } - gcprest.WriteJSON(w, http.StatusOK, v) + gcprest.WriteJSON(w, http.StatusOK, toVersionJSON(keyName, &v)) } func (h *Handler) restoreVersion(w http.ResponseWriter, rt *route) { - v, err := h.store.restoreVersion(rt) + keyName, v, err := h.kms.RestoreCryptoKeyVersion(rt.ref()) if err != nil { writeKMSErr(w, err) return } - gcprest.WriteJSON(w, http.StatusOK, v) -} - -// --- IAM --- - -func (h *Handler) getIamPolicy(w http.ResponseWriter, rt *route) { - pol, err := h.store.getIAMPolicy(rt) - if err != nil { - gcprest.WriteCErr(w, err) - return - } - - gcprest.WriteJSON(w, http.StatusOK, pol) -} - -func (h *Handler) setIamPolicy(w http.ResponseWriter, r *http.Request, rt *route) { - var req setIamPolicyRequest - if !gcprest.DecodeJSON(w, r, &req) { - return - } - - pol, err := h.store.setIAMPolicy(rt, req.Policy) - if err != nil { - gcpiam.WriteErr(w, err) - return - } - - gcprest.WriteJSON(w, http.StatusOK, pol) -} - -func (*Handler) testIamPermissions(w http.ResponseWriter, r *http.Request) { - var req testIamPermissionsRequest - if !gcprest.DecodeJSON(w, r, &req) { - return - } - - // The emulator has no request principal, so every requested permission is - // reported as held (the stance the iam / resourcemanager handlers take too). - gcprest.WriteJSON(w, http.StatusOK, testIamPermissionsResponse(req)) + gcprest.WriteJSON(w, http.StatusOK, toVersionJSON(keyName, &v)) } diff --git a/server/gcp/kms/store.go b/server/gcp/kms/store.go deleted file mode 100644 index a4886f6b8..000000000 --- a/server/gcp/kms/store.go +++ /dev/null @@ -1,471 +0,0 @@ -package kms - -import ( - "crypto" - "sort" - "strconv" - "sync" - "time" - - "github.com/stackshy/cloudemu/v2/config" - cerrors "github.com/stackshy/cloudemu/v2/errors" -) - -const ( - // defaultDestroyScheduledDuration is the DESTROY_SCHEDULED dwell time a - // CryptoKey carries when create omits destroyScheduledDuration (24h). - defaultDestroyScheduledDuration = "86400s" - // purposeEncryptDecrypt is the only purpose whose keys carry a primary - // version (cryptoKeys.encrypt uses it); every other purpose omits primary. - purposeEncryptDecrypt = "ENCRYPT_DECRYPT" - purposeUnspecified = "CRYPTO_KEY_PURPOSE_UNSPECIFIED" - // defaultProtectionLevel is applied when a versionTemplate omits it. - defaultProtectionLevel = "SOFTWARE" - - stateEnabled = "ENABLED" - stateDisabled = "DISABLED" - stateDestroyed = "DESTROYED" - stateDestroyScheduled = "DESTROY_SCHEDULED" -) - -// keyRingModel is a keyRing plus its crypto keys and IAM policy. -type keyRingModel struct { - name string - createTime time.Time - cryptoKeys map[string]*cryptoKeyModel // keyed by short crypto-key id - iam iamState -} - -// cryptoKeyModel is a crypto key plus its versions. -type cryptoKeyModel struct { - name string - purpose string - createTime time.Time - nextRotationTime string - rotationPeriod string - protectionLevel string - algorithm string - labels map[string]string - importOnly bool - destroyScheduledDuration string - cryptoKeyBackend string - primaryID string - versions map[string]*versionModel // keyed by short version id - nextVersion int - iam iamState -} - -// versionModel is a single crypto-key version. -type versionModel struct { - id string - state string - protectionLevel string - algorithm string - 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 -// no portable driver in cloudemu, so, like the project-IAM and Cloud Billing -// handlers, the handler owns its state here, keyed by full resource name. -type store struct { - mu sync.RWMutex - clock config.Clock - keyRings map[string]*keyRingModel // keyed by full keyRing resource name -} - -func newStore(clock config.Clock) *store { - if clock == nil { - clock = config.RealClock{} - } - - return &store{clock: clock, keyRings: make(map[string]*keyRingModel)} -} - -// --- key ring operations --- - -func (s *store) createKeyRing(rt *route) (keyRingJSON, error) { - name := keyRingName(rt.project, rt.location, rt.keyRing) - - s.mu.Lock() - defer s.mu.Unlock() - - if _, ok := s.keyRings[name]; ok { - return keyRingJSON{}, cerrors.Newf(cerrors.AlreadyExists, "KeyRing %s already exists", name) - } - - kr := &keyRingModel{name: name, createTime: s.clock.Now(), cryptoKeys: map[string]*cryptoKeyModel{}} - s.keyRings[name] = kr - - return toKeyRingJSON(kr), nil -} - -func (s *store) getKeyRing(rt *route) (keyRingJSON, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - kr, err := s.findKeyRing(rt) - if err != nil { - return keyRingJSON{}, err - } - - return toKeyRingJSON(kr), nil -} - -func (s *store) listKeyRings(rt *route) listKeyRingsResponse { - prefix := keyRingName(rt.project, rt.location, "") - - s.mu.RLock() - defer s.mu.RUnlock() - - out := make([]keyRingJSON, 0) - - for name, kr := range s.keyRings { - if len(name) >= len(prefix) && name[:len(prefix)] == prefix { - out = append(out, toKeyRingJSON(kr)) - } - } - - sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) - - return listKeyRingsResponse{KeyRings: out, TotalSize: len(out)} -} - -// --- crypto key operations --- - -func (s *store) createCryptoKey(rt *route, cfg *cryptoKeyConfig, skipInitialVersion bool) (cryptoKeyJSON, error) { - name := cryptoKeyName(rt.project, rt.location, rt.keyRing, cfg.id) - - s.mu.Lock() - defer s.mu.Unlock() - - kr, err := s.findKeyRing(rt) - if err != nil { - return cryptoKeyJSON{}, err - } - - if _, ok := kr.cryptoKeys[cfg.id]; ok { - return cryptoKeyJSON{}, cerrors.Newf(cerrors.AlreadyExists, "CryptoKey %s already exists", name) - } - - now := s.clock.Now() - - ck := &cryptoKeyModel{ - name: name, - purpose: cfg.purpose, - createTime: now, - rotationPeriod: cfg.rotationPeriod, - nextRotationTime: cfg.nextRotationTime, - protectionLevel: cfg.protectionLevel, - algorithm: cfg.algorithm, - labels: cfg.labels, - importOnly: cfg.importOnly, - destroyScheduledDuration: cfg.destroyScheduledDuration, - cryptoKeyBackend: cfg.cryptoKeyBackend, - versions: map[string]*versionModel{}, - nextVersion: 1, - } - - // nextRotationTime is derived from the rotation period when a rotation is - // configured but the caller left the timestamp unset. - if ck.rotationPeriod != "" && ck.nextRotationTime == "" { - if d, ok := parseDurationSeconds(ck.rotationPeriod); ok { - ck.nextRotationTime = rfc3339(now.Add(d)) - } - } - - if !skipInitialVersion && !cfg.importOnly { - v := ck.newVersion(now, stateEnabled) - if ck.purpose == purposeEncryptDecrypt { - ck.primaryID = v.id - } - } - - kr.cryptoKeys[cfg.id] = ck - - return toCryptoKeyJSON(ck), nil -} - -func (s *store) getCryptoKey(rt *route) (cryptoKeyJSON, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - ck, err := s.findCryptoKey(rt) - if err != nil { - return cryptoKeyJSON{}, err - } - - return toCryptoKeyJSON(ck), nil -} - -func (s *store) listCryptoKeys(rt *route) (listCryptoKeysResponse, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - kr, err := s.findKeyRing(rt) - if err != nil { - return listCryptoKeysResponse{}, err - } - - out := make([]cryptoKeyJSON, 0, len(kr.cryptoKeys)) - for _, ck := range kr.cryptoKeys { - out = append(out, toCryptoKeyJSON(ck)) - } - - sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) - - return listCryptoKeysResponse{CryptoKeys: out, TotalSize: len(out)}, nil -} - -func (s *store) patchCryptoKey(rt *route, patch *cryptoKeyPatch) (cryptoKeyJSON, error) { - s.mu.Lock() - defer s.mu.Unlock() - - ck, err := s.findCryptoKey(rt) - if err != nil { - return cryptoKeyJSON{}, err - } - - if patch.setLabels { - ck.labels = patch.labels - } - - if patch.setRotationPeriod { - ck.rotationPeriod = patch.rotationPeriod - } - - if patch.setNextRotationTime { - ck.nextRotationTime = patch.nextRotationTime - } - - if patch.setProtectionLevel { - ck.protectionLevel = patch.protectionLevel - } - - if patch.setAlgorithm { - ck.algorithm = patch.algorithm - } - - return toCryptoKeyJSON(ck), nil -} - -func (s *store) updatePrimaryVersion(rt *route, versionID string) (cryptoKeyJSON, error) { - s.mu.Lock() - defer s.mu.Unlock() - - ck, err := s.findCryptoKey(rt) - if err != nil { - return cryptoKeyJSON{}, err - } - - if ck.purpose != purposeEncryptDecrypt { - return cryptoKeyJSON{}, cerrors.New(cerrors.InvalidArgument, - "UpdateCryptoKeyPrimaryVersion is only valid for keys with purpose ENCRYPT_DECRYPT") - } - - v, ok := ck.versions[versionID] - if !ok { - return cryptoKeyJSON{}, cerrors.Newf(cerrors.NotFound, "CryptoKeyVersion %s not found", versionID) - } - - if v.state != stateEnabled { - return cryptoKeyJSON{}, cerrors.New(cerrors.FailedPrecondition, - "the primary version must be ENABLED") - } - - ck.primaryID = versionID - - return toCryptoKeyJSON(ck), nil -} - -// --- version operations --- - -func (s *store) createVersion(rt *route, state string) (versionJSON, error) { - s.mu.Lock() - defer s.mu.Unlock() - - ck, err := s.findCryptoKey(rt) - if err != nil { - return versionJSON{}, err - } - - if state == "" { - state = stateEnabled - } - - v := ck.newVersion(s.clock.Now(), state) - - return toVersionJSON(ck.name, v), nil -} - -func (s *store) getVersion(rt *route) (versionJSON, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - ck, v, err := s.findVersion(rt) - if err != nil { - return versionJSON{}, err - } - - return toVersionJSON(ck.name, v), nil -} - -func (s *store) listVersions(rt *route) (listVersionsResponse, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - ck, err := s.findCryptoKey(rt) - if err != nil { - return listVersionsResponse{}, err - } - - out := make([]versionJSON, 0, len(ck.versions)) - for _, v := range ck.versions { - out = append(out, toVersionJSON(ck.name, v)) - } - - // Newest-first, matching real Cloud KMS list ordering. - sort.Slice(out, func(i, j int) bool { return out[i].Name > out[j].Name }) - - return listVersionsResponse{CryptoKeyVersions: out, TotalSize: len(out)}, nil -} - -func (s *store) patchVersion(rt *route, state string) (versionJSON, error) { - s.mu.Lock() - defer s.mu.Unlock() - - ck, v, err := s.findVersion(rt) - if err != nil { - return versionJSON{}, err - } - - // Only ENABLED<->DISABLED are user-settable via patch. - switch state { - case stateEnabled, stateDisabled: - if v.state != stateEnabled && v.state != stateDisabled { - return versionJSON{}, cerrors.Newf(cerrors.FailedPrecondition, - "cannot move version from %s to %s", v.state, state) - } - - v.state = state - case "": - // no-op: state not in mask - default: - return versionJSON{}, cerrors.Newf(cerrors.InvalidArgument, "state %s is not user-settable", state) - } - - return toVersionJSON(ck.name, v), nil -} - -func (s *store) destroyVersion(rt *route) (versionJSON, error) { - s.mu.Lock() - defer s.mu.Unlock() - - ck, v, err := s.findVersion(rt) - if err != nil { - return versionJSON{}, err - } - - if v.state != stateEnabled && v.state != stateDisabled { - return versionJSON{}, cerrors.Newf(cerrors.FailedPrecondition, - "CryptoKeyVersion in state %s cannot be destroyed", v.state) - } - - now := s.clock.Now() - v.state = stateDestroyScheduled - - if d, ok := parseDurationSeconds(ck.destroyScheduledDuration); ok { - v.destroyTime = rfc3339(now.Add(d)) - } else { - v.destroyTime = rfc3339(now) - } - - if ck.primaryID == v.id { - ck.primaryID = "" - } - - return toVersionJSON(ck.name, v), nil -} - -func (s *store) restoreVersion(rt *route) (versionJSON, error) { - s.mu.Lock() - defer s.mu.Unlock() - - ck, v, err := s.findVersion(rt) - if err != nil { - return versionJSON{}, err - } - - if v.state != stateDestroyScheduled { - return versionJSON{}, cerrors.Newf(cerrors.FailedPrecondition, - "only a DESTROY_SCHEDULED version can be restored, not one in state %s", v.state) - } - - v.state = stateDisabled - v.destroyTime = "" - - return toVersionJSON(ck.name, v), nil -} - -// --- internal lookups (callers hold s.mu) --- - -func (s *store) findKeyRing(rt *route) (*keyRingModel, error) { - kr, ok := s.keyRings[keyRingName(rt.project, rt.location, rt.keyRing)] - if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "KeyRing %s not found", rt.keyRing) - } - - return kr, nil -} - -func (s *store) findCryptoKey(rt *route) (*cryptoKeyModel, error) { - kr, err := s.findKeyRing(rt) - if err != nil { - return nil, err - } - - ck, ok := kr.cryptoKeys[rt.cryptoKey] - if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "CryptoKey %s not found", rt.cryptoKey) - } - - return ck, nil -} - -func (s *store) findVersion(rt *route) (*cryptoKeyModel, *versionModel, error) { - ck, err := s.findCryptoKey(rt) - if err != nil { - return nil, nil, err - } - - v, ok := ck.versions[rt.version] - if !ok { - return nil, nil, cerrors.Newf(cerrors.NotFound, "CryptoKeyVersion %s not found", rt.version) - } - - return ck, v, nil -} - -// newVersion appends a fresh ENABLED-by-default version to the crypto key and -// returns it. Callers hold s.mu. -func (ck *cryptoKeyModel) newVersion(now time.Time, state string) *versionModel { - id := strconv.Itoa(ck.nextVersion) - ck.nextVersion++ - - v := &versionModel{ - id: id, - state: state, - protectionLevel: ck.protectionLevel, - algorithm: ck.algorithm, - createTime: now, - } - ck.versions[id] = v - - return v -} diff --git a/server/gcp/kms/types.go b/server/gcp/kms/types.go index 14232aad1..289f3bfb5 100644 --- a/server/gcp/kms/types.go +++ b/server/gcp/kms/types.go @@ -1,11 +1,5 @@ package kms -import ( - "strconv" - "strings" - "time" -) - // --- request bodies --- // createCryptoKeyRequest is the CryptoKey create/patch body. Enum fields use @@ -93,68 +87,3 @@ type listVersionsResponse struct { CryptoKeyVersions []versionJSON `json:"cryptoKeyVersions"` TotalSize int `json:"totalSize"` } - -// --- IAM wire types --- - -type iamPolicyJSON struct { - Version int `json:"version,omitempty"` - Bindings []iamBindingJSON `json:"bindings,omitempty"` - Etag string `json:"etag,omitempty"` -} - -type iamBindingJSON struct { - Role string `json:"role"` - Members []string `json:"members,omitempty"` - Condition *iamConditionJSON `json:"condition,omitempty"` -} - -type iamConditionJSON struct { - Expression string `json:"expression,omitempty"` - Title string `json:"title,omitempty"` - Description string `json:"description,omitempty"` -} - -type setIamPolicyRequest struct { - Policy iamPolicyJSON `json:"policy"` -} - -type testIamPermissionsRequest struct { - Permissions []string `json:"permissions"` -} - -type testIamPermissionsResponse struct { - Permissions []string `json:"permissions,omitempty"` -} - -// --- name builders --- - -func keyRingName(project, location, keyRing string) string { - return "projects/" + project + "/locations/" + location + "/keyRings/" + keyRing -} - -func cryptoKeyName(project, location, keyRing, cryptoKey string) string { - return keyRingName(project, location, keyRing) + "/cryptoKeys/" + cryptoKey -} - -// --- time / duration helpers --- - -// rfc3339 formats t as the RFC 3339 UTC timestamp Cloud KMS emits. -func rfc3339(t time.Time) string { - return t.UTC().Format(time.RFC3339Nano) -} - -// parseDurationSeconds parses a protobuf Duration ("7776000s", "3.5s") into a -// time.Duration. ok is false when the string is not a valid seconds Duration. -func parseDurationSeconds(d string) (time.Duration, bool) { - s, ok := strings.CutSuffix(strings.TrimSpace(d), "s") - if !ok || s == "" { - return 0, false - } - - f, err := strconv.ParseFloat(s, 64) - if err != nil { - return 0, false - } - - return time.Duration(f * float64(time.Second)), true -}