Skip to content

Commit

Permalink
Add support for rotating root keys (#53)
Browse files Browse the repository at this point in the history
  • Loading branch information
sethvargo authored and Jim Kalafut committed Nov 19, 2019
1 parent 524aa9a commit 4cf5123
Show file tree
Hide file tree
Showing 25 changed files with 391 additions and 5,497 deletions.
3 changes: 0 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ module github.com/hashicorp/vault-plugin-secrets-gcp
go 1.12

require (
github.com/golang/mock v1.2.0 // indirect
github.com/hashicorp/errwrap v1.0.0
github.com/hashicorp/go-cleanhttp v0.5.1
github.com/hashicorp/go-gcp-common v0.5.0
Expand All @@ -16,6 +15,4 @@ require (
github.com/mitchellh/mapstructure v1.1.2
golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a
google.golang.org/api v0.3.2
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/yaml.v2 v2.2.2 // indirect
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/vault v1.3.0 h1:MaMv38qb6DNH1uuNLHaebioueemmCrftQdOiUnlNrPU=
github.com/hashicorp/vault-plugin-auth-gcp v0.5.1 h1:8DR00s+Wmc21i3sfzvsqW88VMdf6NI2ue+onGoHshww=
github.com/hashicorp/vault-plugin-auth-gcp v0.5.1/go.mod h1:eLj92eX8MPI4vY1jaazVLF2sVbSAJ3LRHLRhF/pUmlI=
github.com/hashicorp/vault/api v1.0.1 h1:YQI4SgOlkmbEKZI8ZClo6fm9oXlBHJUlrbEtFiRPrng=
Expand All @@ -99,6 +100,7 @@ github.com/hashicorp/vault/api v1.0.5-0.20190814205728-e9c5cd8aca98 h1:LUVHA+Z7z
github.com/hashicorp/vault/api v1.0.5-0.20190814205728-e9c5cd8aca98/go.mod h1:t4IAg1Is4bLUtTq8cGgeUh0I8oDRBXPk2bM1Jvg/nWA=
github.com/hashicorp/vault/sdk v0.1.8 h1:pfF3KwA1yPlfpmcumNsFM4uo91WMasX5gTuIkItu9r0=
github.com/hashicorp/vault/sdk v0.1.8/go.mod h1:tHZfc6St71twLizWNHvnnbiGFo1aq0eD2jGPLtP8kAU=
github.com/hashicorp/vault/sdk v0.1.13 h1:mOEPeOhT7jl0J4AMl1E705+BcmeRs1VmKNb9F0sMLy8=
github.com/hashicorp/vault/sdk v0.1.14-0.20190814205504-1cad00d1133b h1:uC3aN7xIG8gPNm9cbNY05OJ44cYfAv5Rn+QLSBsFq1s=
github.com/hashicorp/vault/sdk v0.1.14-0.20190814205504-1cad00d1133b/go.mod h1:B+hVj7TpuQY1Y/GPbCpffmgd+tSEwvhkWnjtSYCaS2M=
github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
Expand Down
1 change: 1 addition & 0 deletions plugin/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func Backend() *backend {
pathsRoleSet(b),
[]*framework.Path{
pathConfig(b),
pathConfigRotateRoot(b),
pathSecretAccessToken(b),
pathSecretServiceAccountKey(b),
},
Expand Down
133 changes: 133 additions & 0 deletions plugin/path_config_rotate_root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package gcpsecrets

import (
"context"
"encoding/base64"
"fmt"

"github.com/hashicorp/errwrap"
"github.com/hashicorp/go-gcp-common/gcputil"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
"google.golang.org/api/iam/v1"
)

func pathConfigRotateRoot(b *backend) *framework.Path {
return &framework.Path{
Pattern: "config/rotate-root",

Operations: map[logical.Operation]framework.OperationHandler{
logical.UpdateOperation: &framework.PathOperation{
Callback: b.pathConfigRotateRootWrite,
},
},

HelpSynopsis: pathConfigRotateRootHelpSyn,
HelpDescription: pathConfigRotateRootHelpDesc,
}
}

func (b *backend) pathConfigRotateRootWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
// Get the current configuration
cfg, err := getConfig(ctx, req.Storage)
if err != nil {
return nil, err
}
if cfg == nil {
return nil, fmt.Errorf("no configuration")
}
if cfg.CredentialsRaw == "" {
return nil, fmt.Errorf("configuration does not have credentials - this " +
"endpoint only works with user-provided JSON credentials explicitly " +
"provided via the config/ endpoint")
}

// Parse the credential JSON to extract the email (we need it for the API
// call)
creds, err := gcputil.Credentials(cfg.CredentialsRaw)
if err != nil {
return nil, errwrap.Wrapf("credentials are invalid: {{err}}", err)
}

// Generate a new service account key
iamAdmin, err := b.IAMAdminClient(req.Storage)
if err != nil {
return nil, errwrap.Wrapf("failed to create iam client: {{err}}", err)
}

saName := "projects/-/serviceAccounts/" + creds.ClientEmail
newKey, err := iamAdmin.Projects.ServiceAccounts.Keys.
Create(saName, &iam.CreateServiceAccountKeyRequest{
KeyAlgorithm: keyAlgorithmRSA2k,
PrivateKeyType: privateKeyTypeJson,
}).
Context(ctx).
Do()
if err != nil {
return nil, errwrap.Wrapf("failed to create new key: {{err}}", err)
}

// Base64-decode the private key data (it's the JSON file)
newCredsJSON, err := base64.StdEncoding.DecodeString(newKey.PrivateKeyData)
if err != nil {
return nil, errwrap.Wrapf("failed to decode credentials: {{err}}", err)
}

// Verify creds are valid
newCreds, err := gcputil.Credentials(string(newCredsJSON))
if err != nil {
return nil, errwrap.Wrapf("api returned invalid credentials: {{err}}", err)
}

// Update the configuration
cfg.CredentialsRaw = string(newCredsJSON)
entry, err := logical.StorageEntryJSON("config", cfg)
if err != nil {
return nil, errwrap.Wrapf("failed to generate new configuration: {{err}}", err)
}
if err := req.Storage.Put(ctx, entry); err != nil {
return nil, errwrap.Wrapf("failed to save new configuration: {{err}}", err)
}

// Clear caches to pick up the new credentials
b.ClearCaches()

// Delete the old service account key
oldKeyName := fmt.Sprintf("projects/%s/serviceAccounts/%s/keys/%s",
creds.ProjectId,
creds.ClientEmail,
creds.PrivateKeyId)
if _, err := iamAdmin.Projects.ServiceAccounts.Keys.
Delete(oldKeyName).
Context(ctx).
Do(); err != nil {
return nil, errwrap.Wrapf(fmt.Sprintf(
"failed to delete old service account key (%q) - the new service "+
"account key (%q) is active, but the old one still exists: {{err}}",
creds.PrivateKeyId, newCreds.PrivateKeyId), err)
}

// We did it!
return &logical.Response{
Data: map[string]interface{}{
"private_key_id": newCreds.PrivateKeyId,
},
}, nil
}

const pathConfigRotateRootHelpSyn = `
Request to rotate the GCP credentials used by Vault
`

const pathConfigRotateRootHelpDesc = `
This path attempts to rotate the GCP service account credentials used by Vault
for this mount. It does this by generating a new key for the service account,
replacing the internal value, and then scheduling a deletion of the old service
account key. Note that it does not create a new service account, only a new
version of the service account key.
This path is only valid if Vault has been configured to use GCP credentials via
the config/ endpoint where "credentials" were specified. Additionally, the
provided service account must have permissions to create and delete service
account keys.
`
198 changes: 198 additions & 0 deletions plugin/path_config_rotate_root_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package gcpsecrets

import (
"context"
"encoding/base64"
"fmt"
"io/ioutil"
"os"
"strings"
"testing"
"time"

"github.com/hashicorp/go-cleanhttp"
"github.com/hashicorp/go-gcp-common/gcputil"
"github.com/hashicorp/vault/sdk/logical"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/iam/v1"
)

func TestConfigRotateRootUpdate(t *testing.T) {
t.Parallel()

t.Run("no_configuration", func(t *testing.T) {
t.Parallel()

b, storage := getTestBackend(t)
_, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.UpdateOperation,
Path: "config/rotate-root",
Storage: storage,
})
if err == nil {
t.Fatal("expected error")
}
if exp, act := "no configuration", err.Error(); !strings.Contains(act, exp) {
t.Errorf("expected %q to contain %q", act, exp)
}
})

t.Run("config_with_no_credentials", func(t *testing.T) {
t.Parallel()

ctx := context.Background()
b, storage := getTestBackend(t)

entry, err := logical.StorageEntryJSON("config", &config{
TTL: 5 * time.Minute,
})
if err != nil {
t.Fatal(err)
}
if err := storage.Put(ctx, entry); err != nil {
t.Fatal(err)
}

_, err = b.HandleRequest(ctx, &logical.Request{
Operation: logical.UpdateOperation,
Path: "config/rotate-root",
Storage: storage,
})
if err == nil {
t.Fatal("expected error")
}
if exp, act := "does not have credentials", err.Error(); !strings.Contains(act, exp) {
t.Errorf("expected %q to contain %q", act, exp)
}
})

t.Run("config_with_invalid_credentials", func(t *testing.T) {
t.Parallel()

ctx := context.Background()
b, storage := getTestBackend(t)

entry, err := logical.StorageEntryJSON("config", &config{
CredentialsRaw: "baconbaconbacon",
})
if err != nil {
t.Fatal(err)
}
if err := storage.Put(ctx, entry); err != nil {
t.Fatal(err)
}

_, err = b.HandleRequest(ctx, &logical.Request{
Operation: logical.UpdateOperation,
Path: "config/rotate-root",
Storage: storage,
})
if err == nil {
t.Fatal("expected error")
}
if exp, act := "credentials are invalid", err.Error(); !strings.Contains(act, exp) {
t.Errorf("expected %q to contain %q", act, exp)
}
})

t.Run("rotate", func(t *testing.T) {
t.Parallel()

if testing.Short() {
t.Skip("skipping integration test (short)")
}

ctx := context.Background()
b, storage := getTestBackend(t)

// Get user-supplied credentials
credsPath := os.Getenv("GOOGLE_CREDENTIALS")
credsBytes, err := ioutil.ReadFile(credsPath)
if err != nil {
t.Fatal(err)
}
creds, err := google.CredentialsFromJSON(ctx, credsBytes, iam.CloudPlatformScope)
if err != nil {
t.Fatal(err)
}
parsedCreds, err := gcputil.Credentials(string(credsBytes))
if err != nil {
t.Fatal(err)
}

// Create http client
clientCtx := context.WithValue(ctx, oauth2.HTTPClient, cleanhttp.DefaultClient())
client := oauth2.NewClient(clientCtx, creds.TokenSource)

// Create IAM client
iamAdmin, err := iam.New(client)
if err != nil {
t.Fatal(err)
}

// Create a new key, since this endpoint revokes the old key
saName := "projects/-/serviceAccounts/" + parsedCreds.ClientEmail
newKey, err := iamAdmin.Projects.ServiceAccounts.Keys.
Create(saName, &iam.CreateServiceAccountKeyRequest{
KeyAlgorithm: keyAlgorithmRSA2k,
PrivateKeyType: privateKeyTypeJson,
}).
Context(ctx).
Do()
if err != nil {
t.Fatal(err)
}

// Base64-decode the private key data (it's the JSON file)
newCredsJSON, err := base64.StdEncoding.DecodeString(newKey.PrivateKeyData)
if err != nil {
t.Fatal(err)
}

// Parse new creds
newCreds, err := gcputil.Credentials(string(newCredsJSON))
if err != nil {
t.Fatal(err)
}

// If we made it this far, schedule a cleanup of the new key
defer func() {
newKeyName := fmt.Sprintf("projects/%s/serviceAccounts/%s/keys/%s",
newCreds.ProjectId,
newCreds.ClientEmail,
newCreds.PrivateKeyId)
iamAdmin.Projects.ServiceAccounts.Keys.Delete(newKeyName)
}()

// Set config to the key
entry, err := logical.StorageEntryJSON("config", &config{
CredentialsRaw: string(newCredsJSON),
})
if err != nil {
t.Fatal(err)
}
if err := storage.Put(ctx, entry); err != nil {
t.Fatal(err)
}

// Rotate the key
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.UpdateOperation,
Path: "config/rotate-root",
Storage: storage,
})
if err != nil {
t.Fatal(err)
}

privateKeyId := resp.Data["private_key_id"]
if privateKeyId == "" {
t.Errorf("missing private_key_id")
}

if privateKeyId == newCreds.PrivateKeyId {
t.Errorf("creds were not rotated")
}
})
}
Loading

0 comments on commit 4cf5123

Please sign in to comment.