Repository: google/exposure-notifications-server
Pull request #958 (2020-09-08 03:56:25)

Update healthauthority.html


<!-- Please include the 'why' behind your changes if no issue exists -->
## Proposed Changes

* Correcting spell mistake in AudienceBlock input notes as "Value"

**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
NONE
```

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

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/958#" title="Author self-approved">raghvenders</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **sethvargo**
You can assign the PR to them by writing `/assign @sethvargo` in a comment when ready.

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

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

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

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":["sethvargo"]} -->
google-cla[bot]: 
Thanks for your pull request. It looks like this may be your first contribution to a Google open source project (if not, look below for help). Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

:memo: **Please visit <https://cla.developers.google.com/> to sign.**

Once you've signed (or fixed any issues), please reply here with `@googlebot I signed it!` and we'll verify it.

----

#### What to do if you already signed the CLA

##### Individual signers

*   It's possible we don't have your GitHub username or you're using a different email address on your commit. Check [your existing CLA data](https://cla.developers.google.com/clas) and verify that your [email is set on your git commits](https://help.github.com/articles/setting-your-email-in-git/).

##### Corporate signers

*   Your company has a Point of Contact who decides which employees are authorized to participate. Ask your POC to be added to the group of authorized contributors. If you don't know who your Point of Contact is, direct the Google project maintainer to [go/cla#troubleshoot](http://go/cla#troubleshoot) ([Public version](https://opensource.google/docs/cla/#troubleshoot)).
*   The email used to register you as an authorized contributor must be the email used for the Git commit. Check [your existing CLA data](https://cla.developers.google.com/clas) and verify that your [email is set on your git commits](https://help.github.com/articles/setting-your-email-in-git/).
*   The email used to register you as an authorized contributor must also be [attached to your GitHub account](https://github.com/settings/emails).
		

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2Fgoogle%2Fexposure-notifications-server%2Fpull%2F958) for more info**.

<!-- need_sender_cla -->
google-oss-robot: Hi @raghvenders. Thanks for your PR.

I'm waiting for a [google](https://github.com/orgs/google/people) member to verify that this patch is reasonable to test. If it is, they should reply with `/ok-to-test` on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work. Regular contributors should [join the org](https://github.com/orgs/google/people) to skip this step.

Once the patch is verified, the new status will be reflected by the `ok-to-test` label.

I understand the commands that are listed [here](https://go.k8s.io/bot-commands?repo=google%2Fexposure-notifications-server).

<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.
</details>

raghvenders: /assign @sethvargo
mikehelmick: /ok-to-test
/lgtm
googlebot: 
Thanks for your pull request. It looks like this may be your first contribution to a Google open source project (if not, look below for help). Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

:memo: **Please visit <https://cla.developers.google.com/> to sign.**

Once you've signed (or fixed any issues), please reply here with `@googlebot I signed it!` and we'll verify it.

----

#### What to do if you already signed the CLA

##### Individual signers

*   It's possible we don't have your GitHub username or you're using a different email address on your commit. Check [your existing CLA data](https://cla.developers.google.com/clas) and verify that your [email is set on your git commits](https://help.github.com/articles/setting-your-email-in-git/).

##### Corporate signers

*   Your company has a Point of Contact who decides which employees are authorized to participate. Ask your POC to be added to the group of authorized contributors. If you don't know who your Point of Contact is, direct the Google project maintainer to [go/cla#troubleshoot](http://go/cla#troubleshoot) ([Public version](https://opensource.google/docs/cla/#troubleshoot)).
*   The email used to register you as an authorized contributor must be the email used for the Git commit. Check [your existing CLA data](https://cla.developers.google.com/clas) and verify that your [email is set on your git commits](https://help.github.com/articles/setting-your-email-in-git/).
*   The email used to register you as an authorized contributor must also be [attached to your GitHub account](https://github.com/settings/emails).
		

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2Fgoogle%2Fexposure-notifications-server%2Fpull%2F958) for more info**.

<!-- need_sender_cla -->
sethvargo: Hey @raghvenders - this looks great, but can you please sign the CLA? We can't merge contributions without a CLA.

Patch:
diff --git a/internal/integration/helpers.go b/internal/integration/helpers.go
index 6b607bb..a16cd49 100644
--- a/internal/integration/helpers.go
+++ b/internal/integration/helpers.go
@@ -95,18 +95,11 @@ func NewTestServer(tb testing.TB, exportPeriod time.Duration) (*serverenv.Server
 		}
 	}
 
-	km, err := keys.NewInMemory(ctx)
-	if err != nil {
-		tb.Fatal(err)
-	}
-	if _, err := km.CreateEncryptionKey("tokenkey"); err != nil {
-		tb.Fatal(err)
-	}
-	if _, err := km.CreateSigningKey(ctx, "signing", "signingkey"); err != nil {
-		tb.Fatal(err)
-	}
+	kms := keys.TestKeyManager(tb)
+	tokenKey := keys.TestEncryptionKey(tb, kms)
+
 	// create an initial revision key.
-	revisionDB, err := revdb.New(db, &revdb.KMSConfig{WrapperKeyID: "tokenkey", KeyManager: km})
+	revisionDB, err := revdb.New(db, &revdb.KMSConfig{WrapperKeyID: tokenKey, KeyManager: kms})
 	if err != nil {
 		tb.Fatal(err)
 	}
@@ -144,7 +137,7 @@ func NewTestServer(tb testing.TB, exportPeriod time.Duration) (*serverenv.Server
 		serverenv.WithAuthorizedAppProvider(aap),
 		serverenv.WithBlobStorage(bs),
 		serverenv.WithDatabase(db),
-		serverenv.WithKeyManager(km),
+		serverenv.WithKeyManager(kms),
 		serverenv.WithSecretManager(sm),
 	)
 	// Note: don't call env.Cleanup() because the database helper closes the
@@ -208,7 +201,7 @@ func NewTestServer(tb testing.TB, exportPeriod time.Duration) (*serverenv.Server
 	// TODO: this is a grpc listener and requires a lot of setup.
 
 	revConfig := revision.Config{
-		KeyID:     "tokenkey",
+		KeyID:     tokenKey,
 		AAD:       []byte{1, 2, 3},
 		MinLength: 28,
 	}
diff --git a/pkg/keys/config.go b/pkg/keys/config.go
index 57d379b..1d4f46a 100644
--- a/pkg/keys/config.go
+++ b/pkg/keys/config.go
@@ -22,7 +22,7 @@ const (
 	KeyManagerTypeAzureKeyVault  KeyManagerType = "AZURE_KEY_VAULT"
 	KeyManagerTypeGoogleCloudKMS KeyManagerType = "GOOGLE_CLOUD_KMS"
 	KeyManagerTypeHashiCorpVault KeyManagerType = "HASHICORP_VAULT"
-	KeyManagerTypeInMemory       KeyManagerType = "IN_MEMORY"
+	KeyManagerTypeFilesystem     KeyManagerType = "FILESYSTEM"
 )
 
 // Config defines configuration.
@@ -34,4 +34,7 @@ type Config struct {
 	// Adherence to this config setting is optional and based
 	// upon the key manager implementation and underlying capabilities.
 	CreateHSMKeys bool `env:"CREATE_HSM_KEYS, default=true"`
+
+	// FilesystemRoot is the root path where keys are managed on the filesystem.
+	FilesystemRoot string `env:"KEY_FILESYSTEM_ROOT"`
 }
diff --git a/pkg/keys/filesystem.go b/pkg/keys/filesystem.go
new file mode 100644
index 0000000..adc894c
--- /dev/null
+++ b/pkg/keys/filesystem.go
@@ -0,0 +1,418 @@
+// 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 keys
+
+import (
+	"bytes"
+	"context"
+	"crypto"
+	"crypto/aes"
+	"crypto/cipher"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/x509"
+	"encoding/json"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"sort"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+)
+
+var _ EncryptionKeyManager = (*Filesystem)(nil)
+var _ KeyManager = (*Filesystem)(nil)
+var _ SigningKeyManager = (*Filesystem)(nil)
+
+// Filesystem is a key manager that uses the filesystem to store and retrieve
+// keys. It should only be used for local development and testing.
+type Filesystem struct {
+	root string
+	mu   sync.RWMutex
+}
+
+// NewFilesystem creates a new KeyManager backed by the local filesystem. It
+// should only be used for development and testing.
+//
+// If root is provided and does not exist, it will be created. If root is a
+// relative path, it's relative to where the process is currently executing. If
+// root is not supplied, all data is dumped in the current working directory.
+//
+// In general, root should either be a hardcoded path like $(pwd)/local or a
+// temporary directory like os.TempDir().
+func NewFilesystem(ctx context.Context, root string) (*Filesystem, error) {
+	if root != "" {
+		if err := os.MkdirAll(root, 0700); err != nil {
+			return nil, err
+		}
+	}
+
+	return &Filesystem{
+		root: root,
+	}, nil
+}
+
+// NewSigner creates a new signer from the given key. If the key does not exist,
+// it returns an error. If the key is not a signing key, it returns an error.
+func (k *Filesystem) NewSigner(ctx context.Context, keyID string) (crypto.Signer, error) {
+	k.mu.RLock()
+	defer k.mu.RUnlock()
+
+	pth := filepath.Join(k.root, keyID)
+	b, err := ioutil.ReadFile(pth)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read signing key: %w", err)
+	}
+
+	pk, err := x509.ParseECPrivateKey(b)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse signing key: %w", err)
+	}
+
+	return pk, nil
+}
+
+// Encrypt encrypts the given plaintext and aad with the key. If the key does
+// not exist, it returns an error.
+func (k *Filesystem) Encrypt(ctx context.Context, keyID string, plaintext []byte, aad []byte) ([]byte, error) {
+	k.mu.RLock()
+	defer k.mu.RUnlock()
+
+	// Find the most recent DEK - that's what we'll use for encryption
+	pth := filepath.Join(k.root, keyID)
+	infos, err := ioutil.ReadDir(pth)
+	if err != nil {
+		return nil, fmt.Errorf("failed to list keys: %w", err)
+	}
+	if len(infos) < 1 {
+		return nil, fmt.Errorf("there are no key versions")
+	}
+	var latest os.FileInfo
+	for _, info := range infos {
+		if info.Name() == "metadata" {
+			continue
+		}
+		if latest == nil {
+			latest = info
+			continue
+		}
+		if info.Name() > latest.Name() {
+			latest = info
+		}
+	}
+	if latest == nil {
+		return nil, fmt.Errorf("key %q does not exist", keyID)
+	}
+
+	latestPath := filepath.Join(pth, latest.Name())
+	dek, err := ioutil.ReadFile(latestPath)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read encryption key: %w", err)
+	}
+
+	block, err := aes.NewCipher(dek)
+	if err != nil {
+		return nil, fmt.Errorf("bad cipher block: %w", err)
+	}
+	aesgcm, err := cipher.NewGCM(block)
+	if err != nil {
+		return nil, fmt.Errorf("failed to wrap cipher block: %w", err)
+	}
+	nonce := make([]byte, aesgcm.NonceSize())
+	if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+		return nil, fmt.Errorf("failed to generate nonce: %w", err)
+	}
+	ciphertext := aesgcm.Seal(nonce, nonce, plaintext, aad)
+
+	// Append the keyID to the ciphertext so we know which key to use to decrypt.
+	id := []byte(latest.Name() + ":")
+	ciphertext = append(id, ciphertext...)
+
+	return ciphertext, nil
+}
+
+// Decrypt decrypts the ciphertext. It returns an error if decryption fails or
+// if the key does not exist.
+func (k *Filesystem) Decrypt(ctx context.Context, keyID string, ciphertext []byte, aad []byte) ([]byte, error) {
+	k.mu.RLock()
+	defer k.mu.RUnlock()
+
+	// Figure out which DEK to use
+	parts := bytes.SplitN(ciphertext, []byte(":"), 2)
+	if len(parts) < 2 {
+		return nil, fmt.Errorf("invalid ciphertext: missing version")
+	}
+	version, ciphertext := parts[0], parts[1]
+
+	versionPath := filepath.Join(k.root, keyID, string(version))
+	dek, err := ioutil.ReadFile(versionPath)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read encryption key: %w", err)
+	}
+
+	block, err := aes.NewCipher(dek)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create cipher from dek: %w", err)
+	}
+
+	aesgcm, err := cipher.NewGCM(block)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create gcm from dek: %w", err)
+	}
+
+	size := aesgcm.NonceSize()
+	if len(ciphertext) < size {
+		return nil, fmt.Errorf("malformed ciphertext")
+	}
+	nonce, ciphertextPortion := ciphertext[:size], ciphertext[size:]
+
+	plaintext, err := aesgcm.Open(nil, nonce, ciphertextPortion, aad)
+	if err != nil {
+		return nil, fmt.Errorf("failed to decrypt ciphertext with dek: %w", err)
+	}
+
+	return plaintext, nil
+}
+
+// SigningKeyVersions lists all the versions for the given parent. If the
+// provided parent is not a signing key, it returns an error.
+func (k *Filesystem) SigningKeyVersions(ctx context.Context, parent string) ([]SigningKeyVersion, error) {
+	k.mu.RLock()
+	defer k.mu.RUnlock()
+
+	metadata, err := k.metadataForKey(parent)
+	if err != nil {
+		return nil, fmt.Errorf("failed to list signing keys: %w", err)
+	}
+	if metadata.KeyType != "signing" {
+		return nil, fmt.Errorf("failed to list signing keys: key is not a signing key type")
+	}
+
+	pth := filepath.Join(k.root, parent)
+	var versions []SigningKeyVersion
+	if err := filepath.Walk(pth, func(curr string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+
+		if info.IsDir() {
+			return nil
+		}
+
+		if info.Name() == "metadata" {
+			return nil
+		}
+
+		b, err := ioutil.ReadFile(curr)
+		if err != nil {
+			return err
+		}
+
+		pk, err := x509.ParseECPrivateKey(b)
+		if err != nil {
+			return fmt.Errorf("failed to parse signing key: %w", err)
+		}
+
+		versions = append(versions, &filesystemSigningKey{
+			name:    strings.TrimPrefix(curr, k.root),
+			created: info.ModTime(),
+			pk:      pk,
+		})
+
+		return nil
+	}); err != nil {
+		return nil, fmt.Errorf("failed to list keys: failed to walk: %w", err)
+	}
+
+	// Sort keys descending so the newest is first
+	sort.Slice(versions, func(i, j int) bool {
+		a := versions[i].(*filesystemSigningKey).name
+		b := versions[j].(*filesystemSigningKey).name
+		return a > b
+	})
+
+	return versions, nil
+}
+
+// CreateSigningKey creates a signing key. For this implementation, that means
+// it creates a folder on disk (but no keys inside). If the folder already
+// exists, it returns its name.
+func (k *Filesystem) CreateSigningKey(_ context.Context, parent, name string) (string, error) {
+	k.mu.Lock()
+	defer k.mu.Unlock()
+
+	pth := filepath.Join(k.root, parent, name)
+	if err := os.MkdirAll(pth, 0700); err != nil {
+		return "", fmt.Errorf("failed to create directory for key: %w", err)
+	}
+
+	metadataPath := filepath.Join(pth, "metadata")
+	b, err := ioutil.ReadFile(metadataPath)
+	if err != nil && !os.IsNotExist(err) {
+		return "", fmt.Errorf("failed to read metadata file: %w", err)
+	}
+	if len(b) > 0 {
+		var metadata filesystemKeyInfo
+		if err := json.Unmarshal(b, &metadata); err != nil {
+			return "", fmt.Errorf("failed to parse metadata: %w", err)
+		}
+		if metadata.KeyType != "signing" {
+			return "", fmt.Errorf("found key, but is not signing type")
+		}
+		return strings.TrimPrefix(pth, k.root), nil
+	}
+
+	// If we got this far, the metadata file does not exist, so create it.
+	metadata := &filesystemKeyInfo{KeyType: "signing"}
+	b, err = json.Marshal(metadata)
+	if err != nil {
+		return "", fmt.Errorf("failed to generate metadata file: %w", err)
+	}
+	if err := ioutil.WriteFile(metadataPath, b, 0600); err != nil {
+		return "", fmt.Errorf("failed to create metadata file: %w", err)
+	}
+	return strings.TrimPrefix(pth, k.root), nil
+}
+
+// CreateEncryptionKey creates an encryption key. For this implementation, that
+// means it creates a folder on disk (but no keys inside). If the folder already
+// exists, it returns its name.
+func (k *Filesystem) CreateEncryptionKey(_ context.Context, parent, name string) (string, error) {
+	k.mu.Lock()
+	defer k.mu.Unlock()
+
+	pth := filepath.Join(k.root, parent, name)
+	if err := os.MkdirAll(pth, 0700); err != nil {
+		return "", fmt.Errorf("failed to create directory for key: %w", err)
+	}
+
+	metadataPath := filepath.Join(pth, "metadata")
+	b, err := ioutil.ReadFile(metadataPath)
+	if err != nil && !os.IsNotExist(err) {
+		return "", fmt.Errorf("failed to read metadata file: %w", err)
+	}
+	if len(b) > 0 {
+		var metadata filesystemKeyInfo
+		if err := json.Unmarshal(b, &metadata); err != nil {
+			return "", fmt.Errorf("failed to parse metadata: %w", err)
+		}
+		if metadata.KeyType != "encryption" {
+			return "", fmt.Errorf("found key, but is not encryption type")
+		}
+		return strings.TrimPrefix(pth, k.root), nil
+	}
+
+	// If we got this far, the metadata file does not exist, so create it.
+	metadata := &filesystemKeyInfo{KeyType: "encryption"}
+	b, err = json.Marshal(metadata)
+	if err != nil {
+		return "", fmt.Errorf("failed to generate metadata file: %w", err)
+	}
+	if err := ioutil.WriteFile(metadataPath, b, 0600); err != nil {
+		return "", fmt.Errorf("failed to create metadata file: %w", err)
+	}
+	return strings.TrimPrefix(pth, k.root), nil
+}
+
+// CreateKeyVersion creates a new key version for the parent. If the parent is a
+// signing key, it creates a signing key. If the parent is an encryption key, it
+// creates an encryption key. If the parent does not exist, it returns an error.
+func (k *Filesystem) CreateKeyVersion(_ context.Context, parent string) (string, error) {
+	k.mu.Lock()
+	defer k.mu.Unlock()
+
+	metadata, err := k.metadataForKey(parent)
+	if err != nil {
+		return "", fmt.Errorf("failed to create key version: %w", err)
+	}
+
+	switch t := metadata.KeyType; t {
+	case "signing":
+		pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+		if err != nil {
+			return "", fmt.Errorf("failed to generate signing key: %w", err)
+		}
+		b, err := x509.MarshalECPrivateKey(pk)
+		if err != nil {
+			return "", fmt.Errorf("failed to marshal signing key: %w", err)
+		}
+		pth := filepath.Join(k.root, parent, strconv.FormatInt(time.Now().UnixNano(), 10))
+		if err := ioutil.WriteFile(pth, b, 0600); err != nil {
+			return "", fmt.Errorf("failed to write signing key to disk: %w", err)
+		}
+		return strings.TrimPrefix(pth, k.root), nil
+	case "encryption":
+		ek := make([]byte, 32)
+		if _, err := io.ReadFull(rand.Reader, ek); err != nil {
+			return "", fmt.Errorf("failed to generate encryption key: %w", err)
+		}
+		pth := filepath.Join(k.root, parent, strconv.FormatInt(time.Now().UnixNano(), 10))
+		if err := ioutil.WriteFile(pth, ek, 0600); err != nil {
+			return "", fmt.Errorf("failed to write encryption key to disk: %w", err)
+		}
+		return strings.TrimPrefix(pth, k.root), nil
+	default:
+		return "", fmt.Errorf("unknown key type %q", t)
+	}
+}
+
+// DestroyKeyVersion destroys the given key version. It does nothing if the key
+// does not exist.
+func (k *Filesystem) DestroyKeyVersion(_ context.Context, id string) error {
+	k.mu.Lock()
+	defer k.mu.Unlock()
+
+	pth := filepath.Join(k.root, id)
+	if err := os.Remove(pth); err != nil && !os.IsNotExist(err) {
+		return fmt.Errorf("failed to destroy key version: %w", err)
+	}
+	return nil
+}
+
+type filesystemSigningKey struct {
+	name    string
+	created time.Time
+	pk      *ecdsa.PrivateKey
+}
+
+func (k *filesystemSigningKey) KeyID() string          { return k.name }
+func (k *filesystemSigningKey) CreatedAt() time.Time   { return k.created }
+func (k *filesystemSigningKey) DestroyedAt() time.Time { return time.Time{} }
+func (k *filesystemSigningKey) Signer(_ context.Context) (crypto.Signer, error) {
+	return k.pk, nil
+}
+
+type filesystemKeyInfo struct {
+	KeyType string `json:"t"`
+}
+
+func (k *Filesystem) metadataForKey(parent string) (*filesystemKeyInfo, error) {
+	metadataPath := filepath.Join(k.root, parent, "metadata")
+	b, err := ioutil.ReadFile(metadataPath)
+	if err != nil {
+		return nil, fmt.Errorf("failed to open metadata (does the key exist?): %w", err)
+	}
+
+	var metadata filesystemKeyInfo
+	if err := json.Unmarshal(b, &metadata); err != nil {
+		return nil, fmt.Errorf("failed to parse metadata: %w", err)
+	}
+	return &metadata, nil
+}
diff --git a/pkg/keys/google_cloud_kms.go b/pkg/keys/google_cloud_kms.go
index cfb1590..e0e33b6 100644
--- a/pkg/keys/google_cloud_kms.go
+++ b/pkg/keys/google_cloud_kms.go
@@ -46,18 +46,9 @@ type CloudKMSSigningKeyVersion struct {
 	keyManager  *GoogleCloudKMS
 }
 
-func (k *CloudKMSSigningKeyVersion) KeyID() string {
-	return k.keyID
-}
-
-func (k *CloudKMSSigningKeyVersion) CreatedAt() time.Time {
-	return k.createdAt
-}
-
-func (k *CloudKMSSigningKeyVersion) DestroyedAt() time.Time {
-	return k.destroyedAt
-}
-
+func (k *CloudKMSSigningKeyVersion) KeyID() string          { return k.keyID }
+func (k *CloudKMSSigningKeyVersion) CreatedAt() time.Time   { return k.createdAt }
+func (k *CloudKMSSigningKeyVersion) DestroyedAt() time.Time { return k.destroyedAt }
 func (k *CloudKMSSigningKeyVersion) Signer(ctx context.Context) (crypto.Signer, error) {
 	return k.keyManager.NewSigner(ctx, k.keyID)
 }
diff --git a/pkg/keys/hashicorp_vault.go b/pkg/keys/hashicorp_vault.go
index 117518c..9bfa74f 100644
--- a/pkg/keys/hashicorp_vault.go
+++ b/pkg/keys/hashicorp_vault.go
@@ -92,18 +92,9 @@ type vaultKeyVersion struct {
 	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) 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,
diff --git a/pkg/keys/in_memory.go b/pkg/keys/in_memory.go
deleted file mode 100644
index 73632c7..0000000
--- a/pkg/keys/in_memory.go
+++ /dev/null
@@ -1,193 +0,0 @@
-// 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 keys
-
-import (
-	"context"
-	"crypto"
-	"crypto/aes"
-	"crypto/cipher"
-	"crypto/ecdsa"
-	"crypto/elliptic"
-	"crypto/rand"
-	"fmt"
-	"io"
-	"sync"
-)
-
-var _ KeyManager = (*InMemory)(nil)
-var _ SigningKeyAdder = (*InMemory)(nil)
-var _ EncryptionKeyAdder = (*InMemory)(nil)
-
-// InMemory is useful for testing. Do NOT use in a running system as all
-// keys are only kept in memory and will be lost across server reboots.
-type InMemory struct {
-	mu          sync.RWMutex
-	signingKeys map[string]*ecdsa.PrivateKey
-	cryptoKeys  map[string][]byte
-}
-
-// NewInMemory creates a new, local, in memory KeyManager.
-func NewInMemory(ctx context.Context) (*InMemory, error) {
-	return &InMemory{
-		signingKeys: make(map[string]*ecdsa.PrivateKey),
-		cryptoKeys:  make(map[string][]byte),
-	}, nil
-}
-
-// CreateSigningKey generates a new ECDSA P256 Signing Key identified by
-// the provided keyID
-func (k *InMemory) CreateSigningKey(ctx context.Context, parent, name string) (string, error) {
-	k.mu.Lock()
-	defer k.mu.Unlock()
-
-	keyID := fmt.Sprintf("%s/%s", parent, name)
-	if _, ok := k.signingKeys[keyID]; ok {
-		return "", fmt.Errorf("key already exists: %v", keyID)
-	}
-
-	pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
-	if err != nil {
-		return "", fmt.Errorf("unable to generate private key: %w", err)
-	}
-
-	k.signingKeys[keyID] = pk
-	return keyID, nil
-}
-
-// AddSigningKey adds a new ECDSA P256 Signing Key identified by the provided
-// keyID.
-func (k *InMemory) AddSigningKey(keyID string, pk *ecdsa.PrivateKey) error {
-	k.mu.Lock()
-	defer k.mu.Unlock()
-
-	if _, ok := k.signingKeys[keyID]; ok {
-		return fmt.Errorf("key already exists: %v", keyID)
-	}
-
-	k.signingKeys[keyID] = pk
-	return nil
-}
-
-// CreateEncryptionKey generates and stores new encryption key identified by the
-// provided keyID.
-func (k *InMemory) CreateEncryptionKey(keyID string) ([]byte, error) {
-	k.mu.Lock()
-	defer k.mu.Unlock()
-
-	if _, ok := k.cryptoKeys[keyID]; ok {
-		return nil, fmt.Errorf("key already exists: %v", keyID)
-	}
-
-	key := make([]byte, 32)
-	if _, err := io.ReadFull(rand.Reader, key); err != nil {
-		return nil, fmt.Errorf("failed to read random bytes: %w", err)
-	}
-
-	k.cryptoKeys[keyID] = key
-	return key, nil
-}
-
-// AddEncryptionKey stores the key on the system.
-func (k *InMemory) AddEncryptionKey(keyID string, key []byte) error {
-	k.mu.Lock()
-	defer k.mu.Unlock()
-
-	if _, ok := k.cryptoKeys[keyID]; ok {
-		return fmt.Errorf("key already exists: %v", keyID)
-	}
-
-	k.cryptoKeys[keyID] = key
-	return nil
-}
-
-func (k *InMemory) NewSigner(ctx context.Context, keyID string) (crypto.Signer, error) {
-	var pk *ecdsa.PrivateKey
-	{
-		k.mu.RLock()
-		defer k.mu.RUnlock()
-
-		if k, ok := k.signingKeys[keyID]; !ok {
-			return nil, fmt.Errorf("key not found")
-		} else {
-			pk = k
-		}
-	}
-
-	return pk, nil
-}
-
-func (k *InMemory) getDEK(keyID string) ([]byte, error) {
-	k.mu.RLock()
-	defer k.mu.RUnlock()
-
-	if dek, ok := k.cryptoKeys[keyID]; ok {
-		return dek, nil
-	}
-	return nil, fmt.Errorf("key not found")
-}
-
-func (k *InMemory) Encrypt(ctx context.Context, keyID string, plaintext []byte, aad []byte) ([]byte, error) {
-	dek, err := k.getDEK(keyID)
-	if err != nil {
-		return nil, err
-	}
-
-	block, err := aes.NewCipher(dek)
-	if err != nil {
-		return nil, fmt.Errorf("bad cipher block: %w", err)
-	}
-	aesgcm, err := cipher.NewGCM(block)
-	if err != nil {
-		return nil, fmt.Errorf("failed to wrap cipher block: %w", err)
-	}
-	nonce := make([]byte, aesgcm.NonceSize())
-	if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
-		return nil, fmt.Errorf("failed to generate nonce: %w", err)
-	}
-	ciphertext := aesgcm.Seal(nonce, nonce, plaintext, aad)
-
-	return ciphertext, nil
-}
-
-func (k *InMemory) Decrypt(ctx context.Context, keyID string, ciphertext []byte, aad []byte) ([]byte, error) {
-	dek, err := k.getDEK(keyID)
-	if err != nil {
-		return nil, err
-	}
-
-	block, err := aes.NewCipher(dek)
-	if err != nil {
-		return nil, fmt.Errorf("failed to create cipher from dek: %w", err)
-	}
-
-	aesgcm, err := cipher.NewGCM(block)
-	if err != nil {
-		return nil, fmt.Errorf("failed to create gcm from dek: %w", err)
-	}
-
-	size := aesgcm.NonceSize()
-	if len(ciphertext) < size {
-		return nil, fmt.Errorf("malformed ciphertext")
-	}
-	nonce, ciphertextPortion := ciphertext[:size], ciphertext[size:]
-
-	plaintext, err := aesgcm.Open(nil, nonce, ciphertextPortion, aad)
-	if err != nil {
-		return nil, fmt.Errorf("failed to decrypt ciphertext with dek: %w", err)
-	}
-
-	return plaintext, nil
-}
diff --git a/pkg/keys/keys.go b/pkg/keys/keys.go
index db44c46..f9b4243 100644
--- a/pkg/keys/keys.go
+++ b/pkg/keys/keys.go
@@ -23,7 +23,6 @@ package keys
 import (
 	"context"
 	"crypto"
-	"crypto/ecdsa"
 	"crypto/x509"
 	"encoding/pem"
 	"fmt"
@@ -59,11 +58,6 @@ type KeyVersionCreator interface {
 	CreateKeyVersion(ctx context.Context, parent string) (string, error)
 }
 
-// EncryptionKeyAdder supports creating encryption keys.
-type EncryptionKeyAdder interface {
-	AddEncryptionKey(string, []byte) error
-}
-
 // KeyVersionDestroyer supports destroying a key version.
 type KeyVersionDestroyer interface {
 	// DestroyKeyVersion destroys the given key version, if it exists. If the
@@ -71,11 +65,6 @@ type KeyVersionDestroyer interface {
 	DestroyKeyVersion(ctx context.Context, id string) error
 }
 
-// SigningKeyAdder supports creating signing keys.
-type SigningKeyAdder interface {
-	AddSigningKey(string, *ecdsa.PrivateKey) error
-}
-
 // SigningKeyVersion represents the necessary details that this application needs
 // to manage signing keys in an external KMS.
 type SigningKeyVersion interface {
@@ -100,6 +89,17 @@ type SigningKeyManager interface {
 	KeyVersionDestroyer
 }
 
+// EncryptionKeyManager supports extended management of encryption keys,
+// versions, and rotation.
+type EncryptionKeyManager interface {
+	// CreateEncryptionKey creates a new encryption key in the given parent,
+	// returning the id. If the key already exists, it returns the key's id.
+	CreateEncryptionKey(ctx context.Context, parent, name string) (string, error)
+
+	KeyVersionCreator
+	KeyVersionDestroyer
+}
+
 // KeyManagerFor returns the appropriate key manager for the given type.
 func KeyManagerFor(ctx context.Context, config *Config) (KeyManager, error) {
 	typ := config.KeyManagerType
@@ -112,8 +112,8 @@ func KeyManagerFor(ctx context.Context, config *Config) (KeyManager, error) {
 		return NewGoogleCloudKMS(ctx, config)
 	case KeyManagerTypeHashiCorpVault:
 		return NewHashiCorpVault(ctx)
-	case KeyManagerTypeInMemory:
-		return NewInMemory(ctx)
+	case KeyManagerTypeFilesystem:
+		return NewFilesystem(ctx, config.FilesystemRoot)
 	}
 
 	return nil, fmt.Errorf("unknown key manager type: %v", typ)
diff --git a/tools/admin-console/templates/healthauthority.html b/tools/admin-console/templates/healthauthority.html
index 53f62a2..94e73f5 100644
--- a/tools/admin-console/templates/healthauthority.html
+++ b/tools/admin-console/templates/healthauthority.html
@@ -33,7 +33,7 @@ health authority.</p>
     <div class="col-sm-6">
       <input type="text" id="Audience" name="Audience" size="50" value="{{.ha.Audience}}">
       <small id="AudienceHelpBlock" class="form-text text-muted">
-        The valud for audience is defined by the operator of this server. It should be
+        The value for audience is defined by the operator of this server. It should be
         communicated to the health authority for inclusion in the verification
         certificates. This is the 'aud' field of the certificate JWT.
         <strong>Once set, this field should not be edited.</strong></small>


Test patch:
diff --git a/internal/keyrotation/rotate_test.go b/internal/keyrotation/rotate_test.go
index 41b4147..d9e0ecd 100644
--- a/internal/keyrotation/rotate_test.go
+++ b/internal/keyrotation/rotate_test.go
@@ -35,11 +35,9 @@ func TestRotateKeys(t *testing.T) {
 	t.Parallel()
 	ctx := context.Background()
 
-	kms, _ := keys.NewInMemory(context.Background())
-	keyID := "testKeyID"
-	if _, err := kms.CreateEncryptionKey(keyID); err != nil {
-		t.Fatal(err)
-	}
+	kms := keys.TestKeyManager(t)
+	keyID := keys.TestEncryptionKey(t, kms)
+
 	config := &Config{
 		RevisionToken:      revision.Config{KeyID: keyID},
 		DeleteOldKeyPeriod: 14 * 24 * time.Hour, // two weeks
diff --git a/internal/keyrotation/server_test.go b/internal/keyrotation/server_test.go
index 3a36d5f..f243771 100644
--- a/internal/keyrotation/server_test.go
+++ b/internal/keyrotation/server_test.go
@@ -31,7 +31,7 @@ func TestNewRotationHandler(t *testing.T) {
 	ctx := context.Background()
 	testDB := database.NewTestDatabase(t)
 
-	kms, _ := keys.NewInMemory(context.Background())
+	kms := keys.TestKeyManager(t)
 
 	testCases := []struct {
 		name string
@@ -61,10 +61,8 @@ func TestNewRotationHandler(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			t.Parallel()
 
-			keyID := "test" + t.Name()
-			if _, err := kms.CreateEncryptionKey(keyID); err != nil {
-				t.Fatal(err)
-			}
+			keyID := keys.TestEncryptionKey(t, kms)
+
 			config := &Config{
 				RevisionToken: revision.Config{KeyID: keyID},
 			}
diff --git a/internal/publish/publish_test.go b/internal/publish/publish_test.go
index 5af0969..aa865fd 100644
--- a/internal/publish/publish_test.go
+++ b/internal/publish/publish_test.go
@@ -387,17 +387,13 @@ func TestPublishWithBypass(t *testing.T) {
 		}
 
 		ctx := context.Background()
+
 		// Database init for all modules that will be used.
 		testDB := coredb.NewTestDatabase(t)
-		// Make key manager
-		kms, err := keys.NewInMemory(ctx)
-		if err != nil {
-			t.Fatalf("can't make kms: %v", err)
-		}
-		keyID := "rev"
-		if _, err := kms.CreateEncryptionKey(keyID); err != nil {
-			t.Fatal(err)
-		}
+
+		kms := keys.TestKeyManager(t)
+		keyID := keys.TestEncryptionKey(t, kms)
+
 		tokenAAD := make([]byte, 16)
 		if _, err := rand.Read(tokenAAD); err != nil {
 			t.Fatalf("not enough entropy: %v", err)
@@ -707,17 +703,13 @@ func TestKeyRevision(t *testing.T) {
 	}
 
 	ctx := context.Background()
+
 	// Database init for all modules that will be used.
 	testDB := coredb.NewTestDatabase(t)
-	// Make key manager
-	kms, err := keys.NewInMemory(ctx)
-	if err != nil {
-		t.Fatalf("can't make kms: %v", err)
-	}
-	keyID := "rev"
-	if _, err := kms.CreateEncryptionKey(keyID); err != nil {
-		t.Fatal(err)
-	}
+
+	kms := keys.TestKeyManager(t)
+	keyID := keys.TestEncryptionKey(t, kms)
+
 	tokenAAD := make([]byte, 16)
 	if _, err := rand.Read(tokenAAD); err != nil {
 		t.Fatalf("not enough entropy: %v", err)
diff --git a/internal/revision/database/revision_test.go b/internal/revision/database/revision_test.go
index 2e1d8a2..2c4bd46 100644
--- a/internal/revision/database/revision_test.go
+++ b/internal/revision/database/revision_test.go
@@ -35,14 +35,8 @@ func TestRevisionKey(t *testing.T) {
 	testDB := database.NewTestDatabase(t)
 	ctx := context.Background()
 
-	kms, err := keys.NewInMemory(ctx)
-	if err != nil {
-		t.Fatalf("unable to cerate in memory KMS")
-	}
-	keyID := "funkey"
-	if _, err := kms.CreateEncryptionKey(keyID); err != nil {
-		t.Fatalf("unable to generate key: %v", err)
-	}
+	kms := keys.TestKeyManager(t)
+	keyID := keys.TestEncryptionKey(t, kms)
 
 	cfg := KMSConfig{keyID, kms}
 	revDB, err := New(testDB, &cfg)
@@ -71,14 +65,8 @@ func TestMultipleRevisionKeys(t *testing.T) {
 	testDB := database.NewTestDatabase(t)
 	ctx := context.Background()
 
-	kms, err := keys.NewInMemory(ctx)
-	if err != nil {
-		t.Fatalf("unable to cerate in memory KMS")
-	}
-	keyID := "funkey"
-	if _, err := kms.CreateEncryptionKey(keyID); err != nil {
-		t.Fatalf("unable to generate key: %v", err)
-	}
+	kms := keys.TestKeyManager(t)
+	keyID := keys.TestEncryptionKey(t, kms)
 
 	cfg := KMSConfig{keyID, kms}
 	revDB, err := New(testDB, &cfg)
diff --git a/internal/revision/revision_test.go b/internal/revision/revision_test.go
index 3f2f238..a1d7790 100644
--- a/internal/revision/revision_test.go
+++ b/internal/revision/revision_test.go
@@ -16,9 +16,7 @@ package revision
 
 import (
 	"context"
-	"crypto/rand"
 	"fmt"
-	"io"
 	"testing"
 	"time"
 
@@ -151,18 +149,8 @@ func TestEncryptDecrypt(t *testing.T) {
 	testDB := database.NewTestDatabase(t)
 	ctx := context.Background()
 
-	kms, err := keys.NewInMemory(ctx)
-	if err != nil {
-		t.Fatalf("unable to cerate in memory KMS")
-	}
-	keyID := "skeleton"
-	if _, err := kms.CreateEncryptionKey(keyID); err != nil {
-		t.Fatalf("unable to generate key: %v", err)
-	}
-	key := make([]byte, 32)
-	if _, err := io.ReadFull(rand.Reader, key); err != nil {
-		t.Fatalf("unable to generate AES key: %v", err)
-	}
+	kms := keys.TestKeyManager(t)
+	keyID := keys.TestEncryptionKey(t, kms)
 
 	cfg := revisiondb.KMSConfig{
 		WrapperKeyID: keyID,
diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go
index 809bfcf..c1d41f7 100644
--- a/internal/setup/setup_test.go
+++ b/internal/setup/setup_test.go
@@ -61,7 +61,7 @@ func (t *testConfig) DatabaseConfig() *database.Config {
 
 func (t *testConfig) KeyManagerConfig() *keys.Config {
 	return &keys.Config{
-		KeyManagerType: keys.KeyManagerType("IN_MEMORY"),
+		KeyManagerType: keys.KeyManagerType("FILESYSTEM"),
 	}
 }
 
@@ -186,8 +186,8 @@ func TestSetupWith(t *testing.T) {
 			t.Errorf("expected key manager to exist")
 		}
 
-		if _, ok := km.(*keys.InMemory); !ok {
-			t.Errorf("expected %T to be InMemory", km)
+		if _, ok := km.(*keys.Filesystem); !ok {
+			t.Errorf("expected %T to be Filesystem", km)
 		}
 	})
 
diff --git a/pkg/keys/filesystem_test.go b/pkg/keys/filesystem_test.go
new file mode 100644
index 0000000..d25096a
--- /dev/null
+++ b/pkg/keys/filesystem_test.go
@@ -0,0 +1,389 @@
+// 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 keys
+
+import (
+	"bytes"
+	"context"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/x509"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestNewFilesystem(t *testing.T) {
+	t.Parallel()
+
+	ctx := context.Background()
+
+	t.Run("root_empty", func(t *testing.T) {
+		t.Parallel()
+
+		if _, err := NewFilesystem(ctx, ""); err != nil {
+			t.Fatal(err)
+		}
+	})
+
+	t.Run("root_relative", func(t *testing.T) {
+		t.Parallel()
+
+		t.Cleanup(func() {
+			if err := os.RemoveAll("tmp1"); err != nil {
+				t.Fatal(err)
+			}
+		})
+
+		fs, err := NewFilesystem(ctx, "tmp1")
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if _, err := fs.CreateSigningKey(ctx, "foo", "bar"); err != nil {
+			t.Fatal(err)
+		}
+
+		if _, err := os.Stat("tmp1/foo/bar"); err != nil {
+			t.Fatal(err)
+		}
+	})
+
+	t.Run("root_absolute", func(t *testing.T) {
+		t.Parallel()
+
+		tmp, err := ioutil.TempDir("", "")
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		t.Cleanup(func() {
+			if err := os.RemoveAll(tmp); err != nil {
+				t.Fatal(err)
+			}
+		})
+
+		fs, err := NewFilesystem(ctx, tmp)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if _, err := fs.CreateSigningKey(ctx, "foo", "bar"); err != nil {
+			t.Fatal(err)
+		}
+
+		if _, err := os.Stat(tmp + "/foo/bar"); err != nil {
+			t.Fatal(err)
+		}
+	})
+}
+
+func TestFilesystem_NewSigner(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name  string
+		keyID string
+		setup func(string) error
+		err   string
+	}{
+		{
+			name:  "error_key_not_exist",
+			keyID: "totally_not_valid",
+			err:   "failed to read signing key",
+		},
+		{
+			name:  "error_key_not_ecdsa",
+			keyID: "banana",
+			setup: func(dir string) error {
+				pth := filepath.Join(dir, "banana")
+				return ioutil.WriteFile(pth, []byte("dafd"), 0600)
+			},
+			err: "failed to parse signing key",
+		},
+		{
+			name:  "happy",
+			keyID: "apple",
+			setup: func(dir string) error {
+				pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+				if err != nil {
+					return err
+				}
+				b, err := x509.MarshalECPrivateKey(pk)
+				if err != nil {
+					return err
+				}
+
+				pth := filepath.Join(dir, "apple")
+				return ioutil.WriteFile(pth, b, 0600)
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			ctx := context.Background()
+
+			dir, err := ioutil.TempDir("", "")
+			if err != nil {
+				t.Fatal(err)
+			}
+			t.Cleanup(func() { os.RemoveAll(dir) })
+
+			if tc.setup != nil {
+				if err := tc.setup(dir); err != nil {
+					t.Fatal(err)
+				}
+			}
+
+			fs, err := NewFilesystem(ctx, dir)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			if _, err := fs.NewSigner(ctx, tc.keyID); err != nil {
+				if tc.err == "" {
+					t.Fatal(err)
+				}
+
+				if !strings.Contains(err.Error(), tc.err) {
+					t.Fatalf("expected %#v to contain %#v", err.Error(), tc.err)
+				}
+			}
+		})
+	}
+}
+
+func TestFilesystem_EncryptDecrypt(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name      string
+		keyID     string
+		plaintext []byte
+		aad       []byte
+		setup     func(*Filesystem) error
+		err       string
+	}{
+		{
+			name:  "error_key_not_exist",
+			keyID: "totally_not_valid",
+			err:   "failed to list keys",
+		},
+		{
+			name:  "no_key_versions",
+			keyID: "banana",
+			setup: func(fs *Filesystem) error {
+				dir := filepath.Join(fs.root, "banana")
+				return os.MkdirAll(dir, 0700)
+			},
+			err: "no key versions",
+		},
+		{
+			name:      "happy",
+			keyID:     "apple",
+			plaintext: []byte("bacon"),
+			setup: func(fs *Filesystem) error {
+				ctx := context.Background()
+				id, err := fs.CreateEncryptionKey(ctx, "", "apple")
+				if err != nil {
+					return err
+				}
+				if _, err := fs.CreateKeyVersion(ctx, id); err != nil {
+					return err
+				}
+				return nil
+			},
+		},
+		{
+			name:      "multi",
+			keyID:     "apple",
+			plaintext: []byte("bacon"),
+			setup: func(fs *Filesystem) error {
+				ctx := context.Background()
+				id, err := fs.CreateEncryptionKey(ctx, "", "apple")
+				if err != nil {
+					return err
+				}
+
+				for i := 0; i < 3; i++ {
+					if _, err := fs.CreateKeyVersion(ctx, id); err != nil {
+						return err
+					}
+				}
+				return nil
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			ctx := context.Background()
+
+			dir, err := ioutil.TempDir("", "")
+			if err != nil {
+				t.Fatal(err)
+			}
+			t.Cleanup(func() { os.RemoveAll(dir) })
+
+			fs, err := NewFilesystem(ctx, dir)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			if tc.setup != nil {
+				if err := tc.setup(fs); err != nil {
+					t.Fatal(err)
+				}
+			}
+
+			ciphertext, err := fs.Encrypt(ctx, tc.keyID, tc.plaintext, tc.aad)
+			if err != nil {
+				if tc.err == "" {
+					t.Fatal(err)
+				}
+
+				if !strings.Contains(err.Error(), tc.err) {
+					t.Fatalf("expected %#v to contain %#v", err.Error(), tc.err)
+				}
+			}
+
+			if len(ciphertext) > 0 {
+				// Create another key version - this will ensure our ciphertext -> key
+				// version mapping works.
+				for i := 0; i < 3; i++ {
+					if _, err := fs.CreateKeyVersion(ctx, tc.keyID); err != nil {
+						t.Fatal(err)
+					}
+				}
+
+				plaintext, err := fs.Decrypt(ctx, tc.keyID, ciphertext, tc.aad)
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				if got, want := plaintext, tc.plaintext; !bytes.Equal(got, want) {
+					t.Errorf("expected %#v to be %#v", got, want)
+				}
+			}
+		})
+	}
+}
+
+func TestFilesystem_SigningKeyVersions(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name  string
+		keyID string
+		setup func(*Filesystem) error
+		exp   int
+		err   string
+	}{
+		{
+			name:  "error_key_not_exist",
+			keyID: "totally_not_valid",
+			err:   "failed to open metadata",
+		},
+		{
+			name:  "happy",
+			keyID: "apple",
+			setup: func(fs *Filesystem) error {
+				ctx := context.Background()
+				id, err := fs.CreateSigningKey(ctx, "", "apple")
+				if err != nil {
+					return err
+				}
+				if _, err := fs.CreateKeyVersion(ctx, id); err != nil {
+					return err
+				}
+				return nil
+			},
+			exp: 1,
+		},
+		{
+			name:  "multi",
+			keyID: "apple",
+			setup: func(fs *Filesystem) error {
+				ctx := context.Background()
+				id, err := fs.CreateSigningKey(ctx, "", "apple")
+				if err != nil {
+					return err
+				}
+
+				for i := 0; i < 3; i++ {
+					if _, err := fs.CreateKeyVersion(ctx, id); err != nil {
+						return err
+					}
+				}
+				return nil
+			},
+			exp: 3,
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			ctx := context.Background()
+
+			dir, err := ioutil.TempDir("", "")
+			if err != nil {
+				t.Fatal(err)
+			}
+			t.Cleanup(func() { os.RemoveAll(dir) })
+
+			fs, err := NewFilesystem(ctx, dir)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			if tc.setup != nil {
+				if err := tc.setup(fs); err != nil {
+					t.Fatal(err)
+				}
+			}
+
+			versions, err := fs.SigningKeyVersions(ctx, tc.keyID)
+			if err != nil {
+				if tc.err == "" {
+					t.Fatal(err)
+				}
+
+				if !strings.Contains(err.Error(), tc.err) {
+					t.Fatalf("expected %#v to contain %#v", err.Error(), tc.err)
+				}
+			}
+
+			if got, want := len(versions), tc.exp; got != want {
+				t.Errorf("expected %d version to be %d", got, want)
+			}
+		})
+	}
+}
diff --git a/pkg/keys/testing.go b/pkg/keys/testing.go
new file mode 100644
index 0000000..b5b027e
--- /dev/null
+++ b/pkg/keys/testing.go
@@ -0,0 +1,92 @@
+// 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 keys
+
+import (
+	"context"
+	"io/ioutil"
+	"os"
+	"testing"
+)
+
+// TestKeyManager creates a new key manager suitable for use in tests.
+func TestKeyManager(tb testing.TB) KeyManager {
+	tb.Helper()
+
+	ctx := context.Background()
+
+	tmpdir, err := ioutil.TempDir("", "")
+	if err != nil {
+		tb.Fatal(err)
+	}
+	tb.Cleanup(func() {
+		if err := os.RemoveAll(tmpdir); err != nil {
+			tb.Fatal(err)
+		}
+	})
+
+	kms, err := NewFilesystem(ctx, tmpdir)
+	if err != nil {
+		tb.Fatal(err)
+	}
+
+	return kms
+}
+
+// TestEncryptionKey creates a new encryption key and key version in the given
+// key manager. If the provided key manager does not support managing encryption
+// keys, it calls t.Fatal.
+func TestEncryptionKey(tb testing.TB, kms KeyManager) string {
+	tb.Helper()
+
+	typ, ok := kms.(EncryptionKeyManager)
+	if !ok {
+		tb.Fatal("kms cannot manage encryption keys")
+	}
+
+	ctx := context.Background()
+	parent, err := typ.CreateEncryptionKey(ctx, "parent", "key")
+	if err != nil {
+		tb.Fatal(err)
+	}
+	if _, err := typ.CreateKeyVersion(ctx, parent); err != nil {
+		tb.Fatal(err)
+	}
+
+	return parent
+}
+
+// TestSigningKey creates a new signing key and key version in the given key
+// manager. If the provided key manager does not support managing signing keys,
+// it calls t.Fatal.
+func TestSigningKey(tb testing.TB, kms KeyManager) string {
+	tb.Helper()
+
+	typ, ok := kms.(SigningKeyManager)
+	if !ok {
+		tb.Fatal("kms cannot manage signing keys")
+	}
+
+	ctx := context.Background()
+	parent, err := typ.CreateSigningKey(ctx, "parent", "key")
+	if err != nil {
+		tb.Fatal(err)
+	}
+	if _, err := typ.CreateKeyVersion(ctx, parent); err != nil {
+		tb.Fatal(err)
+	}
+
+	return parent
+}