-
Notifications
You must be signed in to change notification settings - Fork 24
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add support for rotating root keys (#53)
- Loading branch information
Showing
25 changed files
with
391 additions
and
5,497 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. | ||
` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
} | ||
}) | ||
} |
Oops, something went wrong.