Repository: google/exposure-notifications-server
Pull request #926 (2020-08-27 18:38:38)

Add signing key manager to Vault
Fixes https://github.com/google/exposure-notifications-server/issues/915

⚠️ Vault doesn't support destroying a single key version, so it returns an error.

**Release Note**

<!-- Enter your extended release note in the below block. If the PR requires
additional action from users switching to the new release, include the string
"action required". If no release note is required, write "NONE". -->

```release-note
Add support for SigningKeyManager interface with Vault
```

google-oss-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/926#" title="Author self-approved">sethvargo</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=google%2Fexposure-notifications-server).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/google/exposure-notifications-server/blob/main/OWNERS)~~ [sethvargo]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
google-oss-robot: @sethvargo: The following test **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
pull-en-server-release-unit | 97b732c428863f3d1698b123a3c1df3101fc684f | [link](https://oss-prow.knative.dev/view/gs/oss-prow/pr-logs/pull/google_exposure-notifications-server/926/pull-en-server-release-unit/1299053953283002368) | `/test pull-en-server-release-unit`

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->

Patch:
diff --git a/cmd/cleanup-export/main.go b/cmd/cleanup-export/main.go
index bdcf9e3..6b19e86 100644
--- a/cmd/cleanup-export/main.go
+++ b/cmd/cleanup-export/main.go
@@ -60,6 +60,7 @@ func realMain(ctx context.Context) error {
 
 	mux := http.NewServeMux()
 	mux.Handle("/", handler)
+	mux.Handle("/health", server.HandleHealthz(ctx))
 
 	srv, err := server.New(config.Port)
 	if err != nil {
diff --git a/cmd/cleanup-exposure/main.go b/cmd/cleanup-exposure/main.go
index d39b19d..6ac26ee 100644
--- a/cmd/cleanup-exposure/main.go
+++ b/cmd/cleanup-exposure/main.go
@@ -60,6 +60,7 @@ func realMain(ctx context.Context) error {
 
 	mux := http.NewServeMux()
 	mux.Handle("/", handler)
+	mux.Handle("/health", server.HandleHealthz(ctx))
 
 	srv, err := server.New(config.Port)
 	if err != nil {
diff --git a/cmd/exposure/main.go b/cmd/exposure/main.go
index 5843bcb..c544aac 100644
--- a/cmd/exposure/main.go
+++ b/cmd/exposure/main.go
@@ -54,6 +54,7 @@ func realMain(ctx context.Context) error {
 	defer env.Close(ctx)
 
 	mux := http.NewServeMux()
+	mux.Handle("/health", server.HandleHealthz(ctx))
 	handler, err := publish.NewHandler(ctx, &config, env)
 	if err != nil {
 		return fmt.Errorf("publish.NewHandler: %w", err)
diff --git a/cmd/federationin/main.go b/cmd/federationin/main.go
index 44a3f91..622f36b 100644
--- a/cmd/federationin/main.go
+++ b/cmd/federationin/main.go
@@ -58,6 +58,7 @@ func realMain(ctx context.Context) error {
 
 	mux := http.NewServeMux()
 	mux.Handle("/", handler)
+	mux.Handle("/health", server.HandleHealthz(ctx))
 
 	srv, err := server.New(config.Port)
 	if err != nil {
diff --git a/cmd/generate/main.go b/cmd/generate/main.go
index 71e9c07..44c6dcc 100644
--- a/cmd/generate/main.go
+++ b/cmd/generate/main.go
@@ -61,6 +61,7 @@ func realMain(ctx context.Context) error {
 
 	mux := http.NewServeMux()
 	mux.Handle("/", handler)
+	mux.Handle("/health", server.HandleHealthz(ctx))
 
 	srv, err := server.New(config.Port)
 	if err != nil {
diff --git a/go.mod b/go.mod
index bc27b0b..cb45c29 100644
--- a/go.mod
+++ b/go.mod
@@ -34,7 +34,7 @@ require (
 	github.com/hashicorp/go-msgpack v1.1.5 // indirect
 	github.com/hashicorp/go-multierror v1.1.0
 	github.com/hashicorp/go-plugin v1.3.0 // indirect
-	github.com/hashicorp/go-retryablehttp v0.6.6 // indirect
+	github.com/hashicorp/go-retryablehttp v0.6.7 // indirect
 	github.com/hashicorp/raft v1.1.2 // indirect
 	github.com/hashicorp/vault v1.2.1-0.20200522144850-6f72d4ff250f
 	github.com/hashicorp/vault-plugin-secrets-kv v0.5.5
@@ -51,7 +51,7 @@ require (
 	github.com/mikehelmick/go-chaff v0.3.0
 	github.com/mitchellh/cli v1.1.1 // indirect
 	github.com/mitchellh/go-testing-interface v1.14.1 // indirect
-	github.com/mitchellh/mapstructure v1.3.1 // indirect
+	github.com/mitchellh/mapstructure v1.3.3
 	github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
 	github.com/oklog/run v1.1.0 // indirect
 	github.com/onsi/ginkgo v1.12.0 // indirect
@@ -72,8 +72,10 @@ require (
 	github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c // indirect
 	go.opencensus.io v0.22.4
 	go.uber.org/zap v1.14.1
+	golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a // indirect
+	golang.org/x/net v0.0.0-20200822124328-c89045814202 // indirect
 	golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
-	golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 // indirect
+	golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect
 	golang.org/x/tools v0.0.0-20200715235423-130c9f19d3fe
 	google.golang.org/api v0.29.0
 	google.golang.org/genproto v0.0.0-20200715011427-11fb19a81f2c
diff --git a/go.sum b/go.sum
index ccd8398..94f0bd9 100644
--- a/go.sum
+++ b/go.sum
@@ -614,8 +614,8 @@ github.com/hashicorp/go-retryablehttp v0.5.4 h1:1BZvpawXoJCWX6pNtow9+rpEj+3itIlu
 github.com/hashicorp/go-retryablehttp v0.5.4/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
 github.com/hashicorp/go-retryablehttp v0.6.2 h1:bHM2aVXwBtBJWxHtkSrWuI4umABCUczs52eiUS9nSiw=
 github.com/hashicorp/go-retryablehttp v0.6.2/go.mod h1:gEx6HMUGxYYhJScX7W1Il64m6cc2C1mDaW3NQ9sY1FY=
-github.com/hashicorp/go-retryablehttp v0.6.6 h1:HJunrbHTDDbBb/ay4kxa1n+dLmttUlnP3V9oNE4hmsM=
-github.com/hashicorp/go-retryablehttp v0.6.6/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
+github.com/hashicorp/go-retryablehttp v0.6.7 h1:8/CAEZt/+F7kR7GevNHulKkUjLht3CPmn7egmhieNKo=
+github.com/hashicorp/go-retryablehttp v0.6.7/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
 github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
 github.com/hashicorp/go-rootcerts v1.0.1 h1:DMo4fmknnz0E0evoNYnV48RjWndOsmd6OW+09R3cEP8=
 github.com/hashicorp/go-rootcerts v1.0.1/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
@@ -929,8 +929,8 @@ github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0Qu
 github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/mapstructure v1.3.1 h1:cCBH2gTD2K0OtLlv/Y5H01VQCqmlDxz30kS5Y5bqfLA=
-github.com/mitchellh/mapstructure v1.3.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/mapstructure v1.3.3 h1:SzB1nHZ2Xi+17FP0zVQBHIZqvwRN9408fJO8h+eeNA8=
+github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/mitchellh/pointerstructure v0.0.0-20190430161007-f252a8fd71c8 h1:1CO5wil3HuiVLrUQ2ovSTO+6AfNOA5EMkHHVyHE9IwA=
 github.com/mitchellh/pointerstructure v0.0.0-20190430161007-f252a8fd71c8/go.mod h1:k4XwG94++jLVsSiTxo7qdIfXA9pj9EAeo0QsNNJOLZ8=
 github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
@@ -1275,6 +1275,8 @@ golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 h1:cg5LA/zNPRzIXIWSCxQW10
 golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM=
+golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -1357,6 +1359,8 @@ golang.org/x/net v0.0.0-20200625001655-4c5254603344 h1:vGXIOMxbNfDTk/aXCmfdLgkrS
 golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20200707034311-ab3426394381 h1:VXak5I6aEWmAXeQjA+QSZzlgNrpq9mjcfDemuexIKsU=
 golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA=
+golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -1449,8 +1453,8 @@ golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxb
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI=
-golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e h1:EHBhcS0mlXEAVwNyO2dLfjToGsyY4j24pTs2ScHnX7s=
+golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
diff --git a/internal/debugger/server.go b/internal/debugger/server.go
index 4fda7b9..20dc40e 100644
--- a/internal/debugger/server.go
+++ b/internal/debugger/server.go
@@ -20,6 +20,7 @@ import (
 	"net/http"
 
 	"github.com/google/exposure-notifications-server/internal/serverenv"
+	"github.com/google/exposure-notifications-server/pkg/server"
 )
 
 // Server is the debugger server.
@@ -55,5 +56,7 @@ func NewServer(config *Config, env *serverenv.ServerEnv) (*Server, error) {
 func (s *Server) Routes(ctx context.Context) *http.ServeMux {
 	mux := http.NewServeMux()
 	mux.HandleFunc("/", s.handleDebug(ctx))
+	mux.Handle("/health", server.HandleHealthz(ctx))
+
 	return mux
 }
diff --git a/internal/export/server.go b/internal/export/server.go
index 2fb35b0..4c51616 100644
--- a/internal/export/server.go
+++ b/internal/export/server.go
@@ -20,6 +20,7 @@ import (
 	"net/http"
 
 	"github.com/google/exposure-notifications-server/internal/serverenv"
+	"github.com/google/exposure-notifications-server/pkg/server"
 )
 
 // NewServer makes a Server.
@@ -56,6 +57,7 @@ func (s *Server) Routes(ctx context.Context) *http.ServeMux {
 
 	mux.HandleFunc("/create-batches", s.handleCreateBatches(ctx))
 	mux.HandleFunc("/do-work", s.handleDoWork(ctx))
+	mux.Handle("/health", server.HandleHealthz(ctx))
 
 	return mux
 }
diff --git a/internal/keyrotation/server.go b/internal/keyrotation/server.go
index d811498..72781d3 100644
--- a/internal/keyrotation/server.go
+++ b/internal/keyrotation/server.go
@@ -23,6 +23,7 @@ import (
 	"github.com/google/exposure-notifications-server/internal/database"
 	revisiondb "github.com/google/exposure-notifications-server/internal/revision/database"
 	"github.com/google/exposure-notifications-server/internal/serverenv"
+	"github.com/google/exposure-notifications-server/pkg/server"
 )
 
 // Server hosts end points to manage key rotation
@@ -66,6 +67,7 @@ func (s *Server) Routes(ctx context.Context) *http.ServeMux {
 	mux := http.NewServeMux()
 
 	mux.HandleFunc("/rotate-keys", s.handleRotateKeys(ctx))
+	mux.Handle("/health", server.HandleHealthz(ctx))
 
 	return mux
 }
diff --git a/pkg/keys/hashicorp_vault.go b/pkg/keys/hashicorp_vault.go
index 907fb78..117518c 100644
--- a/pkg/keys/hashicorp_vault.go
+++ b/pkg/keys/hashicorp_vault.go
@@ -18,20 +18,23 @@ import (
 	"context"
 	"crypto"
 	"crypto/ecdsa"
-	"crypto/x509"
 	"encoding/base64"
-	"encoding/pem"
 	"fmt"
 	"io"
+	"sort"
+	"strconv"
 	"strings"
+	"time"
 
 	"github.com/google/exposure-notifications-server/pkg/base64util"
+	"github.com/mitchellh/mapstructure"
 
 	vaultapi "github.com/hashicorp/vault/api"
 )
 
 // Compile-time check to verify implements interface.
 var _ KeyManager = (*HashiCorpVault)(nil)
+var _ SigningKeyManager = (*HashiCorpVault)(nil)
 var _ crypto.Signer = (*HashiCorpVaultSigner)(nil)
 
 // HashiCorpVault implements the keys.KeyManager interface and can be used to
@@ -72,6 +75,146 @@ func NewHCValueKeyID(keyID string) (*HCValueKeyID, error) {
 	}
 }
 
+type readKeyResponse struct {
+	Versions map[string]struct {
+		CreationTime time.Time `ms:"creation_time"`
+		PublicKeyPEM string    `ms:"public_key"`
+	} `ms:"keys"`
+}
+
+var _ SigningKeyVersion = (*vaultKeyVersion)(nil)
+
+type vaultKeyVersion struct {
+	client    *vaultapi.Client
+	name      string
+	version   int64
+	createdAt time.Time
+	publicKey crypto.PublicKey
+}
+
+func (v *vaultKeyVersion) KeyID() string {
+	return fmt.Sprintf("%s/%d", v.name, v.version)
+}
+
+func (v *vaultKeyVersion) CreatedAt() time.Time {
+	return v.createdAt.UTC()
+}
+
+func (v *vaultKeyVersion) DestroyedAt() time.Time {
+	return time.Time{}
+}
+
+func (v *vaultKeyVersion) Signer(ctx context.Context) (crypto.Signer, error) {
+	return &HashiCorpVaultSigner{
+		client:    v.client,
+		name:      v.name,
+		version:   strconv.FormatInt(v.version, 10),
+		publicKey: v.publicKey,
+	}, nil
+}
+
+// SigningKeyVersions returns the signing keys for the given key name.
+func (v *HashiCorpVault) SigningKeyVersions(ctx context.Context, parent string) ([]SigningKeyVersion, error) {
+	var versions []SigningKeyVersion
+
+	pth := fmt.Sprintf("transit/keys/%s", parent)
+	result, err := v.client.Logical().Read(pth)
+	if err != nil {
+		return nil, fmt.Errorf("unable to read key: %w", err)
+	}
+	if result == nil || result.Data == nil {
+		return nil, fmt.Errorf("key does not exist")
+	}
+
+	var response readKeyResponse
+	dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
+		DecodeHook:       mapstructure.StringToTimeHookFunc(time.RFC3339),
+		WeaklyTypedInput: true,
+		Result:           &response,
+		TagName:          "ms",
+	})
+	if err != nil {
+		return nil, fmt.Errorf("failed to setup decoder: %w", err)
+	}
+	if err := dec.Decode(result.Data); err != nil {
+		return nil, fmt.Errorf("failed to decode result: %w", err)
+	}
+
+	for k, ver := range response.Versions {
+		publicKey, err := parsePublicKeyPEM(ver.PublicKeyPEM)
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse public key for %s/%s", k, ver)
+		}
+
+		num, err := strconv.ParseInt(k, 10, 64)
+		if err != nil {
+			return nil, fmt.Errorf("invalid key key %q: %w", k, err)
+		}
+
+		versions = append(versions, &vaultKeyVersion{
+			client:    v.client,
+			name:      parent,
+			version:   num,
+			createdAt: ver.CreationTime,
+			publicKey: publicKey,
+		})
+	}
+
+	// Sort versions - the results come in as a map, so order is undefined.
+	sort.Slice(versions, func(i, j int) bool {
+		x := versions[i].(*vaultKeyVersion).version
+		y := versions[j].(*vaultKeyVersion).version
+		return x < y
+	})
+
+	return versions, nil
+}
+
+// CreateSigningKey creates a new signing key with the given name.
+func (v *HashiCorpVault) CreateSigningKey(ctx context.Context, parent, name string) (string, error) {
+	id := strings.Trim(strings.Join([]string{parent, name}, "/"), "/")
+	pth := fmt.Sprintf("/transit/keys/%s", id)
+	if _, err := v.client.Logical().Write(pth, map[string]interface{}{
+		"name": name,
+		"type": "ecdsa-p256",
+	}); err != nil {
+		return "", fmt.Errorf("failed to create signing key: %w", err)
+	}
+
+	return id, nil
+}
+
+// CreateKeyVersion rotates the given key.
+func (v *HashiCorpVault) CreateKeyVersion(ctx context.Context, parent string) (string, error) {
+	pth := fmt.Sprintf("/transit/keys/%s/rotate", parent)
+	if _, err := v.client.Logical().Write(pth, nil); err != nil {
+		return "", fmt.Errorf("failed to rotate signing key: %w", err)
+	}
+
+	// WARNING: race condition. Since Vault's API does not return information
+	// about the key that was just rotated, we have to READ the API to get the
+	// latest version. If another version was created between ours and now, we'd
+	// pick up that new version.
+	//
+	// To put it another way, there's no guarantee that the last element in this
+	// result is the element we just created.
+	list, err := v.SigningKeyVersions(ctx, parent)
+	if err != nil {
+		return "", fmt.Errorf("failed to lookup signing keys: %w", err)
+	}
+
+	if len(list) < 1 {
+		return "", fmt.Errorf("no signing keys")
+	}
+	return list[len(list)-1].KeyID(), nil
+}
+
+// DestroyKeyVersion is unimplemented on Vault. Vault can only trim keys up to a
+// point (which might be unsafe).
+func (v *HashiCorpVault) DestroyKeyVersion(ctx context.Context, id string) error {
+	return fmt.Errorf("vault does not support destroying a key version")
+}
+
 // NewSigner creates a new signer that uses a key in HashiCorp Vault's transit
 // backend. The keyID is in the format:
 //
@@ -267,24 +410,19 @@ func (s *HashiCorpVaultSigner) getPublicKey() (crypto.PublicKey, error) {
 		return nil, fmt.Errorf("not in the correct format: %T", keyRaw)
 	}
 
-	publicKeyPemRaw, ok := keyTyped["public_key"]
+	publicKeyPEMRaw, ok := keyTyped["public_key"]
 	if !ok {
 		return nil, fmt.Errorf("no public_key field")
 	}
 
-	publicKeyPem, ok := publicKeyPemRaw.(string)
+	publicKeyPEM, ok := publicKeyPEMRaw.(string)
 	if !ok {
-		return nil, fmt.Errorf("invalid public_key type %T", publicKeyPemRaw)
+		return nil, fmt.Errorf("invalid public_key type %T", publicKeyPEMRaw)
 	}
 
-	block, _ := pem.Decode([]byte(publicKeyPem))
-	if block == nil {
-		return nil, fmt.Errorf("pem is invalid")
-	}
-
-	publicKey, err := x509.ParsePKIXPublicKey(block.Bytes)
+	publicKey, err := parsePublicKeyPEM(publicKeyPEM)
 	if err != nil {
-		return nil, fmt.Errorf("failed to parse public key pem: %w", err)
+		return nil, err
 	}
 
 	typed, ok := publicKey.(*ecdsa.PublicKey)
diff --git a/pkg/keys/keys.go b/pkg/keys/keys.go
index 717907f..db44c46 100644
--- a/pkg/keys/keys.go
+++ b/pkg/keys/keys.go
@@ -24,6 +24,8 @@ import (
 	"context"
 	"crypto"
 	"crypto/ecdsa"
+	"crypto/x509"
+	"encoding/pem"
 	"fmt"
 	"time"
 )
@@ -116,3 +118,18 @@ func KeyManagerFor(ctx context.Context, config *Config) (KeyManager, error) {
 
 	return nil, fmt.Errorf("unknown key manager type: %v", typ)
 }
+
+// parsePublicKeyPEM parses the public key in PEM-encoded format into a public
+// key.
+func parsePublicKeyPEM(s string) (interface{}, error) {
+	block, _ := pem.Decode([]byte(s))
+	if block == nil {
+		return nil, fmt.Errorf("pem is invalid")
+	}
+
+	key, err := x509.ParsePKIXPublicKey(block.Bytes)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse public key PEM: %w", err)
+	}
+	return key, nil
+}
diff --git a/pkg/server/healthz.go b/pkg/server/healthz.go
new file mode 100644
index 0000000..7997f23
--- /dev/null
+++ b/pkg/server/healthz.go
@@ -0,0 +1,27 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+)
+
+func HandleHealthz(hctx context.Context) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprintf(w, `{"status": "ok"}`)
+	})
+}


Test patch:
diff --git a/pkg/keys/hashicorp_vault_test.go b/pkg/keys/hashicorp_vault_test.go
index 8f9d7df..5bc6af1 100644
--- a/pkg/keys/hashicorp_vault_test.go
+++ b/pkg/keys/hashicorp_vault_test.go
@@ -62,6 +62,8 @@ func TestNewHashiCorpVaultSigner(t *testing.T) {
 		tc := tc
 
 		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
 			ctx := context.Background()
 			_, err := NewHashiCorpVaultSigner(ctx, tc.client, tc.keyName, tc.keyVersion)
 			if err == nil {
@@ -71,7 +73,249 @@ func TestNewHashiCorpVaultSigner(t *testing.T) {
 	}
 }
 
+func TestHashiCorpVault_SigningKeyVersions(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name  string
+		setup func(client *vaultapi.Client) error
+		size  int
+	}{
+		{
+			name: "lists",
+			setup: func(client *vaultapi.Client) error {
+				// Create a key.
+				if _, err := client.Logical().Write("transit/keys/my-key", map[string]interface{}{
+					"type": "ecdsa-p256",
+				}); err != nil {
+					return fmt.Errorf("failed to create key: %w", err)
+				}
+				return nil
+			},
+			size: 1,
+		},
+		{
+			name: "lists_many",
+			setup: func(client *vaultapi.Client) error {
+				// Create a key.
+				if _, err := client.Logical().Write("transit/keys/my-key", map[string]interface{}{
+					"type": "ecdsa-p256",
+				}); err != nil {
+					return fmt.Errorf("failed to create key: %w", err)
+				}
+
+				for i := 0; i < 5; i++ {
+					if _, err := client.Logical().Write("transit/keys/my-key/rotate", nil); err != nil {
+						return fmt.Errorf("failed to rotate key: %w", err)
+					}
+				}
+
+				return nil
+			},
+			size: 6,
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			// Create a Vault server.
+			ctx := context.Background()
+			core, _, token := vault.TestCoreUnsealedWithConfig(t, &vault.CoreConfig{
+				DisableMlock: true,
+				DisableCache: true,
+				Logger:       vaultlog.NewNullLogger(),
+				LogicalBackends: map[string]vaultlogical.Factory{
+					"transit": vaulttransit.Factory,
+				},
+			})
+			ln, addr := vaulthttp.TestServer(t, core)
+			defer ln.Close()
+
+			// Create the client.
+			client, err := vaultapi.NewClient(&vaultapi.Config{Address: addr})
+			if err != nil {
+				t.Fatal(err)
+			}
+			client.SetToken(token)
+
+			// Enable transit engine.
+			if _, err := client.Logical().Write("sys/mounts/transit", map[string]interface{}{
+				"type": "transit",
+			}); err != nil {
+				t.Fatal(err)
+			}
+
+			// Run setup.
+			if err := tc.setup(client); err != nil {
+				t.Fatal(err)
+			}
+
+			// Wrap client.
+			vault := &HashiCorpVault{client}
+
+			// List the versions.
+			list, err := vault.SigningKeyVersions(ctx, "my-key")
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			if got, want := len(list), tc.size; got != want {
+				t.Errorf("expected list to have %d keys (was %d)", want, got)
+			}
+
+			first := list[0]
+
+			if got, want := first.KeyID(), "my-key/1"; got != want {
+				t.Errorf("expected %#v to be %#v", got, want)
+			}
+		})
+	}
+}
+
+func TestHashiCorpVault_CreateSigningKey(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name string
+	}{
+		{
+			name: "creates",
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			// Create a Vault server.
+			ctx := context.Background()
+			core, _, token := vault.TestCoreUnsealedWithConfig(t, &vault.CoreConfig{
+				DisableMlock: true,
+				DisableCache: true,
+				Logger:       vaultlog.NewNullLogger(),
+				LogicalBackends: map[string]vaultlogical.Factory{
+					"transit": vaulttransit.Factory,
+				},
+			})
+			ln, addr := vaulthttp.TestServer(t, core)
+			defer ln.Close()
+
+			// Create the client.
+			client, err := vaultapi.NewClient(&vaultapi.Config{Address: addr})
+			if err != nil {
+				t.Fatal(err)
+			}
+			client.SetToken(token)
+
+			// Enable transit engine.
+			if _, err := client.Logical().Write("sys/mounts/transit", map[string]interface{}{
+				"type": "transit",
+			}); err != nil {
+				t.Fatal(err)
+			}
+
+			// Wrap client.
+			vault := &HashiCorpVault{client}
+
+			// Create a new version.
+			id, err := vault.CreateSigningKey(ctx, "", "my-key")
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			// Expect the new version
+			if got, want := id, "my-key"; got != want {
+				t.Errorf("expected %#v to be %#v", got, want)
+			}
+		})
+	}
+}
+
+func TestHashiCorpVault_CreateKeyVersion(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name  string
+		setup func(client *vaultapi.Client) error
+	}{
+		{
+			name: "creates",
+			setup: func(client *vaultapi.Client) error {
+				// Create a key.
+				if _, err := client.Logical().Write("transit/keys/my-key", map[string]interface{}{
+					"type": "ecdsa-p256",
+				}); err != nil {
+					return fmt.Errorf("failed to create key: %w", err)
+				}
+				return nil
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			// Create a Vault server.
+			ctx := context.Background()
+			core, _, token := vault.TestCoreUnsealedWithConfig(t, &vault.CoreConfig{
+				DisableMlock: true,
+				DisableCache: true,
+				Logger:       vaultlog.NewNullLogger(),
+				LogicalBackends: map[string]vaultlogical.Factory{
+					"transit": vaulttransit.Factory,
+				},
+			})
+			ln, addr := vaulthttp.TestServer(t, core)
+			defer ln.Close()
+
+			// Create the client.
+			client, err := vaultapi.NewClient(&vaultapi.Config{Address: addr})
+			if err != nil {
+				t.Fatal(err)
+			}
+			client.SetToken(token)
+
+			// Enable transit engine.
+			if _, err := client.Logical().Write("sys/mounts/transit", map[string]interface{}{
+				"type": "transit",
+			}); err != nil {
+				t.Fatal(err)
+			}
+
+			// Run setup.
+			if err := tc.setup(client); err != nil {
+				t.Fatal(err)
+			}
+
+			// Wrap client.
+			vault := &HashiCorpVault{client}
+
+			// Create a new version.
+			id, err := vault.CreateKeyVersion(ctx, "my-key")
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			// Expect the new version
+			if got, want := id, "my-key/2"; got != want {
+				t.Errorf("expected %#v to be %#v", got, want)
+			}
+		})
+	}
+}
+
 func TestHashiCorpVaultEncryptDecrypt(t *testing.T) {
+	t.Parallel()
+
 	setupFn := func(client *vaultapi.Client) error {
 		if _, err := client.Logical().Write("transit/keys/my-key", map[string]interface{}{
 			"type": "aes256-gcm96",
@@ -110,7 +354,11 @@ func TestHashiCorpVaultEncryptDecrypt(t *testing.T) {
 	}
 
 	for _, tc := range cases {
+		tc := tc
+
 		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
 			// Create a Vault server.
 			ctx := context.Background()
 			core, _, token := vault.TestCoreUnsealedWithConfig(t, &vault.CoreConfig{
@@ -164,6 +412,8 @@ func TestHashiCorpVaultEncryptDecrypt(t *testing.T) {
 }
 
 func TestHashiCorpVaultSigner_Public(t *testing.T) {
+	t.Parallel()
+
 	cases := []struct {
 		name       string
 		setup      func(client *vaultapi.Client) error
@@ -232,6 +482,8 @@ func TestHashiCorpVaultSigner_Public(t *testing.T) {
 		tc := tc
 
 		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
 			// Create a Vault server.
 			ctx := context.Background()
 			core, _, token := vault.TestCoreUnsealedWithConfig(t, &vault.CoreConfig{
@@ -287,6 +539,8 @@ func TestHashiCorpVaultSigner_Public(t *testing.T) {
 }
 
 func TestHashiCorpVaultSigner_Sign(t *testing.T) {
+	t.Parallel()
+
 	// Create a Vault server.
 	ctx := context.Background()
 	core, _, token := vault.TestCoreUnsealedWithConfig(t, &vault.CoreConfig{