Repository: google/exposure-notifications-server
Pull request #493 (2020-05-30 00:52:49)

implement verification and processing of health authority diagnosis v…
…erification certificates.

Next step in #349 

This adds 2 new database tables

1. HealthAuthority - represents an individual health authority
2. HealthAuthorityKey - represents key versions. Each time a new signing key is issued, the public key and corresponding version will need to be inserted into this table.

AuthorizedApps can now be scoped to a set of HealthAuthorities. This allows for there to be multi diagnosis verification authorities within a state (Say a university medical system and state dept. of health).

- This is NOT hooked up to the live API yet.
- Happy path tests only


/cc @peetsasaki 

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

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/493#" title="Author self-approved">mikehelmick</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/master/OWNERS)~~ [mikehelmick]

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":[]} -->

Patch:
diff --git a/docs/deploying.md b/docs/deploying.md
index 4d01f3e..93e7b9c 100644
--- a/docs/deploying.md
+++ b/docs/deploying.md
@@ -11,6 +11,11 @@
   - [On Google Cloud](#on-google-cloud)
   - [On a custom setup](#on-a-custom-setup)
 - [Configuring the server](#configuring-the-server)
+- [Configuring components](#configuring-components)
+  - [Blob storage](#blob-storage)
+  - [Key management](#key-management)
+  - [Secrets management](#secrets-management)
+  - [Observability](#observability)
 
 <!-- /TOC -->
 
@@ -262,3 +267,62 @@ the public Internet!**
 1.  Open a browser to [localhost:8080](http://localhost:8080/).
 
     **Remember, you are editing the live configuration of the database!**
+
+
+## Configuring components
+
+### Blob storage
+
+The blob storage component defines where and how export files are written. The
+following configurations are available:
+
+| Name                   | `BLOBSTORE` value      | Description
+| ---------------------- | ---------------------- | -----------
+| Google Cloud Storage\* | `GOOGLE_CLOUD_STORAGE` | Store data in Google Cloud Storage.
+| Filesystem             | `FILESYSTEM`           | Store data on a filesystem.
+| Noop                   | `NOOP`                 | No files are written.
+
+\* default
+
+### Key management
+
+The key management component is responsible for signing exports. The following
+configurations are available:
+
+| Name               | `KEY_MANAGER` value | Description
+| ------------------ | ------------------- | -----------
+| Google Cloud KMS\* | `GOOGLE_CLOUD_KMS`  | Perform signing using Google Cloud KMS.
+| HashiCorp Vault    | `HASHICORP_VAULT`   | Perform signing using HashiCorp Vault.
+
+\* default
+
+
+### Secrets management
+
+The secrets management component is responsible for acquiring secrets. The
+following configurations are available:
+
+| Name                    | `SECRET_MANAGER` value  | Description
+| ----------------------- | ----------------------- | -----------
+| Azure Key Vault         | `AZURE_KEY_VAULT`       | Resolve with Key Vault.
+| Google Secret Manager\* | `GOOGLE_SECRET_MANAGER` | Resolve with Secret Manager.
+| HashiCorp Vault         | `HASHICORP_VAULT`       | Resolve with Vault.
+
+\* default
+
+In the interest of performance, secrets are TTL-cached to eliminate the secret
+manager from most hot paths. Configure this TTL with `SECRET_CACHE_TTL` which is
+expressed as a time duration like "5m" or "15s". The default cache time is 5
+minutes and lower values are strongly discouraged.
+
+### Observability
+
+The observability component is responsible for metrics. The following
+configurations are available:
+
+| Name                    | `OBSERVABILITY_EXPORTER` value  | Description
+| ----------------------- | ------------------------------- | -----------
+| OpenCensus Agent        | `OCAGENT`                       | Use OpenCensus.
+| Stackdriver\*           | `STACKDRIVER`                   | Use Stackdriver.
+
+\* default
diff --git a/internal/admin/config.go b/internal/admin/config.go
index 6c4e8e9..cd390cb 100644
--- a/internal/admin/config.go
+++ b/internal/admin/config.go
@@ -23,24 +23,45 @@ import (
 	"net/http"
 
 	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/secrets"
 	"github.com/google/exposure-notifications-server/internal/setup"
+	"github.com/google/exposure-notifications-server/internal/signing"
+	"github.com/google/exposure-notifications-server/internal/storage"
 )
 
-var _ setup.DBConfigProvider = (*Config)(nil)
+var _ setup.DatabaseConfigProvider = (*Config)(nil)
+var _ setup.KeyManagerConfigProvider = (*Config)(nil)
+var _ setup.SecretManagerConfigProvider = (*Config)(nil)
+var _ setup.BlobstoreConfigProvider = (*Config)(nil)
 
 type Config struct {
+	Database      *database.Config
+	KeyManager    *signing.Config
+	SecretManager *secrets.Config
+	Storage       *storage.Config
+
 	Port         string `envconfig:"PORT" default:"8080"`
 	TemplatePath string `envconfig:"TEMPLATE_DIR" default:"./tools/admin-console/templates"`
-	Database     *database.Config
 	TopFile      string `envconfig:"TOP_FILE" default:"top"`
 	BotFile      string `envconfig:"BOTTOM_FILE" default:"bottom"`
 }
 
-// DB returns the configuration for the databse.
-func (c *Config) DB() *database.Config {
+func (c *Config) DatabaseConfig() *database.Config {
 	return c.Database
 }
 
+func (c *Config) KeyManagerConfig() *signing.Config {
+	return c.KeyManager
+}
+
+func (c *Config) SecretManagerConfig() *secrets.Config {
+	return c.SecretManager
+}
+
+func (c *Config) BlobstoreConfig() *storage.Config {
+	return c.Storage
+}
+
 func (c *Config) RenderTemplate(w http.ResponseWriter, tmpl string, p TemplateMap) error {
 	files := []string{
 		fmt.Sprintf("%s/%s.html", c.TemplatePath, c.TopFile),
diff --git a/internal/authorizedapp/database/authorized_app.go b/internal/authorizedapp/database/authorized_app.go
index 4e870cc..e52e896 100644
--- a/internal/authorizedapp/database/authorized_app.go
+++ b/internal/authorizedapp/database/authorized_app.go
@@ -47,12 +47,12 @@ func (aa *AuthorizedAppDB) InsertAuthorizedApp(ctx context.Context, m *model.Aut
 		result, err := tx.Exec(ctx, `
 			INSERT INTO
 				AuthorizedApp
-			  (app_package_name, platform, allowed_regions,
+			  (app_package_name, platform, allowed_regions, allowed_health_authority_ids,
 			   safetynet_disabled, safetynet_apk_digest, safetynet_cts_profile_match, safetynet_basic_integrity, safetynet_past_seconds, safetynet_future_seconds,
 			   devicecheck_disabled, devicecheck_team_id, devicecheck_key_id, devicecheck_private_key_secret)
 			VALUES
-			  ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
-		`, m.AppPackageName, m.Platform, m.AllAllowedRegions(),
+			  ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
+		`, m.AppPackageName, m.Platform, m.AllAllowedRegions(), m.AllAllowedHealthAuthorityIDs(),
 			m.SafetyNetDisabled, m.SafetyNetApkDigestSHA256, m.SafetyNetCTSProfileMatch, m.SafetyNetBasicIntegrity, int64(m.SafetyNetPastTime.Seconds()), int64(m.SafetyNetFutureTime.Seconds()),
 			m.DeviceCheckDisabled, m.DeviceCheckTeamID, m.DeviceCheckKeyID, m.DeviceCheckPrivateKeySecret)
 
@@ -71,12 +71,12 @@ func (aa *AuthorizedAppDB) UpdateAuthorizedApp(ctx context.Context, priorKey str
 		result, err := tx.Exec(ctx, `
 			UPDATE AuthorizedApp
 			SET
-				app_package_name = $1, platform = $2, allowed_regions = $3,
-				safetynet_disabled = $4, safetynet_apk_digest = $5, safetynet_cts_profile_match = $6, safetynet_basic_integrity = $7, safetynet_past_seconds = $8, safetynet_future_seconds = $9,
-				devicecheck_disabled = $10, devicecheck_team_id = $11, devicecheck_key_id = $12, devicecheck_private_key_secret = $13
+				app_package_name = $1, platform = $2, allowed_regions = $3, allowed_health_authority_ids = $4,
+				safetynet_disabled = $5, safetynet_apk_digest = $6, safetynet_cts_profile_match = $7, safetynet_basic_integrity = $8, safetynet_past_seconds = $9, safetynet_future_seconds = $10,
+				devicecheck_disabled = $11, devicecheck_team_id = $12, devicecheck_key_id = $13, devicecheck_private_key_secret = $14
 			WHERE
-				app_package_name = $14
-			`, m.AppPackageName, m.Platform, m.AllAllowedRegions(),
+				app_package_name = $15
+			`, m.AppPackageName, m.Platform, m.AllAllowedRegions(), m.AllAllowedHealthAuthorityIDs(),
 			m.SafetyNetDisabled, m.SafetyNetApkDigestSHA256, m.SafetyNetCTSProfileMatch, m.SafetyNetBasicIntegrity, int64(m.SafetyNetPastTime.Seconds()), int64(m.SafetyNetFutureTime.Seconds()),
 			m.DeviceCheckDisabled, m.DeviceCheckTeamID, m.DeviceCheckKeyID, m.DeviceCheckPrivateKeySecret, priorKey)
 		if err != nil {
@@ -121,13 +121,13 @@ func (aa *AuthorizedAppDB) GetAllAuthorizedApps(ctx context.Context, sm secrets.
 	defer conn.Release()
 
 	query := `
-    SELECT
-      app_package_name, platform, allowed_regions,
-      safetynet_disabled, safetynet_apk_digest, safetynet_cts_profile_match, safetynet_basic_integrity, safetynet_past_seconds, safetynet_future_seconds,
-      devicecheck_disabled, devicecheck_team_id, devicecheck_key_id, devicecheck_private_key_secret
-    FROM
-      AuthorizedApp
-    ORDER BY app_package_name ASC`
+		SELECT
+			app_package_name, platform, allowed_regions, allowed_health_authority_ids,
+			safetynet_disabled, safetynet_apk_digest, safetynet_cts_profile_match, safetynet_basic_integrity, safetynet_past_seconds, safetynet_future_seconds,
+			devicecheck_disabled, devicecheck_team_id, devicecheck_key_id, devicecheck_private_key_secret
+		FROM
+			AuthorizedApp
+		ORDER BY app_package_name ASC`
 
 	rows, err := conn.Query(ctx, query)
 	if err != nil {
@@ -161,7 +161,7 @@ func (db *AuthorizedAppDB) GetAuthorizedApp(ctx context.Context, sm secrets.Secr
 
 	query := `
 		SELECT
-			app_package_name, platform, allowed_regions,
+			app_package_name, platform, allowed_regions, allowed_health_authority_ids,
 			safetynet_disabled, safetynet_apk_digest, safetynet_cts_profile_match, safetynet_basic_integrity, safetynet_past_seconds, safetynet_future_seconds,
 			devicecheck_disabled, devicecheck_team_id, devicecheck_key_id, devicecheck_private_key_secret
 		FROM
@@ -176,10 +176,11 @@ func (db *AuthorizedAppDB) GetAuthorizedApp(ctx context.Context, sm secrets.Secr
 func scanOneAuthorizedApp(ctx context.Context, row pgx.Row, sm secrets.SecretManager) (*model.AuthorizedApp, error) {
 	config := model.NewAuthorizedApp()
 	var allowedRegions []string
+	var AllowedHealthAuthorityIDs []int64
 	var safetyNetPastSeconds, safetyNetFutureSeconds *int
 	var deviceCheckTeamID, deviceCheckKeyID, deviceCheckPrivateKeySecret sql.NullString
 	if err := row.Scan(
-		&config.AppPackageName, &config.Platform, &allowedRegions,
+		&config.AppPackageName, &config.Platform, &allowedRegions, &AllowedHealthAuthorityIDs,
 		&config.SafetyNetDisabled, &config.SafetyNetApkDigestSHA256, &config.SafetyNetCTSProfileMatch, &config.SafetyNetBasicIntegrity, &safetyNetPastSeconds, &safetyNetFutureSeconds,
 		&config.DeviceCheckDisabled, &deviceCheckTeamID, &deviceCheckKeyID, &deviceCheckPrivateKeySecret,
 	); err != nil {
@@ -203,6 +204,10 @@ func scanOneAuthorizedApp(ctx context.Context, row pgx.Row, sm secrets.SecretMan
 	for _, r := range allowedRegions {
 		config.AllowedRegions[r] = struct{}{}
 	}
+	// and the health authorities map
+	for _, haID := range AllowedHealthAuthorityIDs {
+		config.AllowedHealthAuthorityIDs[haID] = struct{}{}
+	}
 
 	// Handle nulls
 	if v := deviceCheckTeamID; v.Valid && v.String != "" {
diff --git a/internal/authorizedapp/model/authorized_app_model.go b/internal/authorizedapp/model/authorized_app_model.go
index fde9c3d..58b87d2 100644
--- a/internal/authorizedapp/model/authorized_app_model.go
+++ b/internal/authorizedapp/model/authorized_app_model.go
@@ -34,12 +34,19 @@ type AuthorizedApp struct {
 	AppPackageName string
 
 	// Platform is the app platform like "android" or "ios".
+	// Deprecated - Platform doesn't matter with device attestations going away.
 	Platform string
 
 	// AllowedRegions is the list of allowed regions for this app. If the list is
 	// empty, all regions are permitted.
 	AllowedRegions map[string]struct{}
 
+	// AllowedHealthAuthorityIDs represents the set of allowed health authorities
+	// that this app can obtain and verify diagnosis verification certificates from.
+	AllowedHealthAuthorityIDs map[int64]struct{}
+
+	// DEPRECATION NOTICE:
+	// Everything below here is deprecated.
 	// SafetyNet configuration.
 	SafetyNetDisabled        bool
 	SafetyNetApkDigestSHA256 []string
@@ -58,7 +65,8 @@ type AuthorizedApp struct {
 
 func NewAuthorizedApp() *AuthorizedApp {
 	return &AuthorizedApp{
-		AllowedRegions: make(map[string]struct{}),
+		AllowedRegions:            make(map[string]struct{}),
+		AllowedHealthAuthorityIDs: make(map[int64]struct{}),
 	}
 }
 
@@ -70,6 +78,14 @@ func (c *AuthorizedApp) AllAllowedRegions() []string {
 	return regions
 }
 
+func (c *AuthorizedApp) AllAllowedHealthAuthorityIDs() []int64 {
+	has := []int64{}
+	for k := range c.AllowedHealthAuthorityIDs {
+		has = append(has, k)
+	}
+	return has
+}
+
 func (c *AuthorizedApp) Validate() []string {
 	errors := make([]string, 0)
 	if c.AppPackageName == "" {
diff --git a/internal/cleanup/config.go b/internal/cleanup/config.go
index 9de7908..68ced8e 100644
--- a/internal/cleanup/config.go
+++ b/internal/cleanup/config.go
@@ -18,32 +18,36 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/secrets"
 	"github.com/google/exposure-notifications-server/internal/setup"
 	"github.com/google/exposure-notifications-server/internal/storage"
 )
 
 // Compile-time check to assert this config matches requirements.
-var _ setup.BlobStorageConfigProvider = (*Config)(nil)
-var _ setup.DBConfigProvider = (*Config)(nil)
+var _ setup.BlobstoreConfigProvider = (*Config)(nil)
+var _ setup.DatabaseConfigProvider = (*Config)(nil)
+var _ setup.SecretManagerConfigProvider = (*Config)(nil)
 
 // Config represents the configuration and associated environment variables for
 // the cleanup components.
 type Config struct {
-	Port          string        `envconfig:"PORT" default:"8080"`
-	Timeout       time.Duration `envconfig:"CLEANUP_TIMEOUT" default:"10m"`
-	TTL           time.Duration `envconfig:"CLEANUP_TTL" default:"336h"`
+	Storage       *storage.Config
 	Database      *database.Config
-	BlobstoreType string `envconfig:"BLOBSTORE_TYPE" default:"CLOUD_STORAGE"`
+	SecretManager *secrets.Config
+
+	Port    string        `envconfig:"PORT" default:"8080"`
+	Timeout time.Duration `envconfig:"CLEANUP_TIMEOUT" default:"10m"`
+	TTL     time.Duration `envconfig:"CLEANUP_TTL" default:"336h"`
+}
+
+func (c *Config) BlobstoreConfig() *storage.Config {
+	return c.Storage
 }
 
-// DB return the databsae configuration.
-func (c *Config) DB() *database.Config {
+func (c *Config) DatabaseConfig() *database.Config {
 	return c.Database
 }
 
-// BlobStorage returns the BlobStorage configuration.
-func (c *Config) BlobStorage() storage.BlobstoreConfig {
-	return storage.BlobstoreConfig{
-		BlobstoreType: storage.BlobstoreType(c.BlobstoreType),
-	}
+func (c *Config) SecretManagerConfig() *secrets.Config {
+	return c.SecretManager
 }
diff --git a/internal/database/database.go b/internal/database/database.go
index 8ed799b..2e3aad5 100644
--- a/internal/database/database.go
+++ b/internal/database/database.go
@@ -18,6 +18,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"time"
 
 	pgx "github.com/jackc/pgx/v4"
 )
@@ -30,6 +31,13 @@ var (
 	ErrKeyConflict = errors.New("key conflict")
 )
 
+func (db *DB) NullableTime(t time.Time) *time.Time {
+	if t.IsZero() {
+		return nil
+	}
+	return &t
+}
+
 // InTx runs the given function f within a transaction with isolation level isoLevel.
 func (db *DB) InTx(ctx context.Context, isoLevel pgx.TxIsoLevel, f func(tx pgx.Tx) error) error {
 	conn, err := db.Pool.Acquire(ctx)
diff --git a/internal/export/config.go b/internal/export/config.go
index 9b36081..955b38c 100644
--- a/internal/export/config.go
+++ b/internal/export/config.go
@@ -18,19 +18,26 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/secrets"
 	"github.com/google/exposure-notifications-server/internal/setup"
+	"github.com/google/exposure-notifications-server/internal/signing"
 	"github.com/google/exposure-notifications-server/internal/storage"
 )
 
-// Compile-time check to assert this config matches requirements.
-var _ setup.KeyManagerProvider = (*Config)(nil)
-var _ setup.BlobStorageConfigProvider = (*Config)(nil)
-var _ setup.DBConfigProvider = (*Config)(nil)
+// Compile-time check to assert this config matches requirements
+var _ setup.BlobstoreConfigProvider = (*Config)(nil)
+var _ setup.DatabaseConfigProvider = (*Config)(nil)
+var _ setup.KeyManagerConfigProvider = (*Config)(nil)
+var _ setup.SecretManagerConfigProvider = (*Config)(nil)
 
 // Config represents the configuration and associated environment variables for
 // the export components.
 type Config struct {
-	Database       *database.Config
+	Storage       *storage.Config
+	Database      *database.Config
+	KeyManager    *signing.Config
+	SecretManager *secrets.Config
+
 	Port           string        `envconfig:"PORT" default:"8080"`
 	CreateTimeout  time.Duration `envconfig:"CREATE_BATCHES_TIMEOUT" default:"5m"`
 	WorkerTimeout  time.Duration `envconfig:"WORKER_TIMEOUT" default:"5m"`
@@ -39,23 +46,21 @@ type Config struct {
 	MaxRecords     int           `envconfig:"EXPORT_FILE_MAX_RECORDS" default:"30000"`
 	TruncateWindow time.Duration `envconfig:"TRUNCATE_WINDOW" default:"1h"`
 	MinWindowAge   time.Duration `envconfig:"MIN_WINDOW_AGE" default:"2h"`
-	BlobstoreType  string        `envconfig:"BLOBSTORE_TYPE" default:"CLOUD_STORAGE"`
 	TTL            time.Duration `envconfig:"CLEANUP_TTL" default:"336h"`
 }
 
-// DB returns the database config.
-func (c *Config) DB() *database.Config {
+func (c *Config) BlobstoreConfig() *storage.Config {
+	return c.Storage
+}
+
+func (c *Config) DatabaseConfig() *database.Config {
 	return c.Database
 }
 
-// KeyManager returns the KeyManager configuration.
-func (c *Config) KeyManager() bool {
-	return true
+func (c *Config) KeyManagerConfig() *signing.Config {
+	return c.KeyManager
 }
 
-// BlobStorage returns the BlobStorage configuration.
-func (c *Config) BlobStorage() storage.BlobstoreConfig {
-	return storage.BlobstoreConfig{
-		BlobstoreType: storage.BlobstoreType(c.BlobstoreType),
-	}
+func (c *Config) SecretManagerConfig() *secrets.Config {
+	return c.SecretManager
 }
diff --git a/internal/export/database/export.go b/internal/export/database/export.go
index 194a261..7525289 100644
--- a/internal/export/database/export.go
+++ b/internal/export/database/export.go
@@ -44,10 +44,7 @@ func (db *ExportDB) AddExportConfig(ctx context.Context, ec *model.ExportConfig)
 		return err
 	}
 
-	var thru *time.Time
-	if !ec.Thru.IsZero() {
-		thru = &ec.Thru
-	}
+	thru := db.db.NullableTime(ec.Thru)
 	return db.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
 		row := tx.QueryRow(ctx, `
 			INSERT INTO
@@ -72,10 +69,7 @@ func (db *ExportDB) UpdateExportConfig(ctx context.Context, ec *model.ExportConf
 		return err
 	}
 
-	var thru *time.Time
-	if !ec.Thru.IsZero() {
-		thru = &ec.Thru
-	}
+	thru := db.db.NullableTime(ec.Thru)
 	return db.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
 		result, err := tx.Exec(ctx, `
 			UPDATE
diff --git a/internal/export/worker.go b/internal/export/worker.go
index b319c61..93b2c23 100644
--- a/internal/export/worker.go
+++ b/internal/export/worker.go
@@ -205,7 +205,7 @@ func (s *Server) createFile(ctx context.Context, cfi createFileInfo) (string, er
 	logger.Infof("Created file %v, signed with %v keys", objectName, len(signers))
 	ctx, cancel := context.WithTimeout(ctx, blobOperationTimeout)
 	defer cancel()
-	if err := s.env.Blobstore().CreateObject(ctx, cfi.exportBatch.BucketName, objectName, data); err != nil {
+	if err := s.env.Blobstore().CreateObject(ctx, cfi.exportBatch.BucketName, objectName, data, false); err != nil {
 		return "", fmt.Errorf("creating file %s in bucket %s: %w", objectName, cfi.exportBatch.BucketName, err)
 	}
 	return objectName, nil
@@ -276,7 +276,7 @@ func (s *Server) createIndex(ctx context.Context, eb *model.ExportBatch, newObje
 	indexObjectName := exportIndexFilename(eb)
 	ctx, cancel := context.WithTimeout(ctx, blobOperationTimeout)
 	defer cancel()
-	if err := s.env.Blobstore().CreateObject(ctx, eb.BucketName, indexObjectName, data); err != nil {
+	if err := s.env.Blobstore().CreateObject(ctx, eb.BucketName, indexObjectName, data, false); err != nil {
 		return "", 0, fmt.Errorf("creating file %s in bucket %s: %w", indexObjectName, eb.BucketName, err)
 	}
 	return indexObjectName, len(objects), nil
diff --git a/internal/federationin/config.go b/internal/federationin/config.go
index c0d4e85..ac9c900 100644
--- a/internal/federationin/config.go
+++ b/internal/federationin/config.go
@@ -19,6 +19,7 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/secrets"
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
@@ -35,11 +36,14 @@ var (
 )
 
 // Compile-time check to assert this config matches requirements.
-var _ setup.DBConfigProvider = (*Config)(nil)
+var _ setup.DatabaseConfigProvider = (*Config)(nil)
+var _ setup.SecretManagerConfigProvider = (*Config)(nil)
 
 // Config is the configuration for federation-pull components (data pulled from other servers).
 type Config struct {
-	Database       *database.Config
+	Database      *database.Config
+	SecretManager *secrets.Config
+
 	Port           string        `envconfig:"PORT" default:"8080"`
 	Timeout        time.Duration `envconfig:"RPC_TIMEOUT" default:"10m"`
 	TruncateWindow time.Duration `envconfig:"TRUNCATE_WINDOW" default:"1h"`
@@ -56,7 +60,10 @@ type Config struct {
 	CredentialsFile string `envconfig:"CREDENTIALS_FILE"`
 }
 
-// DB returns the database config.
-func (c *Config) DB() *database.Config {
+func (c *Config) DatabaseConfig() *database.Config {
 	return c.Database
 }
+
+func (c *Config) SecretManagerConfig() *secrets.Config {
+	return c.SecretManager
+}
diff --git a/internal/federationout/config.go b/internal/federationout/config.go
index 98b9b7e..0cfeb67 100644
--- a/internal/federationout/config.go
+++ b/internal/federationout/config.go
@@ -18,15 +18,19 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/secrets"
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
 // Compile-time check to assert this config matches requirements.
-var _ setup.DBConfigProvider = (*Config)(nil)
+var _ setup.DatabaseConfigProvider = (*Config)(nil)
+var _ setup.SecretManagerConfigProvider = (*Config)(nil)
 
 // Config is the configuration for the federation components (data sent to other servers).
 type Config struct {
-	Database       *database.Config
+	Database      *database.Config
+	SecretManager *secrets.Config
+
 	Port           string        `envconfig:"PORT" default:"8080"`
 	Timeout        time.Duration `envconfig:"RPC_TIMEOUT" default:"5m"`
 	TruncateWindow time.Duration `envconfig:"TRUNCATE_WINDOW" default:"1h"`
@@ -42,7 +46,10 @@ type Config struct {
 	TLSKeyFile  string `envconfig:"TLS_KEY_FILE"`
 }
 
-// DB returns the database config.
-func (c *Config) DB() *database.Config {
+func (c *Config) DatabaseConfig() *database.Config {
 	return c.Database
 }
+
+func (c *Config) SecretManagerConfig() *secrets.Config {
+	return c.SecretManager
+}
diff --git a/internal/generate/config.go b/internal/generate/config.go
index c0ff45a..45da6c5 100644
--- a/internal/generate/config.go
+++ b/internal/generate/config.go
@@ -19,15 +19,20 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/secrets"
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
 // Compile-time check to assert this config matches requirements.
-var _ setup.DBConfigProvider = (*Config)(nil)
+var _ setup.DatabaseConfigProvider = (*Config)(nil)
+var _ setup.SecretManagerConfigProvider = (*Config)(nil)
 
 // Config represents the configuration and associated environment variables for
 // the publish components.
 type Config struct {
+	Database      *database.Config
+	SecretManager *secrets.Config
+
 	Port             string        `envconfig:"PORT" default:"8080"`
 	NumExposures     int           `envconfig:"NUM_EXPOSURES_GENERATED" default:"10"`
 	KeysPerExposure  int           `envconfig:"KEYS_PER_EXPOSURE" default:"14"`
@@ -35,11 +40,12 @@ type Config struct {
 	MaxIntervalAge   time.Duration `envconfig:"MAX_INTERVAL_AGE_ON_PUBLISH" default:"360h"`
 	TruncateWindow   time.Duration `envconfig:"TRUNCATE_WINDOW" default:"1h"`
 	DefaultRegion    string        `envconfig:"DEFAULT_REGOIN" default:"US"`
-
-	Database *database.Config
 }
 
-// DB returns the configuration for the databse.
-func (c *Config) DB() *database.Config {
+func (c *Config) DatabaseConfig() *database.Config {
 	return c.Database
 }
+
+func (c *Config) SecretManagerConfig() *secrets.Config {
+	return c.SecretManager
+}
diff --git a/internal/monolith/monolith_util.go b/internal/monolith/monolith_util.go
index f0c39cb..86b704a 100644
--- a/internal/monolith/monolith_util.go
+++ b/internal/monolith/monolith_util.go
@@ -30,41 +30,56 @@ import (
 	"github.com/google/exposure-notifications-server/internal/handlers"
 	"github.com/google/exposure-notifications-server/internal/logging"
 	"github.com/google/exposure-notifications-server/internal/publish"
+	"github.com/google/exposure-notifications-server/internal/secrets"
 	"github.com/google/exposure-notifications-server/internal/setup"
+	"github.com/google/exposure-notifications-server/internal/signing"
 	"github.com/google/exposure-notifications-server/internal/storage"
 
 	// Enable observability with distributed tracing and metrics.
 	_ "github.com/google/exposure-notifications-server/internal/observability"
 )
 
-var _ setup.DBConfigProvider = (*MonoConfig)(nil)
 var _ setup.AuthorizedAppConfigProvider = (*MonoConfig)(nil)
-var _ setup.BlobStorageConfigProvider = (*MonoConfig)(nil)
-var _ setup.KeyManagerProvider = (*MonoConfig)(nil)
+var _ setup.BlobstoreConfigProvider = (*MonoConfig)(nil)
+var _ setup.DatabaseConfigProvider = (*MonoConfig)(nil)
+var _ setup.KeyManagerConfigProvider = (*MonoConfig)(nil)
+var _ setup.SecretManagerConfigProvider = (*MonoConfig)(nil)
 
 type MonoConfig struct {
-	Port string `envconfig:"PORT" default:"8080"`
-
 	AuthorizedApp *authorizedapp.Config
+	Storage       *storage.Config
 	Cleanup       *cleanup.Config
-	Export        *export.Config
-	Publish       *publish.Config
 	Database      *database.Config
+	Export        *export.Config
 	FederationIn  *federationin.Config
-	BlobstoreType string `envconfig:"BLOBSTORE_TYPE" default:"CLOUD_STORAGE"`
+	KeyManager    *signing.Config
+	Publish       *publish.Config
+	SecretManager *secrets.Config
+
+	Port string `envconfig:"PORT" default:"8080"`
 }
 
-func (c *MonoConfig) DB() *database.Config { return c.Database }
-func (c *MonoConfig) KeyManager() bool     { return true }
-func (c *MonoConfig) BlobStorage() storage.BlobstoreConfig {
-	return storage.BlobstoreConfig{
-		BlobstoreType: storage.BlobstoreType(c.BlobstoreType),
-	}
+func (c *MonoConfig) AuthorizedAppConfig() *authorizedapp.Config {
+	return c.AuthorizedApp
 }
-func (c *MonoConfig) AuthorizedAppConfig() *authorizedapp.Config { return c.AuthorizedApp }
 
-func RunServer(ctx context.Context) (*MonoConfig, error) {
+func (c *MonoConfig) BlobstoreConfig() *storage.Config {
+	return c.Storage
+}
 
+func (c *MonoConfig) DatabaseConfig() *database.Config {
+	return c.Database
+}
+
+func (c *MonoConfig) KeyManagerConfig() *signing.Config {
+	return c.KeyManager
+}
+
+func (c *MonoConfig) SecretManagerConfig() *secrets.Config {
+	return c.SecretManager
+}
+
+func RunServer(ctx context.Context) (*MonoConfig, error) {
 	logger := logging.FromContext(ctx)
 
 	var config MonoConfig
diff --git a/internal/observability/observability.go b/internal/observability/observability.go
index e04e8d7..3e1cca3 100644
--- a/internal/observability/observability.go
+++ b/internal/observability/observability.go
@@ -16,6 +16,7 @@ package observability
 
 import (
 	"os"
+	"strings"
 	"sync"
 
 	"contrib.go.opencensus.io/exporter/ocagent"
@@ -64,19 +65,19 @@ type traceAndViewExporter interface {
 }
 
 func exporter() traceAndViewExporter {
-	switch os.Getenv("OBSERVABILITY_EXPORTER") {
+	switch strings.ToUpper(os.Getenv("OBSERVABILITY_EXPORTER")) {
 	default:
 		// TODO: Add other trace and view exporters and break out of this.
 		return nil
 
-	case "ocagent":
+	case "OCAGENT":
 		oce, err := ocagent.NewExporter(ocagent.WithInsecure(), ocagent.WithAddress("localhost:55678"))
 		if err != nil {
 			panic(err)
 		}
 		return oce
 
-	case "stackdriver":
+	case "STACKDRIVER":
 		sde, err := stackdriver.NewExporter(stackdriver.Options{
 			ProjectID: os.Getenv("PROJECT_ID"),
 		})
diff --git a/internal/publish/config.go b/internal/publish/config.go
index f565930..73f25c5 100644
--- a/internal/publish/config.go
+++ b/internal/publish/config.go
@@ -20,16 +20,22 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/authorizedapp"
 	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/secrets"
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
 // Compile-time check to assert this config matches requirements.
 var _ setup.AuthorizedAppConfigProvider = (*Config)(nil)
-var _ setup.DBConfigProvider = (*Config)(nil)
+var _ setup.DatabaseConfigProvider = (*Config)(nil)
+var _ setup.SecretManagerConfigProvider = (*Config)(nil)
 
 // Config represents the configuration and associated environment variables for
 // the publish components.
 type Config struct {
+	AuthorizedApp *authorizedapp.Config
+	Database      *database.Config
+	SecretManager *secrets.Config
+
 	Port               string        `envconfig:"PORT" default:"8080"`
 	MinRequestDuration time.Duration `envconfig:"TARGET_REQUEST_DURATION" default:"5s"`
 	MaxKeysOnPublish   int           `envconfig:"MAX_KEYS_ON_PUBLISH" default:"15"`
@@ -39,17 +45,16 @@ type Config struct {
 	// Flags for local development and testing.
 	DebugAPIResponses   bool `envconfig:"DEBUG_API_RESPONSES"`
 	DebugAllowRestOfDay bool `envconfig:"DEBUG_ALLOW_REST_OF_DAY"`
-
-	AuthorizedApp *authorizedapp.Config
-	Database      *database.Config
 }
 
-// AuthorizedApp returns the configuration for authorizedapp.
 func (c *Config) AuthorizedAppConfig() *authorizedapp.Config {
 	return c.AuthorizedApp
 }
 
-// DB returns the configuration for the databse.
-func (c *Config) DB() *database.Config {
+func (c *Config) DatabaseConfig() *database.Config {
 	return c.Database
 }
+
+func (c *Config) SecretManagerConfig() *secrets.Config {
+	return c.SecretManager
+}
diff --git a/internal/publish/model/exposure_model.go b/internal/publish/model/exposure_model.go
index a0cb785..3965a2a 100644
--- a/internal/publish/model/exposure_model.go
+++ b/internal/publish/model/exposure_model.go
@@ -64,13 +64,15 @@ const (
 //  Note: This project doesn't directly include a diagnosis code verification System
 //        but does provide the ability to configure one in `serverevn.ServerEnv`
 type Publish struct {
-	Keys                      []ExposureKey `json:"temporaryExposureKeys"`
-	Regions                   []string      `json:"regions"`
-	AppPackageName            string        `json:"appPackageName"`
-	Platform                  string        `json:"platform"`
-	DeviceVerificationPayload string        `json:"deviceVerificationPayload"`
-	VerificationPayload       string        `json:"verificationPayload"`
-	Padding                   string        `json:"padding"`
+	Keys                []ExposureKey `json:"temporaryExposureKeys"`
+	Regions             []string      `json:"regions"`
+	AppPackageName      string        `json:"appPackageName"`
+	VerificationPayload string        `json:"verificationPayload"`
+	HMACKey             string        `json:"hmackey"`
+	Padding             string        `json:"padding"`
+
+	Platform                  string `json:"platform"`                  // DEPRECATED
+	DeviceVerificationPayload string `json:"deviceVerificationPayload"` // DEPRECATED
 }
 
 func (p *Publish) IsIOS() bool {
diff --git a/internal/secrets/cacher.go b/internal/secrets/cacher.go
index eec019d..b5d18f8 100644
--- a/internal/secrets/cacher.go
+++ b/internal/secrets/cacher.go
@@ -48,16 +48,16 @@ func NewCacher(ctx context.Context, f SecretManagerFunc, ttl time.Duration) (Sec
 		return nil, fmt.Errorf("cacher: %w", err)
 	}
 
-	return WrapCacher(ctx, sm, ttl)
+	return WrapCacher(ctx, sm, ttl), nil
 }
 
 // WrapCacher wraps an existing SecretManager with caching.
-func WrapCacher(ctx context.Context, sm SecretManager, ttl time.Duration) (SecretManager, error) {
+func WrapCacher(ctx context.Context, sm SecretManager, ttl time.Duration) SecretManager {
 	return &Cacher{
 		sm:    sm,
 		ttl:   ttl,
 		cache: make(map[string]*cachedItem),
-	}, nil
+	}
 }
 
 // GetSecretValue implements the SecretManager interface, but caches values and
diff --git a/internal/secrets/gcp_secret_manager.go b/internal/secrets/google_secret_manager.go
similarity index 78%
rename from internal/secrets/gcp_secret_manager.go
rename to internal/secrets/google_secret_manager.go
index 2e969a6..2307b37 100644
--- a/internal/secrets/gcp_secret_manager.go
+++ b/internal/secrets/google_secret_manager.go
@@ -23,21 +23,21 @@ import (
 )
 
 // Compile-time check to verify implements interface.
-var _ SecretManager = (*GCPSecretManager)(nil)
+var _ SecretManager = (*GoogleSecretManager)(nil)
 
-// GCPSecretManager implements SecretManager.
-type GCPSecretManager struct {
+// GoogleSecretManager implements SecretManager.
+type GoogleSecretManager struct {
 	client *secretmanager.Client
 }
 
-// NewGCPSecretManager creates a new secret manager for GCP.
-func NewGCPSecretManager(ctx context.Context) (SecretManager, error) {
+// NewGoogleSecretManager creates a new secret manager for GCP.
+func NewGoogleSecretManager(ctx context.Context) (SecretManager, error) {
 	client, err := secretmanager.NewClient(ctx)
 	if err != nil {
 		return nil, fmt.Errorf("secretmanager.NewClient: %w", err)
 	}
 
-	sm := &GCPSecretManager{
+	sm := &GoogleSecretManager{
 		client: client,
 	}
 
@@ -48,7 +48,7 @@ func NewGCPSecretManager(ctx context.Context) (SecretManager, error) {
 // of the format:
 //
 //     projects/my-project/secrets/my-secret/versions/123
-func (sm *GCPSecretManager) GetSecretValue(ctx context.Context, name string) (string, error) {
+func (sm *GoogleSecretManager) GetSecretValue(ctx context.Context, name string) (string, error) {
 	result, err := sm.client.AccessSecretVersion(ctx, &secretmanagerpb.AccessSecretVersionRequest{
 		Name: name,
 	})
diff --git a/internal/secrets/secrets.go b/internal/secrets/secrets.go
index 8ea4d51..ff74e3d 100644
--- a/internal/secrets/secrets.go
+++ b/internal/secrets/secrets.go
@@ -16,7 +16,26 @@
 // Allows for a different implementation to be bound within the servernv.ServeEnv
 package secrets
 
-import "context"
+import (
+	"context"
+	"fmt"
+	"time"
+)
+
+// SecretManagerType represents a type of secret manager.
+type SecretManagerType string
+
+const (
+	SecretManagerTypeAzureKeyVault        SecretManagerType = "AZURE_KEY_VAULT"
+	SecretManagerTypeGoogleSecretManager  SecretManagerType = "GOOGLE_SECRET_MANAGER"
+	SecretManagerTypeGoogleHashiCorpVault SecretManagerType = "HASHICORP_VAULT"
+)
+
+// Config represents the config for a secret manager.
+type Config struct {
+	SecretManagerType SecretManagerType `envconfig:"SECRET_MANAGER" default:"GOOGLE_SECRET_MANAGER"`
+	SecretCacheTTL    time.Duration     `envconfig:"SECRET_CACHE_TTL" default:"5m"`
+}
 
 // SecretManager defines the minimum shared functionality for a secret manager
 // used by this application.
@@ -26,3 +45,18 @@ type SecretManager interface {
 
 // SecretManagerFunc is a func that returns a secret manager or error.
 type SecretManagerFunc func(ctx context.Context) (SecretManager, error)
+
+// SecretManagerFor returns the secret manager for the given type, or an error
+// if one does not exist.
+func SecretManagerFor(ctx context.Context, typ SecretManagerType) (SecretManager, error) {
+	switch typ {
+	case SecretManagerTypeAzureKeyVault:
+		return NewAzureKeyVault(ctx)
+	case SecretManagerTypeGoogleSecretManager:
+		return NewGoogleSecretManager(ctx)
+	case SecretManagerTypeGoogleHashiCorpVault:
+		return NewHashiCorpVault(ctx)
+	}
+
+	return nil, fmt.Errorf("unknown secret manager type: %v", typ)
+}
diff --git a/internal/setup/setup.go b/internal/setup/setup.go
index 82b1040..7e7e859 100644
--- a/internal/setup/setup.go
+++ b/internal/setup/setup.go
@@ -17,7 +17,6 @@ package setup
 import (
 	"context"
 	"fmt"
-	"time"
 
 	"github.com/google/exposure-notifications-server/internal/authorizedapp"
 	"github.com/google/exposure-notifications-server/internal/database"
@@ -30,10 +29,16 @@ import (
 	"github.com/google/exposure-notifications-server/internal/storage"
 )
 
-// DBConfigProvider ensures that the environment config can provide a DB config.
+// BlobstoreConfigProvider provides the information about current storage
+// configuration.
+type BlobstoreConfigProvider interface {
+	BlobstoreConfig() *storage.Config
+}
+
+// DatabaseConfigProvider ensures that the environment config can provide a DB config.
 // All binaries in this application connect to the database via the same method.
-type DBConfigProvider interface {
-	DB() *database.Config
+type DatabaseConfigProvider interface {
+	DatabaseConfig() *database.Config
 }
 
 // AuthorizedAppConfigProvider signals that the config provided knows how to
@@ -42,31 +47,44 @@ type AuthorizedAppConfigProvider interface {
 	AuthorizedAppConfig() *authorizedapp.Config
 }
 
-// KeyManagerProvider is a marker interface indicating the KeyManagerProvider should be installed.
-type KeyManagerProvider interface {
-	KeyManager() bool
+// KeyManagerConfigProvider is a marker interface indicating the key manager
+// should be installed.
+type KeyManagerConfigProvider interface {
+	KeyManagerConfig() *signing.Config
 }
 
-// BlobStorageConfigProvider provides the information about current Blobstore configuration.
-type BlobStorageConfigProvider interface {
-	BlobStorage() storage.BlobstoreConfig
+// SecretManagerConfigProvider signals that the config knows how to configure a
+// secret manager.
+type SecretManagerConfigProvider interface {
+	SecretManagerConfig() *secrets.Config
 }
 
 // Function returned from setup to be deferred until the caller exits.
 type Defer func()
 
 // Setup runs common initialization code for all servers.
-func Setup(ctx context.Context, config DBConfigProvider) (*serverenv.ServerEnv, Defer, error) {
+func Setup(ctx context.Context, config DatabaseConfigProvider) (*serverenv.ServerEnv, Defer, error) {
 	logger := logging.FromContext(ctx)
 
-	// Can be changed with a different secret manager interface.
-	// TODO(mikehelmick): Make this extensible to other providers.
-	// TODO(sethvargo): Make TTL configurable.
-	sm, err := secrets.NewCacher(ctx, secrets.NewGCPSecretManager, 5*time.Minute)
-	if err != nil {
-		return nil, nil, fmt.Errorf("unable to connect to secret manager: %v", err)
+	// Load the secret manager - this needs to be loaded first because other
+	// processors may require access to secrets.
+	var sm secrets.SecretManager
+	if provider, ok := config.(SecretManagerConfigProvider); ok {
+		smConfig := provider.SecretManagerConfig()
+
+		var err error
+		sm, err = secrets.SecretManagerFor(ctx, smConfig.SecretManagerType)
+		if err != nil {
+			return nil, nil, fmt.Errorf("unable to connect to secret manager: %v", err)
+		}
+
+		// Enable caching, if provided.
+		if ttl := smConfig.SecretCacheTTL; ttl > 0 {
+			sm = secrets.WrapCacher(ctx, sm, ttl)
+		}
 	}
 
+	// Process first round of environment variables.
 	if err := envconfig.Process(ctx, config, sm); err != nil {
 		return nil, nil, fmt.Errorf("error loading environment variables: %v", err)
 	}
@@ -78,18 +96,20 @@ func Setup(ctx context.Context, config DBConfigProvider) (*serverenv.ServerEnv,
 		serverenv.WithMetricsExporter(metrics.NewLogsBasedFromContext),
 	}
 
-	// TODO(mikehelmick): Make this extensible to other providers.
-	if _, ok := config.(KeyManagerProvider); ok {
-		km, err := signing.NewGCPKMS(ctx)
+	// Configure key management for signing.
+	if provider, ok := config.(KeyManagerConfigProvider); ok {
+		kmConfig := provider.KeyManagerConfig()
+		km, err := signing.KeyManagerFor(ctx, kmConfig.KeyManagerType)
 		if err != nil {
 			return nil, nil, fmt.Errorf("unable to connect to key manager: %w", err)
 		}
 		opts = append(opts, serverenv.WithKeyManager(km))
 	}
 
-	if _, ok := config.(BlobStorageConfigProvider); ok {
-		provider := config.(BlobStorageConfigProvider)
-		blobStore, err := storage.CreateBlobstore(ctx, provider.BlobStorage())
+	// Configure blob storage.
+	if provider, ok := config.(BlobstoreConfigProvider); ok {
+		bsConfig := provider.BlobstoreConfig()
+		blobStore, err := storage.BlobstoreFor(ctx, bsConfig.BlobstoreType)
 		if err != nil {
 			return nil, nil, fmt.Errorf("unable to connect to storage system: %v", err)
 		}
@@ -98,13 +118,13 @@ func Setup(ctx context.Context, config DBConfigProvider) (*serverenv.ServerEnv,
 	}
 
 	// Setup the database connection.
-	db, err := database.NewFromEnv(ctx, config.DB())
+	db, err := database.NewFromEnv(ctx, config.DatabaseConfig())
 	if err != nil {
 		return nil, nil, fmt.Errorf("unable to connect to database: %v", err)
 	}
 	{
 		// Log the database config, but omit the password field.
-		redactedDB := config.DB()
+		redactedDB := config.DatabaseConfig()
 		redactedDB.Password = "<hidden>"
 		logger.Infof("Effective DB config: %+v", redactedDB)
 	}
diff --git a/internal/signing/gcpkms.go b/internal/signing/google_cloud_kms.go
similarity index 74%
rename from internal/signing/gcpkms.go
rename to internal/signing/google_cloud_kms.go
index a300879..643df42 100644
--- a/internal/signing/gcpkms.go
+++ b/internal/signing/google_cloud_kms.go
@@ -23,23 +23,23 @@ import (
 )
 
 // Compile-time check to verify implements interface.
-var _ KeyManager = (*GCPKMS)(nil)
+var _ KeyManager = (*GoogleCloudKMS)(nil)
 
-// GCPKMS implements the signing.KeyManager interface and can be used to sign
+// GoogleCloudKMS implements the signing.KeyManager interface and can be used to sign
 // export files.
-type GCPKMS struct {
+type GoogleCloudKMS struct {
 	client *kms.KeyManagementClient
 }
 
-func NewGCPKMS(ctx context.Context) (KeyManager, error) {
+func NewGoogleCloudKMS(ctx context.Context) (KeyManager, error) {
 	client, err := kms.NewKeyManagementClient(ctx)
 	if err != nil {
 		return nil, err
 	}
-	return &GCPKMS{client}, nil
+	return &GoogleCloudKMS{client}, nil
 }
 
-func (kms *GCPKMS) NewSigner(ctx context.Context, keyID string) (crypto.Signer, error) {
+func (kms *GoogleCloudKMS) NewSigner(ctx context.Context, keyID string) (crypto.Signer, error) {
 	signer, err := gcpkms.NewSigner(ctx, kms.client, keyID)
 	if err != nil {
 		return nil, err
diff --git a/internal/signing/signing.go b/internal/signing/signing.go
index eaca638..ab106c4 100644
--- a/internal/signing/signing.go
+++ b/internal/signing/signing.go
@@ -18,11 +18,37 @@ package signing
 import (
 	"context"
 	"crypto"
+	"fmt"
 )
 
+// KeyManagerType defines a specific key manager.
+type KeyManagerType string
+
+const (
+	KeyManagerTypeGoogleCloudKMS KeyManagerType = "GOOGLE_CLOUD_KMS"
+	KeyManagerTypeHashiCorpVault KeyManagerType = "HASHICORP_VAULT"
+)
+
+// Config defines configuration.
+type Config struct {
+	KeyManagerType KeyManagerType `envconfig:"KEY_MANAGER" default:"GOOGLE_CLOUD_KMS"`
+}
+
 // KeyManager defines the interface for working with a KMS system that
 // is able to sign bytes using PKI.
 // KeyManager implementations must be able to return a crypto.Signer
 type KeyManager interface {
 	NewSigner(ctx context.Context, keyID string) (crypto.Signer, error)
 }
+
+// KeyManagerFor returns the appropriate key manager for the given type.
+func KeyManagerFor(ctx context.Context, typ KeyManagerType) (KeyManager, error) {
+	switch typ {
+	case KeyManagerTypeGoogleCloudKMS:
+		return NewGoogleCloudKMS(ctx)
+	case KeyManagerTypeHashiCorpVault:
+		return NewHashiCorpVault(ctx)
+	}
+
+	return nil, fmt.Errorf("unknown key manager type: %v", typ)
+}
diff --git a/internal/storage/filesystem_storage.go b/internal/storage/filesystem_storage.go
index 92c1ba4..9088bab 100644
--- a/internal/storage/filesystem_storage.go
+++ b/internal/storage/filesystem_storage.go
@@ -38,7 +38,7 @@ func NewFilesystemStorage(ctx context.Context) (Blobstore, error) {
 
 // CreateObject creates a new object on the filesystem or overwrites an existing
 // one.
-func (s *FilesystemStorage) CreateObject(ctx context.Context, folder, filename string, contents []byte) error {
+func (s *FilesystemStorage) CreateObject(ctx context.Context, folder, filename string, contents []byte, cacheable bool) error {
 	pth := filepath.Join(folder, filename)
 	if err := ioutil.WriteFile(pth, contents, 0644); err != nil {
 		return fmt.Errorf("failed to create object: %w", err)
diff --git a/internal/storage/google_cloud_storage.go b/internal/storage/google_cloud_storage.go
index 9b73685..a69319d 100644
--- a/internal/storage/google_cloud_storage.go
+++ b/internal/storage/google_cloud_storage.go
@@ -43,8 +43,13 @@ func NewGoogleCloudStorage(ctx context.Context) (Blobstore, error) {
 }
 
 // CreateObject creates a new cloud storage object or overwrites an existing one.
-func (gcs *GoogleCloudStorage) CreateObject(ctx context.Context, bucket, objectName string, contents []byte) error {
+func (gcs *GoogleCloudStorage) CreateObject(ctx context.Context, bucket, objectName string, contents []byte, cacheable bool) error {
 	wc := gcs.client.Bucket(bucket).Object(objectName).NewWriter(ctx)
+	if !cacheable {
+		wc.Metadata = map[string]string{
+			"Cache-Control": "no-cache, max-age=0",
+		}
+	}
 	if _, err := wc.Write(contents); err != nil {
 		return fmt.Errorf("storage.Writer.Write: %w", err)
 	}
diff --git a/internal/storage/noop_storage.go b/internal/storage/noop_storage.go
new file mode 100644
index 0000000..b716671
--- /dev/null
+++ b/internal/storage/noop_storage.go
@@ -0,0 +1,35 @@
+// 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 storage
+
+import "context"
+
+// Compile-time check to verify implements.
+var _ Blobstore = (*NoopBlobstore)(nil)
+
+// NoopBlobstore is a blobstore that does nothing.
+type NoopBlobstore struct{}
+
+func NewNoopBlobstore(ctx context.Context) (Blobstore, error) {
+	return &NoopBlobstore{}, nil
+}
+
+func (s *NoopBlobstore) CreateObject(ctx context.Context, folder, filename string, contents []byte, cacheable bool) error {
+	return nil
+}
+
+func (s *NoopBlobstore) DeleteObject(ctx context.Context, folder, filename string) error {
+	return nil
+}
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
index 1e8769f..05377d4 100644
--- a/internal/storage/storage.go
+++ b/internal/storage/storage.go
@@ -18,63 +18,42 @@ package storage
 import (
 	"context"
 	"fmt"
-
-	"github.com/google/exposure-notifications-server/internal/logging"
 )
 
-// Identifies the type of Blobestore to use
+// BlobstoreType defines a specific blobstore.
 type BlobstoreType string
 
 const (
-	None          BlobstoreType = "NONE"
-	Cloud_storage BlobstoreType = "CLOUD_STORAGE"
-	Filesystem    BlobstoreType = "FILESYSTEM"
+	BlobstoreTypeGoogleCloudStorage BlobstoreType = "GOOGLE_CLOUD_STORAGE"
+	BlobstoreTypeFilesystem         BlobstoreType = "FILESYSTEM"
+	BlobstoreTypeNoop               BlobstoreType = "NOOP"
 )
 
-// Blobstore Configuration
-type BlobstoreConfig struct {
-	BlobstoreType BlobstoreType
+// Config defines the configuration for a blobstore.
+type Config struct {
+	BlobstoreType BlobstoreType `envconfig:"BLOBSTORE" default:"GOOGLE_CLOUD_STORAGE"`
 }
 
 // Blobstore defines the minimum interface for a blob storage system.
 type Blobstore interface {
 	// CreateObject creates or overwrites an object in the storage system.
-	CreateObject(ctx context.Context, bucket, objectName string, contents []byte) error
+	CreateObject(ctx context.Context, bucket, objectName string, contents []byte, cacheable bool) error
 
 	// DeleteObject deltes an object or does nothing if the object doesn't exist.
 	DeleteObject(ctx context.Context, bucket, objectName string) error
 }
 
-// Blobstore that does nothing.
-type NoopBlobstore struct{}
-
-func NewNoopBlobstore(ctx context.Context) (Blobstore, error) {
-	return &NoopBlobstore{}, nil
-}
-
-// No op.
-func (s *NoopBlobstore) CreateObject(ctx context.Context, folder, filename string, contents []byte) error {
-	return nil
-}
-
-// No op.
-func (s *NoopBlobstore) DeleteObject(ctx context.Context, folder, filename string) error {
-	return nil
-}
-
-// Creates a new BlobstoreFactory based on the provided BlobstoreType.
-func CreateBlobstore(ctx context.Context, config BlobstoreConfig) (Blobstore, error) {
-	logger := logging.FromContext(ctx)
-	logger.Infof("BlobstoreType is set up to %v", config.BlobstoreType)
-
-	switch config.BlobstoreType {
-	case Cloud_storage:
+// BlobstoreFor returns the blob store for the given type, or an error if one
+// does not exist.
+func BlobstoreFor(ctx context.Context, typ BlobstoreType) (Blobstore, error) {
+	switch typ {
+	case BlobstoreTypeGoogleCloudStorage:
 		return NewGoogleCloudStorage(ctx)
-	case Filesystem:
+	case BlobstoreTypeFilesystem:
 		return NewFilesystemStorage(ctx)
-	case None:
+	case BlobstoreTypeNoop:
 		return NewNoopBlobstore(ctx)
 	default:
-		return nil, fmt.Errorf("unknown BlobstoreType: %v", config.BlobstoreType)
+		return nil, fmt.Errorf("unknown blob store: %v", typ)
 	}
 }
diff --git a/internal/verification/database/health_authority_db.go b/internal/verification/database/health_authority_db.go
new file mode 100644
index 0000000..7937e28
--- /dev/null
+++ b/internal/verification/database/health_authority_db.go
@@ -0,0 +1,167 @@
+// 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 database
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/verification/model"
+
+	pgx "github.com/jackc/pgx/v4"
+)
+
+// HealthAuthorityDB allows for opreations against authorized health authorities
+// for diagnosis signature verification.
+type HealthAuthorityDB struct {
+	db *database.DB
+}
+
+// New creates a HealthAuthorityDB attached to a specific database driver.
+func New(db *database.DB) *HealthAuthorityDB {
+	return &HealthAuthorityDB{db}
+}
+
+// AddHealthAuthority inserts a new HealthAuthority record into the database.
+func (db *HealthAuthorityDB) AddHealthAuthority(ctx context.Context, ha *model.HealthAuthority) error {
+	if ha == nil {
+		return errors.New("provided HealthAuthority cannot be nil")
+	}
+	if len(ha.Keys) != 0 {
+		return fmt.Errorf("unable to insert health authority with keys, attach keys later")
+	}
+	if err := ha.Validate(); err != nil {
+		return err
+	}
+
+	return db.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
+		row := tx.QueryRow(ctx, `
+			INSERT INTO
+				HealthAuthority
+				(iss, aud, name)
+			VALUES
+				($1, $2, $3)
+			RETURNING id
+			`, ha.Issuer, ha.Audience, ha.Name)
+		if err := row.Scan(&ha.ID); err != nil {
+			return fmt.Errorf("inserting healthauthority: %w", err)
+		}
+		return nil
+	})
+}
+
+// GetHealthAuthority retrieves a HealthAuthority record by the issuer name.
+func (db *HealthAuthorityDB) GetHealthAuthority(ctx context.Context, issuer string) (*model.HealthAuthority, error) {
+	conn, err := db.db.Pool.Acquire(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("acquiring connection: %w", err)
+	}
+	defer conn.Release()
+
+	row := conn.QueryRow(ctx, `
+		SELECT
+			id, iss, aud, name
+		FROM
+			HealthAuthority
+		WHERE
+			iss = $1`, issuer)
+
+	var ha model.HealthAuthority
+	if err := row.Scan(&ha.ID, &ha.Issuer, &ha.Audience, &ha.Name); err != nil {
+		return nil, err
+	}
+
+	haks, err := db.GetHealthAuthorityKeys(ctx, &ha)
+	if err != nil {
+		return nil, err
+	}
+	ha.Keys = haks
+
+	return &ha, nil
+}
+
+func (db *HealthAuthorityDB) AddHealthAuthorityKey(ctx context.Context, ha *model.HealthAuthority, hak *model.HealthAuthorityKey) error {
+	if ha == nil {
+		return errors.New("provided HealthAuthority cannot be nil")
+	}
+	if hak == nil {
+		return errors.New("provided HealthAuthorityKey cannot be nil")
+	}
+
+	if err := hak.Validate(); err != nil {
+		return err
+	}
+	if ha.ID == 0 {
+		return errors.New("invalid health authority ID, must be non zero")
+	}
+
+	hak.AuthorityID = ha.ID
+	thru := db.db.NullableTime(hak.Thru)
+	return db.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
+		result, err := tx.Exec(ctx, `
+			INSERT INTO
+				HealthAuthorityKey
+				(health_authority_id, version, from_timestamp, thru_timestamp, public_key)
+			VALUES
+				($1, $2, $3, $4, $5)
+			`, hak.AuthorityID, hak.Version, hak.From, thru, hak.PublicKeyPEM)
+		if err != nil {
+			return fmt.Errorf("inserting healthauthoritykey: %w", err)
+		}
+		if result.RowsAffected() != 1 {
+			return fmt.Errorf("no rows inserted")
+		}
+		return nil
+	})
+}
+
+func (db *HealthAuthorityDB) GetHealthAuthorityKeys(ctx context.Context, ha *model.HealthAuthority) ([]*model.HealthAuthorityKey, error) {
+	conn, err := db.db.Pool.Acquire(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("acquiring connection: %w", err)
+	}
+	defer conn.Release()
+
+	rows, err := conn.Query(ctx, `
+		SELECT
+		  health_authority_id, version, from_timestamp, thru_timestamp, public_key
+		FROM
+		  HealthAuthorityKey
+		WHERE
+		  health_authority_id = $1`, ha.ID)
+	if err != nil {
+		return nil, err
+	}
+
+	var haks []*model.HealthAuthorityKey
+	for rows.Next() {
+		if rows.Err() != nil {
+			return nil, rows.Err()
+		}
+		var hak model.HealthAuthorityKey
+		var thru *time.Time
+		if err := rows.Scan(&hak.AuthorityID, &hak.Version, &hak.From, &thru, &hak.PublicKeyPEM); err != nil {
+			return nil, err
+		}
+		if thru != nil {
+			hak.Thru = *thru
+		}
+		haks = append(haks, &hak)
+	}
+	return haks, nil
+}
diff --git a/internal/verification/model/health_authority.go b/internal/verification/model/health_authority.go
new file mode 100644
index 0000000..1047dd8
--- /dev/null
+++ b/internal/verification/model/health_authority.go
@@ -0,0 +1,95 @@
+// 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 model
+
+import (
+	"crypto/ecdsa"
+	"crypto/x509"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"time"
+)
+
+// HealthAuthority represents a public health authority that is authorized to
+// issue diagnosis verification certificates accepted by this server.
+type HealthAuthority struct {
+	ID       int64
+	Issuer   string
+	Audience string
+	Name     string
+	Keys     []*HealthAuthorityKey
+}
+
+// Validate returns an error if the HealthAuthority struct is not valid.
+func (ha *HealthAuthority) Validate() error {
+	if ha.Issuer == "" {
+		return errors.New("issuer cannot be empty")
+	}
+	if ha.Audience == "" {
+		return errors.New("audience cannot be empty")
+	}
+	if ha.Name == "" {
+		return errors.New("name cannot be empty")
+	}
+	return nil
+}
+
+// HealthAuthorityKey represents a public key version for a given health authority.
+type HealthAuthorityKey struct {
+	AuthorityID  int64
+	Version      string
+	From         time.Time
+	Thru         time.Time
+	PublicKeyPEM string
+}
+
+// Validate returns an error if the HealthAuthorityKey is not valid.
+func (k *HealthAuthorityKey) Validate() error {
+	if _, err := k.PublicKey(); err != nil {
+		return fmt.Errorf("invalid public key PEM block: %w", err)
+	}
+	return nil
+}
+
+// IsValid returns true if the key is valid based on the current time.
+func (k *HealthAuthorityKey) IsValid() bool {
+	return k.IsValidAt(time.Now())
+}
+
+// IsValidAt returns true if the key is valid at a specific point in time.
+func (k *HealthAuthorityKey) IsValidAt(t time.Time) bool {
+	return t.After(k.From) && (k.Thru.IsZero() || k.Thru.After(t))
+}
+
+// PublicKey decodes the PublicKeyPEM text and returns the `*ecdsa.PublicKey`
+// This system only supports verifying ECDSA JWTs, `alg: ES256`
+func (k *HealthAuthorityKey) PublicKey() (*ecdsa.PublicKey, error) {
+	block, _ := pem.Decode([]byte(k.PublicKeyPEM))
+	if block == nil || block.Type != "PUBLIC KEY" {
+		return nil, errors.New("unable to decode PEM block containing PUBLIC KEY")
+	}
+	pub, err := x509.ParsePKIXPublicKey(block.Bytes)
+	if err != nil {
+		return nil, fmt.Errorf("x509.ParsePKIXPublicKey: %w", err)
+	}
+
+	switch typ := pub.(type) {
+	case *ecdsa.PublicKey:
+		return typ, nil
+	default:
+		return nil, fmt.Errorf("unsupported public key type: %T", typ)
+	}
+}
diff --git a/internal/verification/phaverify.go b/internal/verification/phaverify.go
new file mode 100644
index 0000000..0650974
--- /dev/null
+++ b/internal/verification/phaverify.go
@@ -0,0 +1,120 @@
+// 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 verification provides the ability to verify the diagnosis certificates
+// (JWTs) coming from public health authorities that are responsible for verifying
+// diagnosis pin codes and ceritfying the TEKs.
+package verification
+
+import (
+	"context"
+	"crypto/hmac"
+	"fmt"
+
+	aamodel "github.com/google/exposure-notifications-server/internal/authorizedapp/model"
+	"github.com/google/exposure-notifications-server/internal/base64util"
+	pubmodel "github.com/google/exposure-notifications-server/internal/publish/model"
+	"github.com/google/exposure-notifications-server/internal/verification/database"
+
+	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
+	utils "github.com/google/exposure-notifications-server/pkg/verification"
+
+	"github.com/dgrijalva/jwt-go"
+)
+
+// Verifier can be used to verify public health authority diagnosis verification certificates.
+type Verifier struct {
+	db *database.HealthAuthorityDB
+}
+
+// New creates a new verifier, based on this DB handle.
+func New(db *database.HealthAuthorityDB) *Verifier {
+	return &Verifier{db}
+}
+
+// VerifyDiagnosisCertificate accepts a publish request (from which is extracts the JWT),
+// fully verifies the JWT and signture against what the passed in authorrized app is allowed
+// to use. Returns any transmission risk overrides if they are present.
+func (v *Verifier) VerifyDiagnosisCertificate(ctx context.Context, authApp *aamodel.AuthorizedApp, publish *pubmodel.Publish) (verifyapi.TransmissionRiskVector, error) {
+	// These get assigned during the ParseWithClaims closure.
+	var healthAuthorityID int64
+	var claims *verifyapi.VerificationClaims
+
+	// Unpack JWT so we can determine issuer and key version.
+	token, err := jwt.ParseWithClaims(publish.VerificationPayload, &verifyapi.VerificationClaims{}, func(token *jwt.Token) (interface{}, error) {
+		if method, ok := token.Method.(*jwt.SigningMethodECDSA); !ok || method.Name != jwt.SigningMethodES256.Name {
+			return nil, fmt.Errorf("unsupported signing method, must be %v", jwt.SigningMethodES256.Name)
+		}
+
+		var ok bool
+		claims, ok = token.Claims.(*verifyapi.VerificationClaims)
+		if !ok {
+			return nil, fmt.Errorf("does not contain expected claim set")
+		}
+
+		// Based on issuer, load the key versions.
+		ha, err := v.db.GetHealthAuthority(ctx, claims.Issuer)
+		if err != nil {
+			return nil, fmt.Errorf("error looking up issuer: %v : %w", claims.Issuer, err)
+		}
+
+		// Find a key version.
+		for _, hak := range ha.Keys {
+			// Key version matches and the key is valid based on the current time.
+			if hak.Version == claims.KeyVersion && hak.IsValid() {
+				healthAuthorityID = ha.ID
+				// Extract the public key from the PEM block.
+				return hak.PublicKey()
+			}
+		}
+		return nil, fmt.Errorf("key not found: iss: %v version: %v", claims.Issuer, claims.KeyVersion)
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	if !token.Valid {
+		return nil, fmt.Errorf("invalid verificationPayload")
+	}
+	if err := claims.Valid(); err != nil {
+		return nil, err
+	}
+
+	// JWT is valid and signature is valid.
+	// This is chacked after the signature verification to prevent timing attacks.
+	if _, ok := authApp.AllowedHealthAuthorityIDs[healthAuthorityID]; !ok {
+		return nil, fmt.Errorf("app %v has not authorized health authority issuer: %v", authApp.AppPackageName, claims.Issuer)
+	}
+
+	// Verify the HMAC.
+	jwtHMAC, err := base64util.DecodeString(claims.SignedMAC)
+	if err != nil {
+		return nil, fmt.Errorf("error decoding HMAC from claims: %w", err)
+	}
+	secret, err := base64util.DecodeString(publish.HMACKey)
+	if err != nil {
+		return nil, fmt.Errorf("error decoding HMAC secret from publish request: %w", err)
+	}
+	wantHMAC, err := utils.CalculateExposureKeyHMAC(publish.Keys, secret)
+	if err != nil {
+		return nil, fmt.Errorf("calculating expected HMAC: %w", err)
+	}
+
+	if !hmac.Equal(wantHMAC, jwtHMAC) {
+		return nil, fmt.Errorf("HMAC mismatch, publish request does not match disgnosis verification certificate")
+	}
+
+	// Everything looks good. Return the transmission override vector.
+	return claims.TransmissionRisks, nil
+}
diff --git a/migrations/000026_health_authority_config.down.sql b/migrations/000026_health_authority_config.down.sql
new file mode 100644
index 0000000..e37e3fc
--- /dev/null
+++ b/migrations/000026_health_authority_config.down.sql
@@ -0,0 +1,22 @@
+-- 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.
+
+BEGIN;
+
+DROP TABLE HealthAuthorityKey;
+DROP TABLE HealthAuthority;
+
+ALTER TABLE AuthorizedApp DROP COLUMN allowed_health_authority_ids;
+
+END;
diff --git a/migrations/000026_health_authority_config.up.sql b/migrations/000026_health_authority_config.up.sql
new file mode 100644
index 0000000..cd49b0b
--- /dev/null
+++ b/migrations/000026_health_authority_config.up.sql
@@ -0,0 +1,38 @@
+-- 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.
+
+BEGIN;
+
+CREATE TABLE HealthAuthority(
+	id SERIAL PRIMARY KEY,
+	iss VARCHAR(200) NOT NULL,
+	aud VARCHAR(200) NOT NULL,
+  name VARCHAR(200) NOT NULL,
+  CONSTRAINT uniqueiss UNIQUE(iss)
+);
+
+CREATE TABLE HealthAuthorityKey(
+	health_authority_id INT NOT NULL REFERENCES HealthAuthority(id),
+	version varchar(100) NOT NULL,
+	from_timestamp TIMESTAMPTZ NOT NULL,
+	thru_timestamp TIMESTAMPTZ,
+	public_key VARCHAR(500),
+	PRIMARY KEY(health_authority_id, version)
+);
+
+ALTER TABLE AuthorizedApp
+  ADD COLUMN allowed_health_authority_ids int[];
+
+
+END;
diff --git a/pkg/api/v1alpha1/verification_types.go b/pkg/api/v1alpha1/verification_types.go
index 55a01f2..24fa661 100644
--- a/pkg/api/v1alpha1/verification_types.go
+++ b/pkg/api/v1alpha1/verification_types.go
@@ -17,13 +17,17 @@
 // These APIs are not considered to be stable at HEAD.
 package v1alpha1
 
-import "sort"
+import (
+	"sort"
+
+	"github.com/dgrijalva/jwt-go"
+)
 
 const (
-	exposureKeyHMACClaim          = "tekmac"
-	healthAuthorityDataClaim      = "phadata"
-	transmissionRiskOverrideClaim = "tris"
-	keyVersionClaim               = "keyVersion"
+	ExposureKeyHMACClaim          = "tekmac"
+	HealthAuthorityDataClaim      = "phadata"
+	TransmissionRiskOverrideClaim = "trisk"
+	KeyVersionClaim               = "keyVersion"
 )
 
 // TransmissionRiskVector is an additional set of claims that can be
@@ -53,3 +57,18 @@ func (a TransmissionRiskVector) Less(i, j int) bool {
 func (a TransmissionRiskVector) Swap(i, j int) {
 	a[i], a[j] = a[j], a[i]
 }
+
+type VerificationClaims struct {
+	PHAClaims         map[string]string      `json:"phadata"`
+	TransmissionRisks TransmissionRiskVector `json:"trisk"`
+	SignedMAC         string                 `json:"tekmac"`
+	KeyVersion        string                 `json:"keyVersion"`
+	jwt.StandardClaims
+}
+
+func NewVerificationClaims() *VerificationClaims {
+	return &VerificationClaims{
+		PHAClaims:         make(map[string]string),
+		TransmissionRisks: []TransmissionRiskOverride{},
+	}
+}
diff --git a/pkg/verification/utils.go b/pkg/verification/utils.go
new file mode 100644
index 0000000..c18a474
--- /dev/null
+++ b/pkg/verification/utils.go
@@ -0,0 +1,50 @@
+// 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 verification
+
+import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"fmt"
+	"sort"
+	"strings"
+
+	"github.com/google/exposure-notifications-server/internal/publish/model"
+)
+
+// CalculateExposureKeyHMAC will calculate the verification protocol HMAC value.
+// Input keys are already to be base64 encoded. They will be sorted if necessary.
+func CalculateExposureKeyHMAC(keys []model.ExposureKey, secret []byte) ([]byte, error) {
+	if len(keys) == 0 {
+		return nil, fmt.Errorf("cannot calculate hmac on empty exposure keys")
+	}
+	// Sort by the key
+	sort.Slice(keys, func(i int, j int) bool {
+		return strings.Compare(keys[i].Key, keys[j].Key) <= 0
+	})
+
+	// Build the cleartext.
+	perKeyText := make([]string, 0, len(keys))
+	for _, ek := range keys {
+		perKeyText = append(perKeyText,
+			fmt.Sprintf("%s.%d.%d.%d", ek.Key, ek.IntervalNumber, ek.IntervalCount, ek.TransmissionRisk))
+	}
+
+	cleartext := strings.Join(perKeyText, ",")
+	mac := hmac.New(sha256.New, secret)
+	mac.Write([]byte(cleartext))
+
+	return mac.Sum(nil), nil
+}


Test patch:
diff --git a/internal/authorizedapp/database/authorized_app_test.go b/internal/authorizedapp/database/authorized_app_test.go
index da529bd..6474c61 100644
--- a/internal/authorizedapp/database/authorized_app_test.go
+++ b/internal/authorizedapp/database/authorized_app_test.go
@@ -54,11 +54,12 @@ func TestAuthorizedAppLifecycle(t *testing.T) {
 	}
 
 	source := &model.AuthorizedApp{
-		AppPackageName:      "myapp",
-		Platform:            "both",
-		AllowedRegions:      map[string]struct{}{"US": {}},
-		SafetyNetDisabled:   true,
-		DeviceCheckDisabled: true,
+		AppPackageName:            "myapp",
+		Platform:                  "both",
+		AllowedRegions:            map[string]struct{}{"US": {}},
+		AllowedHealthAuthorityIDs: map[int64]struct{}{1: {}, 2: {}},
+		SafetyNetDisabled:         true,
+		DeviceCheckDisabled:       true,
 	}
 
 	if err := aadb.InsertAuthorizedApp(ctx, source); err != nil {
@@ -134,31 +135,33 @@ func TestGetAuthorizedApp(t *testing.T) {
 		{
 			name: "bare",
 			sql: `
-				INSERT INTO AuthorizedApp (app_package_name, platform, allowed_regions)
-				VALUES ($1, $2, $3)
+				INSERT INTO AuthorizedApp (app_package_name, platform, allowed_regions, allowed_health_authority_ids)
+				VALUES ($1, $2, $3, $4)
 			`,
-			args: []interface{}{"myapp", "android", []string{"US"}},
+			args: []interface{}{"myapp", "android", []string{"US"}, []int64{1}},
 			exp: &model.AuthorizedApp{
-				AppPackageName:           "myapp",
-				Platform:                 "android",
-				AllowedRegions:           map[string]struct{}{"US": {}},
-				SafetyNetBasicIntegrity:  true,
-				SafetyNetCTSProfileMatch: true,
+				AppPackageName:            "myapp",
+				Platform:                  "android",
+				AllowedRegions:            map[string]struct{}{"US": {}},
+				AllowedHealthAuthorityIDs: map[int64]struct{}{1: {}},
+				SafetyNetBasicIntegrity:   true,
+				SafetyNetCTSProfileMatch:  true,
 			},
 		},
 		{
 			name: "all_regions",
 			sql: `
-				INSERT INTO AuthorizedApp (app_package_name, platform, allowed_regions)
-				VALUES ($1, $2, $3)
+				INSERT INTO AuthorizedApp (app_package_name, platform, allowed_regions, allowed_health_authority_ids)
+				VALUES ($1, $2, $3, $4)
 			`,
-			args: []interface{}{"myapp", "android", []string{}},
+			args: []interface{}{"myapp", "android", []string{}, []int64{1}},
 			exp: &model.AuthorizedApp{
-				AppPackageName:           "myapp",
-				Platform:                 "android",
-				AllowedRegions:           map[string]struct{}{},
-				SafetyNetBasicIntegrity:  true,
-				SafetyNetCTSProfileMatch: true,
+				AppPackageName:            "myapp",
+				Platform:                  "android",
+				AllowedRegions:            map[string]struct{}{},
+				AllowedHealthAuthorityIDs: map[int64]struct{}{1: {}},
+				SafetyNetBasicIntegrity:   true,
+				SafetyNetCTSProfileMatch:  true,
 			},
 		},
 		{
@@ -178,13 +181,14 @@ func TestGetAuthorizedApp(t *testing.T) {
 				false, []string{"092fcfb", "252f10c"}, false, false,
 			},
 			exp: &model.AuthorizedApp{
-				AppPackageName:           "myapp",
-				Platform:                 "android",
-				AllowedRegions:           map[string]struct{}{},
-				SafetyNetDisabled:        false,
-				SafetyNetApkDigestSHA256: []string{"092fcfb", "252f10c"},
-				SafetyNetBasicIntegrity:  false,
-				SafetyNetCTSProfileMatch: false,
+				AppPackageName:            "myapp",
+				Platform:                  "android",
+				AllowedRegions:            map[string]struct{}{},
+				AllowedHealthAuthorityIDs: map[int64]struct{}{},
+				SafetyNetDisabled:         false,
+				SafetyNetApkDigestSHA256:  []string{"092fcfb", "252f10c"},
+				SafetyNetBasicIntegrity:   false,
+				SafetyNetCTSProfileMatch:  false,
 			},
 		},
 
@@ -198,12 +202,13 @@ func TestGetAuthorizedApp(t *testing.T) {
 			`,
 			args: []interface{}{"myapp", "android", []string{"US"}, 1800},
 			exp: &model.AuthorizedApp{
-				AppPackageName:           "myapp",
-				Platform:                 "android",
-				AllowedRegions:           map[string]struct{}{"US": {}},
-				SafetyNetBasicIntegrity:  true,
-				SafetyNetCTSProfileMatch: true,
-				SafetyNetPastTime:        30 * time.Minute,
+				AppPackageName:            "myapp",
+				Platform:                  "android",
+				AllowedRegions:            map[string]struct{}{"US": {}},
+				AllowedHealthAuthorityIDs: map[int64]struct{}{},
+				SafetyNetBasicIntegrity:   true,
+				SafetyNetCTSProfileMatch:  true,
+				SafetyNetPastTime:         30 * time.Minute,
 			},
 		},
 		{
@@ -216,12 +221,13 @@ func TestGetAuthorizedApp(t *testing.T) {
 			`,
 			args: []interface{}{"myapp", "android", []string{"US"}, 1800},
 			exp: &model.AuthorizedApp{
-				AppPackageName:           "myapp",
-				Platform:                 "android",
-				AllowedRegions:           map[string]struct{}{"US": {}},
-				SafetyNetBasicIntegrity:  true,
-				SafetyNetCTSProfileMatch: true,
-				SafetyNetFutureTime:      30 * time.Minute,
+				AppPackageName:            "myapp",
+				Platform:                  "android",
+				AllowedRegions:            map[string]struct{}{"US": {}},
+				AllowedHealthAuthorityIDs: map[int64]struct{}{},
+				SafetyNetBasicIntegrity:   true,
+				SafetyNetCTSProfileMatch:  true,
+				SafetyNetFutureTime:       30 * time.Minute,
 			},
 		},
 		{
@@ -243,6 +249,7 @@ func TestGetAuthorizedApp(t *testing.T) {
 				AppPackageName:              "myapp",
 				Platform:                    "ios",
 				AllowedRegions:              map[string]struct{}{"US": {}},
+				AllowedHealthAuthorityIDs:   map[int64]struct{}{},
 				SafetyNetCTSProfileMatch:    true,
 				SafetyNetBasicIntegrity:     true,
 				DeviceCheckDisabled:         false,
diff --git a/internal/export/server_test.go b/internal/export/server_test.go
index 58e7a9d..63c74ec 100644
--- a/internal/export/server_test.go
+++ b/internal/export/server_test.go
@@ -28,7 +28,7 @@ import (
 // TestNewServer tests NewServer().
 func TestNewServer(t *testing.T) {
 	emptyStorage := &storage.GoogleCloudStorage{}
-	emptyKMS := &signing.GCPKMS{}
+	emptyKMS := &signing.GoogleCloudKMS{}
 	emptyDB := &database.DB{}
 	ctx := context.Background()
 
diff --git a/internal/secrets/cacher_test.go b/internal/secrets/cacher_test.go
index 2b25062..b9c7080 100644
--- a/internal/secrets/cacher_test.go
+++ b/internal/secrets/cacher_test.go
@@ -34,10 +34,7 @@ func TestCacher_GetSecretValue(t *testing.T) {
 	ctx := context.Background()
 
 	sm := &testSecretManager{value: "first"}
-	cached, err := WrapCacher(ctx, sm, 250*time.Millisecond)
-	if err != nil {
-		t.Fatal(err)
-	}
+	cached := WrapCacher(ctx, sm, 250*time.Millisecond)
 
 	// Read the value once, which should cache it.
 	if _, err := cached.GetSecretValue(ctx, "secret"); err != nil {
diff --git a/internal/storage/filesystem_storage_test.go b/internal/storage/filesystem_storage_test.go
index c900f4e..0a9c129 100644
--- a/internal/storage/filesystem_storage_test.go
+++ b/internal/storage/filesystem_storage_test.go
@@ -63,7 +63,7 @@ func TestFilesystemStorage_CreateObject(t *testing.T) {
 				t.Fatal(err)
 			}
 
-			err = storage.CreateObject(ctx, tc.folder, tc.filepath, tc.contents)
+			err = storage.CreateObject(ctx, tc.folder, tc.filepath, tc.contents, false)
 			if (err != nil) != tc.err {
 				t.Fatal(err)
 			}
diff --git a/internal/verification/database/health_authority_db_test.go b/internal/verification/database/health_authority_db_test.go
new file mode 100644
index 0000000..fbc6504
--- /dev/null
+++ b/internal/verification/database/health_authority_db_test.go
@@ -0,0 +1,101 @@
+// 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 database
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/verification/model"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+const (
+	validPEM = `-----BEGIN PUBLIC KEY-----
+MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEA+k9YktDK3UpOhBIy+O17biuwd/g
+IBSEEHOdgpAynz0yrHpkWL6vxjNHxRdWcImZxPgL0NVHMdY4TlsL7qaxBQ==
+-----END PUBLIC KEY-----`
+)
+
+func TestAddRetrieveHealthAuthority(t *testing.T) {
+	t.Parallel()
+
+	testDB := database.NewTestDatabase(t)
+	ctx := context.Background()
+
+	want := &model.HealthAuthority{
+		Issuer:   "doh.mystate.gov",
+		Audience: "ens.usacovid.org",
+		Name:     "My State Department of Healthiness",
+	}
+
+	haDB := New(testDB)
+	if err := haDB.AddHealthAuthority(ctx, want); err != nil {
+		t.Fatal(err)
+	}
+
+	got, err := haDB.GetHealthAuthority(ctx, want.Issuer)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Fatalf("mismatch (-want, +got):\n%s", diff)
+	}
+}
+
+func TestAddRetrieveHealthAuthorityKeys(t *testing.T) {
+	t.Parallel()
+
+	testDB := database.NewTestDatabase(t)
+	ctx := context.Background()
+
+	want := &model.HealthAuthority{
+		Issuer:   "doh.mystate.gov",
+		Audience: "ens.usacovid.org",
+		Name:     "My State Department of Healthiness",
+	}
+
+	haDB := New(testDB)
+	if err := haDB.AddHealthAuthority(ctx, want); err != nil {
+		t.Fatal(err)
+	}
+
+	wantKeys := []*model.HealthAuthorityKey{
+		&model.HealthAuthorityKey{
+			Version:      "v1",
+			From:         time.Now().Truncate(time.Second),
+			PublicKeyPEM: validPEM,
+		},
+	}
+
+	if err := haDB.AddHealthAuthorityKey(ctx, want, wantKeys[0]); err != nil {
+		t.Fatal(err)
+	}
+	want.Keys = wantKeys
+
+	// Reading back the HA will also read back the keys.
+	got, err := haDB.GetHealthAuthority(ctx, want.Issuer)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Fatalf("mismatch (-want, +got):\n%s", diff)
+	}
+}
diff --git a/internal/verification/model/health_authority_test.go b/internal/verification/model/health_authority_test.go
new file mode 100644
index 0000000..9eab202
--- /dev/null
+++ b/internal/verification/model/health_authority_test.go
@@ -0,0 +1,124 @@
+// 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 model
+
+import (
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestIsValid(t *testing.T) {
+	now := time.Now().UTC()
+	cases := []struct {
+		name  string
+		from  time.Time
+		thru  time.Time
+		valid bool
+	}{
+		{
+			name:  "valid now",
+			from:  now.Add(-1 * time.Minute),
+			thru:  now.Add(1 * time.Minute),
+			valid: true,
+		},
+		{
+			name:  "valid no expiration",
+			from:  now.Add(-1 * time.Minute),
+			thru:  time.Time{},
+			valid: true,
+		},
+		{
+			name:  "not valid yet",
+			from:  now.Add(1 * time.Minute),
+			valid: false,
+		},
+		{
+			name:  "expired",
+			from:  now.Add(-2 * time.Minute),
+			thru:  now.Add(-1 * time.Second),
+			valid: false,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			hak := HealthAuthorityKey{
+				From: tc.from,
+				Thru: tc.thru,
+			}
+			if valid := hak.IsValid(); valid != tc.valid {
+				t.Errorf("IsValid: want: %v got: %v", tc.valid, valid)
+			}
+		})
+	}
+}
+
+func TestPublicKeyParse(t *testing.T) {
+	cases := []struct {
+		name     string
+		pemBlock string
+		msg      string
+	}{
+		{
+			name: "valid PEM",
+			pemBlock: `-----BEGIN PUBLIC KEY-----
+MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEA+k9YktDK3UpOhBIy+O17biuwd/g
+IBSEEHOdgpAynz0yrHpkWL6vxjNHxRdWcImZxPgL0NVHMdY4TlsL7qaxBQ==
+-----END PUBLIC KEY-----`,
+		},
+		{
+			name: "invalid PEM",
+			pemBlock: `-----BEGIN PUBLIC KEY-----
+totally invalid
+-----END PUBLIC KEY-----`,
+			msg: "unable to decode PEM block containing PUBLIC KEY",
+		},
+		{
+			name: "wrong key type",
+			pemBlock: `-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvs3MAjWBFJecFLwT4lhd
+HxXbn7EaVbx3/JgiXG3Q3PCCxEYQq6SRYp/4qJpZJ2nAW+BoMCxZjTBq8bmby3WT
+js5A/G62dLgq5qKRsny6kw2ix3tFXb0I9TsPSUieVmxPgioFF1ytvIU7wKQ07vAZ
+HW05DlJJM3E9WhB/ZVKl9NmVp01CcojfhmENPNu65XaAWEMp4txyyX7rU8iPPSsK
+QCmoWZQ6r1E1r5+/RumIobbwdYxax3esvC4B3W2jyLFqMJGVBrhWf7tDki/3mCub
+NTG3+oqI0Q6a3kPOuAAAupr373j7O1YXrM2KAix966EPwTNlK7YCcJa0m6PKz9DT
+6wIDAQAB
+-----END PUBLIC KEY-----`,
+			msg: "unsupported public key type: *rsa.PublicKey",
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			hak := HealthAuthorityKey{
+				PublicKeyPEM: tc.pemBlock,
+			}
+
+			k, err := hak.PublicKey()
+			if err != nil {
+				if tc.msg == "" {
+					t.Errorf("unexpected error: %v", err)
+				} else if !strings.Contains(err.Error(), tc.msg) {
+					t.Errorf("missing error text: want '%v', got '%v'", tc.msg, err.Error())
+				}
+			} else if k == nil {
+				t.Errorf("ECDSA public key is unexpectedly nil")
+			}
+
+		})
+	}
+
+}
diff --git a/internal/verification/phaverify_test.go b/internal/verification/phaverify_test.go
new file mode 100644
index 0000000..8a3cba1
--- /dev/null
+++ b/internal/verification/phaverify_test.go
@@ -0,0 +1,154 @@
+// 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 verification provides the ability to verify the diagnosis certificates
+// (JWTs) coming from public health authorities that are responsible for verifying
+// diagnosis pin codes and ceritfying the TEKs.
+package verification
+
+import (
+	"context"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/pem"
+	"testing"
+	"time"
+
+	"github.com/dgrijalva/jwt-go"
+
+	aamodel "github.com/google/exposure-notifications-server/internal/authorizedapp/model"
+	coredb "github.com/google/exposure-notifications-server/internal/database"
+	pubmodel "github.com/google/exposure-notifications-server/internal/publish/model"
+	"github.com/google/exposure-notifications-server/internal/verification/database"
+	"github.com/google/exposure-notifications-server/internal/verification/model"
+
+	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
+	utils "github.com/google/exposure-notifications-server/pkg/verification"
+)
+
+// TODO(mikehelmick) : this only tests the happy path. Write non happy tests.
+func TestVerifyCertificate(t *testing.T) {
+	t.Parallel()
+
+	// Generate ECDSA key pair.
+	privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		t.Fatal(err)
+	}
+	publicKey := privateKey.Public()
+
+	// Get the PEM for the public key.
+	x509EncodedPub, err := x509.MarshalPKIXPublicKey(publicKey)
+	if err != nil {
+		t.Fatal(err)
+	}
+	pemEncodedPub := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: x509EncodedPub})
+	pemPublicKey := string(pemEncodedPub)
+
+	// Set up database. Create HealthAuthority + HAKey for the test.
+	testDB := coredb.NewTestDatabase(t)
+	ctx := context.Background()
+	haDB := database.New(testDB)
+
+	healthAuthority := model.HealthAuthority{
+		Issuer:   "doh.my.gov",
+		Audience: "exposure-notifications-server",
+		Name:     "Very Real Health Authority",
+	}
+	if err := haDB.AddHealthAuthority(ctx, &healthAuthority); err != nil {
+		t.Fatal(err)
+	}
+
+	hak := model.HealthAuthorityKey{
+		Version:      "v1",
+		From:         time.Now(),
+		PublicKeyPEM: pemPublicKey,
+	}
+	if err := haDB.AddHealthAuthorityKey(ctx, &healthAuthority, &hak); err != nil {
+		t.Fatal(err)
+	}
+
+	// Build the verification certificate.
+	hmacKeyBytes := make([]byte, 32)
+	if _, err := rand.Read(hmacKeyBytes); err != nil {
+		t.Fatal(err)
+	}
+	hmacKeyB64 := base64.StdEncoding.EncodeToString(hmacKeyBytes)
+
+	// Fake authorized app.
+	authApp := aamodel.NewAuthorizedApp()
+	authApp.AllowedHealthAuthorityIDs[healthAuthority.ID] = struct{}{}
+
+	// Build a sample certificate.
+	publish := pubmodel.Publish{
+		Keys: []pubmodel.ExposureKey{
+			{
+				Key:              "IRgYIhYiy4WMl9z68bMk6w==",
+				IntervalNumber:   2650032,
+				IntervalCount:    144,
+				TransmissionRisk: 4,
+			},
+			{
+				Key:              "dPCphLzfG4uzXneNimkPRQ====",
+				IntervalNumber:   2650032 + 144,
+				IntervalCount:    144,
+				TransmissionRisk: 4,
+			},
+			{
+				Key:              "5AUyPfJKcg5cr3OgKdR8Sw==",
+				IntervalNumber:   2650032 + 144*2,
+				IntervalCount:    144,
+				TransmissionRisk: 4,
+			},
+		},
+	}
+
+	// Calculate the HMAC.
+	hmac, err := utils.CalculateExposureKeyHMAC(publish.Keys, hmacKeyBytes)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	claims := verifyapi.NewVerificationClaims()
+	claims.Audience = "exposure-notifications-server"
+	claims.Issuer = "doh.my.gov"
+	claims.IssuedAt = time.Now().UTC().Unix()
+	claims.ExpiresAt = time.Now().UTC().Add(5 * time.Minute).Unix()
+	claims.SignedMAC = base64.StdEncoding.EncodeToString(hmac) // would be generated on the client and passed through.
+	claims.KeyVersion = "v1"                                   // matches the key configured above.
+	// leaves PHAClaims and transmission risk overrides out of it.
+
+	token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+	jwtText, err := token.SignedString(privateKey)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Insert this data into the publish request.
+	publish.VerificationPayload = jwtText
+	publish.HMACKey = hmacKeyB64
+
+	// Actually test the verify code.
+	verifier := New(haDB)
+	overrides, err := verifier.VerifyDiagnosisCertificate(ctx, authApp, &publish)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(overrides) != 0 {
+		t.Errorf("wanted no overrides, got %v", overrides)
+	}
+}
diff --git a/pkg/verification/utils_test.go b/pkg/verification/utils_test.go
new file mode 100644
index 0000000..6079374
--- /dev/null
+++ b/pkg/verification/utils_test.go
@@ -0,0 +1,54 @@
+// 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 verification
+
+import (
+	"encoding/base64"
+	"testing"
+
+	"github.com/google/exposure-notifications-server/internal/publish/model"
+)
+
+func TestCalculateHac(t *testing.T) {
+	secret := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
+
+	eKeys := []model.ExposureKey{
+		{
+			Key:              "z2Cx9hdz2SlxZ8GEgqTYpA==",
+			IntervalNumber:   1,
+			IntervalCount:    144,
+			TransmissionRisk: 3,
+		},
+		{
+			Key:              "dPCphLzfG4uzXneNimkPRQ==",
+			IntervalNumber:   144,
+			IntervalCount:    144,
+			TransmissionRisk: 5,
+		},
+	}
+
+	mac, err := CalculateExposureKeyHMAC(eKeys, secret)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	got := base64.StdEncoding.EncodeToString(mac)
+	want := "2u1nHt5WWurJytFLF3xitNzM99oNrad2y4YGOL53AeY="
+	// Normally, to verify we would calculate this again, and verify with
+	// hmac.Equals. This is just verifying the calculation code in this package.
+	if got != want {
+		t.Fatalf("incorrect mac, want: %v, got %v", want, mac)
+	}
+}