Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce the gcp_kms KeyManager plugin #3410

Merged
merged 26 commits into from
Nov 10, 2022

Conversation

amartinezfayo
Copy link
Member

@amartinezfayo amartinezfayo commented Sep 7, 2022

This PR introduces the gcp_kms KeyManager plugin, that uses the Google Cloud Key Management Service.

Fixes #3194.

@evan2645 evan2645 added this to the 1.4.3 milestone Sep 8, 2022
Signed-off-by: Agustín Martínez Fayó <[email protected]>
@@ -267,6 +267,24 @@ plugins {
# }
# }

# KeyManager "gcp_kms": A key manager for signing SVIDs which generates
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# KeyManager "gcp_kms": A key manager for signing SVIDs which generates
# KeyManager "gcp_kms": A key manager for signing SVIDs which generates

#
# key_policy_file: A file path location to a custom IAM Policy (v3)
# in JSON format to be attached to created CryptoKeys.
# key_policy_file = "custom-gcp-kms-policy.json"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this file is going to be loaded at start and never updated?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the file will be read each time that Configure is called.

# KeyManager "gcp_kms": A key manager for signing SVIDs which generates
# and stores keys in Google Cloud KMS.
# KeyManager "gcp_kms" {
# plugin_data = {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in another plugins we provides a way to configure different service account to be used, for example service_account_file that allows to set a different credentials,
Maybe we can add something like this here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the plugin supports that already. I just need to add the example here.


The plugin requires to have in the configured key ring the following IAM permissions granted to the authenticated service account:
```
cloudkms.cryptoKeys.*
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what it means .* we really need all cryptoKeys permissions?
can you instead set the ones we really need?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we need all the permissions over cryptoKeys (cloudkms.cryptoKeys.create, cloudkms.cryptoKeys.get, cloudkms.cryptoKeys.getIamPolicy, cloudkms.cryptoKeys.list, cloudkms.cryptoKeys.setIamPolicy, cloudkms.cryptoKeys.update).

}
}

// scheduleDestroyTask ia a long running task that schedules the destruction
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// scheduleDestroyTask ia a long running task that schedules the destruction
// scheduleDestroyTask is a long running task that schedules the destruction

// The GetCryptoKeyVersion call failed. Log this and re-enqueue
// the CryptoKey for destruction. Hopefully, this is a
// recoverable error.
log.Error("Could not get the CryptoKeyVersion while trying to schedule it for destruction")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you log the error here?


for _, e := range keyEntries {
p.entries[e.publicKey.Id] = *e
p.log.Debug("Cloud KMS key loaded", cryptoKeyVersionNameTag, e.cryptoKeyVersion.Name, algorithmTag, e.cryptoKeyVersion.Algorithm)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as a general comment, I think log will already have the plugin name as a fields so Cloud KMS is not required as part of the logs.

return "", status.Errorf(codes.Internal, "failed to generate ID for server: %v", err)
}

err = os.WriteFile(idPath, []byte(id), 0600)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this plugin may works on windows too, is that permission lvl enough there?

@azdagron azdagron modified the milestones: 1.4.3, 1.5.0 Sep 15, 2022
Signed-off-by: Agustín Martínez Fayó <[email protected]>
…ng a new CryptoKey.

Signed-off-by: Agustín Martínez Fayó <[email protected]>
Signed-off-by: Agustín Martínez Fayó <[email protected]>
Signed-off-by: Agustín Martínez Fayó <[email protected]>
Signed-off-by: Agustín Martínez Fayó <[email protected]>
@evan2645
Copy link
Member

This is ready to review, but more tests are on the way.

Signed-off-by: Agustín Martínez Fayó <[email protected]>
Signed-off-by: Agustín Martínez Fayó <[email protected]>
…Cloud KMS API

Signed-off-by: Agustín Martínez Fayó <[email protected]>
@amartinezfayo amartinezfayo marked this pull request as ready for review October 14, 2022 23:12
Copy link
Collaborator

@MarcosDY MarcosDY left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some more comments, I still need to take a look to unit tests

Comment on lines 274 to 284
# key_metadata_file: A file path location where information about
# generated keys will be persisted.
# key_metadata_file = "./file_path"
#
# key_policy_file: A file path location to a custom IAM Policy (v3)
# in JSON format to be attached to created CryptoKeys.
# key_policy_file = "custom-gcp-kms-policy.json"
#
# key_ring: Resource ID of the key ring where the keys managed by this
# plugin reside.
# key_ring = "projects/project/locations/location/keyRings/key-ring"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# key_metadata_file: A file path location where information about
# generated keys will be persisted.
# key_metadata_file = "./file_path"
#
# key_policy_file: A file path location to a custom IAM Policy (v3)
# in JSON format to be attached to created CryptoKeys.
# key_policy_file = "custom-gcp-kms-policy.json"
#
# key_ring: Resource ID of the key ring where the keys managed by this
# plugin reside.
# key_ring = "projects/project/locations/location/keyRings/key-ring"
# # key_metadata_file: A file path location where information about
# # generated keys will be persisted.
# # key_metadata_file = "./file_path"
#
# # key_policy_file: A file path location to a custom IAM Policy (v3)
# # in JSON format to be attached to created CryptoKeys.
# # key_policy_file = "custom-gcp-kms-policy.json"
#
# # key_ring: Resource ID of the key ring where the keys managed by this
# # plugin reside.
# # key_ring = "projects/project/locations/location/keyRings/key-ring"

Comment on lines 9 to 14
| Key | Type | Required | Description | Default |
| --- | ---- | -------- | ----------- | ------- |
| key_policy_file | string | no | A file path location to a custom [IAM Policy (v3)](https://cloud.google.com/pubsub/docs/reference/rpc/google.iam.v1#google.iam.v1.Policy) in JSON format to be attached to created CryptoKeys. | "" |
| key_metadata_file | string | yes | A file path location where key metadata used by the plugin will be persisted. | "" |
| key_ring | string | yes | Resource ID of the key ring where the keys managed by this plugin reside, in the format projects/\*/locations/\*/keyRings/\* | "" |
| service_account_file | string | no | Path to the service account file used to authenticate with the Cloud KMS API. | Value of `GOOGLE_APPLICATION_CREDENTIALS ` environment variable. |
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you make this table consistent with our new standards?

@@ -0,0 +1,108 @@
# Server plugin: KeyManager "gcp_kms"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: we are going to add a md linter, as part of this linter we are going to force the lines to a max of 80 characters, it will be great if you apply that here

### Required permissions

The plugin requires to have in the configured key ring the following IAM permissions granted to the authenticated service account:
```
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all code pieces must have a type defined, what do you think to set it as text?

Comment on lines 97 to 98
```
KeyManager "gcp_kms" {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
```
KeyManager "gcp_kms" {
```hcl
KeyManager "gcp_kms" {

return fakeCryptoKeyVersion.privateKey.Sign(rand.Reader, digest, opts)
}

cryptoKeyName := path.Dir(path.Dir(signReq.Name))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why double path.Dir is required?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need the CryptoKey name here. The signing request comes with the CryptoKeyVersion name, e.g.: projects/project-name/locations/location-name/keyRings/key-ring-name/spire-key-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-spireKeyID-1/cryptoKeyVersions/1, so we need to get go up two levels to be at the CryptoKey level, in this case: projects/project-name/locations/location-name/keyRings/key-ring-name/spire-key-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-spireKeyID-1.

return nil, status.Error(codes.Internal, "cryptoKey is nil")
}

it := kf.kmsClient.ListCryptoKeyVersions(ctx, &kmspb.ListCryptoKeyVersionsRequest{
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: can you add a comment, about this List will return only latest versions? (we keep only 2 actives for now)

Comment on lines 295 to 301
p.entriesMtx.RLock()
defer p.entriesMtx.RUnlock()

keyEntry, hasKey := p.entries[req.KeyId]
if !hasKey {
return nil, status.Errorf(codes.NotFound, "key %q not found", req.KeyId)
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

may we unlock as soon as we get the entry?
Sings can be a slow process, and I'm worried to unlocking entries until we finish the signing can result in some unexpected locks,
maybe you can add a getEntry(keyID string) (entry, ok) {} and add lock there

Comment on lines +523 to +525
if err := p.enqueueDestruction(cryptoKeyVersion.Name); err != nil {
p.log.With(cryptoKeyNameTag, cryptoKey.Name).Error("Failed to enqueue CryptoKeyVersion for destruction", reasonTag, err)
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens if we are not able to enqueue destruction but continue here?
we can get into a situation where we failed to enqueue all versions, but at the end we marke cryptoKey as inactive.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. Calling setInactive after detecting that there are no more CryptoKeyVersions at this point assumed that all versions could be enqueued for destruction, but that may not always be the case if there was an error. Since we already call setInactive if the returned CryptoKey doesn't have enabled CryptoKeyVersions, a safer approach would be to just leave that and remove this one.

defer p.entriesMtx.RUnlock()
var errs []string
for _, entry := range p.entries {
entry.cryptoKey.Labels[labelNameLastUpdate] = fmt.Sprint(p.hooks.clk.Now().Unix())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function has a RLock, but it is setting things here

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch!


If the _Key Metadata File_ is not found during server startup, the file is recreated, with a new auto-generated server ID. Consequently, if the file is lost, the plugin will not be able to identify keys that it has previously managed and will recreate new keys on demand.

When an active key is rotated, the old CryptoKeyVersion is scheduled for destruction, and the corresponding CryptoKey is set as inactive.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this correct? The last sentence in "Use of key versions" seems contradictory and makes a little more sense.

Since we already have that sentence, maybe we can just remove this one?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching this. This paragraph was written when the plugin didn't manage multiple CryptoKeyVersions.

| Key | Type | Required | Description | Default |
| --- | ---- | -------- | ----------- | ------- |
| key_policy_file | string | no | A file path location to a custom [IAM Policy (v3)](https://cloud.google.com/pubsub/docs/reference/rpc/google.iam.v1#google.iam.v1.Policy) in JSON format to be attached to created CryptoKeys. | "" |
| key_metadata_file | string | yes | A file path location where key metadata used by the plugin will be persisted. | "" |
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
| key_metadata_file | string | yes | A file path location where key metadata used by the plugin will be persisted. | "" |
| key_metadata_file | string | yes | A file path location where key metadata used by the plugin will be persisted. See "[Management of keys](#management-of-keys)" for more information. | "" |

When I read this table, the first question I had was "what is this and why do I need it" .. the section below explains it well ❤️


### Required permissions

The plugin requires to have in the configured key ring the following IAM permissions granted to the authenticated service account:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The plugin requires to have in the configured key ring the following IAM permissions granted to the authenticated service account:
The plugin requires the following IAM permissions be granted to the authenticated service account in the configured key ring:

Here's an alternative, I'm not sure if it's more or less confusing 😂

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I struggled a lot with that phrase! The suggested change looks a lot better to me.

// with the CryptoKey, CryptoKeyVersion and public key.
func (p *Plugin) createKey(ctx context.Context, spireKeyID string, keyType keymanagerv1.KeyType) (*keyEntry, error) {
p.entriesMtx.Lock()
defer p.entriesMtx.Unlock()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming I understand this correctly ... we'll create a key here when SPIRE prepares a new one ... but, we take a write lock here to do it, so the act of preparing a new key will block signing operations on our other (currently active) key?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. The lock window could be reduced using a function that would update the cache holding the write lock only in that function. This function could then call it when it updates the key entries cache. I'll make that change.

p.log.Error("Failed to enqueue CryptoKeyVersion for destruction", reasonTag, err)
}

return &newKeyEntry, nil
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we're effectively branching all of createKey on whether we have an existing crypto key or not, it might be easier to read if it were broken out into two further functions

listCryptoKeysErr error
describeKeyErr error
getPublicKeyErr error
}{
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: can you add test cases to:

  • failed to get or create serverID
  • ServiceAccountFile is used
  • failed to create KMS client

if tt.expectCode != codes.OK {
return
}
require.NoError(t, err)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little worried about we are not testing the actual state of this configured plugin, like if it is really getting (and storing) keys? what happens with stored config? etc

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. I'll extend the assertions to assert the config settings and keys loaded.

Comment on lines 188 to 217
{
CryptoKey: &kmspb.CryptoKey{
Name: cryptoKeyName1,
VersionTemplate: &kmspb.CryptoKeyVersionTemplate{Algorithm: kmspb.CryptoKeyVersion_EC_SIGN_P256_SHA256},
},
fakeCryptoKeyVersions: map[string]*fakeCryptoKeyVersion{
"1": {
publicKey: pubKey,
CryptoKeyVersion: &kmspb.CryptoKeyVersion{
Algorithm: kmspb.CryptoKeyVersion_EC_SIGN_P256_SHA256,
Name: fmt.Sprintf("%s/cryptoKeyVersions/1", cryptoKeyName1),
},
},
},
},
{
CryptoKey: &kmspb.CryptoKey{
Name: cryptoKeyName1,
VersionTemplate: &kmspb.CryptoKeyVersionTemplate{Algorithm: kmspb.CryptoKeyVersion_EC_SIGN_P256_SHA256},
},
fakeCryptoKeyVersions: map[string]*fakeCryptoKeyVersion{
"2": {
publicKey: pubKey,
CryptoKeyVersion: &kmspb.CryptoKeyVersion{
Algorithm: kmspb.CryptoKeyVersion_EC_SIGN_P256_SHA256,
Name: fmt.Sprintf("%s/cryptoKeyVersions/2", cryptoKeyName1),
},
},
},
},
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why this "duplicated" keys are reuqired? are you thinking on test that we keep only one?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was duplicated without intention, removed.

ts.fakeKMSClient.putFakeCryptoKeys(tt.fakeCryptoKeys)

// This is so dispose aliases blocks on init and allows to test dispose keys isolated
ts.plugin.hooks.disposeCryptoKeysSignal = make(chan error)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make(chan error, 1)?

Comment on lines 427 to 428
require.NotNil(t, err)
require.Equal(t, tt.err, err.Error())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

require.EqualError(t, err, tt.err)?

Comment on lines 891 to 892
require.NotNil(t, err)
require.Equal(t, tt.expectError, err.Error())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
require.NotNil(t, err)
require.Equal(t, tt.expectError, err.Error())
require.EqualError(t, err, err.Error())

Comment on lines 976 to 977
require.Error(t, err)
require.Equal(t, err.Error(), tt.err)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
require.Error(t, err)
require.Equal(t, err.Error(), tt.err)
require.EqualError(t, err, tt.err)


err = ts.plugin.setIamPolicy(ctx, cryptoKeyName1)
if tt.expectError != "" {
require.Error(t, err, tt.expectError)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
require.Error(t, err, tt.expectError)
require.EqualError(t, err, tt.expectError)

},
},
signatureCrc32C: &wrapperspb.Int64Value{Value: 1},
},
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you add a test case with unsupported sign type?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. I've added a test case to test an unsupported key type in TestGenerateKey.

getPublicKeyErr error
getTokenInfoErr error
updateCryptoKeyErr error
}{
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont know if this is the place, but, can you add a test case to validate what happens if we are not able to enqueue items for destruction? and verify that cryptoKey is disabled "only" when it does not contain vesions?

Signed-off-by: Agustín Martínez Fayó <[email protected]>
Signed-off-by: Agustín Martínez Fayó <[email protected]>
Signed-off-by: Agustín Martínez Fayó <[email protected]>
Signed-off-by: Agustín Martínez Fayó <[email protected]>
@azdagron azdagron modified the milestones: 1.5.0, 1.5.1 Oct 27, 2022
Copy link
Collaborator

@MarcosDY MarcosDY left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@amartinezfayo amartinezfayo merged commit 1e272b0 into spiffe:main Nov 10, 2022
@amartinezfayo amartinezfayo deleted the gcp-kms branch March 1, 2023 18:00
stevend-uber pushed a commit to stevend-uber/spire that referenced this pull request Oct 16, 2023
* Introduce the gcp_kms plugin

Signed-off-by: Agustín Martínez Fayó <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

KMS plugin for GCP
4 participants