Repository: google/exposure-notifications-server
Pull request #313 (2020-05-16 01:51:23)

Refactor apiconfig interfaces
This takes us from 6 layers of indirection to 2. It's still not perfect.

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/313#" title="Author self-approved">sethvargo</a>*

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

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

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

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

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
sethvargo: /assign @mikehelmick 
sethvargo: /hold until @mikehelmick approves
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/313#pullrequestreview-413039399" title="Approved">crwilcox</a>*, *<a href="https://github.com/google/exposure-notifications-server/pull/313#" title="Author self-approved">sethvargo</a>*

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

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

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

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

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
google-prow-robot: New changes are detected. LGTM label has been removed.
google-prow-robot: @sethvargo: The following test **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
pull-release-unit | 344d4734e234850b098d390ff3b8ee02a891e25e | [link](https://prow-internal.gcpnode.com/view/gcs/gke-prow/pr-logs/pull/google_exposure-notifications-server/313/pull-release-unit/1261696083310940160) | `/test pull-release-unit`

[Full PR test history](https://prow-internal.gcpnode.com/pr-history?org=google&repo=exposure-notifications-server&pr=313). [Your PR dashboard](https://gubernator-internal.googleplex.com/pr/sethvargo).

<details>

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

Patch:
diff --git a/cmd/exposure/main.go b/cmd/exposure/main.go
index dc6c06a..68869f5 100644
--- a/cmd/exposure/main.go
+++ b/cmd/exposure/main.go
@@ -37,6 +37,15 @@ func main() {
 	}
 	defer closer()
 
+	if config.BypassDeviceCheck {
+		logger.Errorf("Bypassing DeviceCheck verification for iOS devices. " +
+			"This should only be done in test environments!")
+	}
+	if config.BypassSafetyNet {
+		logger.Errorf("Bypassing SafetyNet verification for Android devices. " +
+			"This should only be done in test environments!")
+	}
+
 	handler, err := publish.NewHandler(ctx, &config, env)
 	if err != nil {
 		logger.Fatalf("unable to create publish handler: %v", err)
diff --git a/cmd/monolith/main.go b/cmd/monolith/main.go
index 59e60d3..a75a491 100644
--- a/cmd/monolith/main.go
+++ b/cmd/monolith/main.go
@@ -20,9 +20,9 @@ import (
 	"fmt"
 	"net/http"
 
+	"github.com/google/exposure-notifications-server/internal/apiconfig"
 	"github.com/google/exposure-notifications-server/internal/cleanup"
 	"github.com/google/exposure-notifications-server/internal/database"
-	"github.com/google/exposure-notifications-server/internal/dbapiconfig"
 	"github.com/google/exposure-notifications-server/internal/export"
 	"github.com/google/exposure-notifications-server/internal/federationin"
 	"github.com/google/exposure-notifications-server/internal/handlers"
@@ -31,21 +31,26 @@ import (
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
+var _ setup.DBConfigProvider = (*MonoConfig)(nil)
+var _ setup.APIConfigProvider = (*MonoConfig)(nil)
+var _ setup.BlobStorageConfigProvider = (*MonoConfig)(nil)
+var _ setup.KeyManagerProvider = (*MonoConfig)(nil)
+
 type MonoConfig struct {
 	Port string `envconfig:"PORT" default:"8080"`
 
-	APIConfigOpts *dbapiconfig.ConfigOpts
-	Cleanup       *cleanup.Config
-	Export        *export.Config
-	Publish       *publish.Config
-	Database      *database.Config
-	FederationIn  *federationin.Config
+	APIConfig    *apiconfig.Config
+	Cleanup      *cleanup.Config
+	Export       *export.Config
+	Publish      *publish.Config
+	Database     *database.Config
+	FederationIn *federationin.Config
 }
 
-func (c *MonoConfig) DB() *database.Config         { return c.Database }
-func (c *MonoConfig) KeyManager() bool             { return true }
-func (c *MonoConfig) BlobStorage() bool            { return true }
-func (c *MonoConfig) API() *dbapiconfig.ConfigOpts { return c.APIConfigOpts }
+func (c *MonoConfig) DB() *database.Config               { return c.Database }
+func (c *MonoConfig) KeyManager() bool                   { return true }
+func (c *MonoConfig) BlobStorage() bool                  { return true }
+func (c *MonoConfig) APIConfigConfig() *apiconfig.Config { return c.APIConfig }
 
 func main() {
 	ctx := context.Background()
diff --git a/internal/android/nonce.go b/internal/android/nonce.go
deleted file mode 100644
index 97518d8..0000000
--- a/internal/android/nonce.go
+++ /dev/null
@@ -1,95 +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 android
-
-import (
-	"crypto/sha256"
-	"encoding/base64"
-	"fmt"
-	"sort"
-	"strings"
-
-	"github.com/google/exposure-notifications-server/internal/model"
-)
-
-// Noncer definces an interface for providing Nonce strings.
-type Noncer interface {
-	// Nonce returns the expected nonce given input data.
-	Nonce() string
-}
-
-// Compile-time check to assert NonceData implements the Noncer interface.
-var _ Noncer = (*nonceData)(nil)
-
-type nonceData struct {
-	appPackageName string
-	ttKeysBase64   []model.ExposureKey
-	regions        []string
-	verification   string
-}
-
-// NewNonce Creates a new `Noncer{}` based on the inbound publish request.
-// This ensures that the data in the request is the same data that was used
-// to create the device attestation.
-func NewNonce(publish *model.Publish) Noncer {
-	// base64 keys are to be lexographically sorted
-	sortedKeys := make([]model.ExposureKey, len(publish.Keys))
-	copy(sortedKeys, publish.Keys)
-	sort.Slice(sortedKeys, func(i int, j int) bool {
-		return sortedKeys[i].Key < sortedKeys[j].Key
-	})
-
-	// regions are to be uppercased and then lexographically sorted
-	sortedRegions := make([]string, len(publish.Regions))
-	for i, r := range publish.Regions {
-		sortedRegions[i] = strings.ToUpper(r)
-	}
-	sort.Strings(sortedRegions)
-
-	return &nonceData{
-		appPackageName: publish.AppPackageName,
-		ttKeysBase64:   sortedKeys,
-		regions:        sortedRegions,
-		verification:   publish.VerificationPayload,
-	}
-}
-
-// Nonce returns the expected nonce for this data, from this application.
-func (n *nonceData) Nonce() string {
-	keys := make([]string, 0, len(n.ttKeysBase64))
-	for _, k := range n.ttKeysBase64 {
-		keys = append(keys, fmt.Sprintf("%v.%v.%v.%v", k.Key, k.IntervalNumber, k.IntervalCount, k.TransmissionRisk))
-	}
-
-	// The cleartext is a combination of all of the data on the request
-	// in a specific order.
-	//
-	// appPackageName|key[,key]|region[,region]|verificationAuthorityName
-	// Keys are ancoded as
-	//     base64(exposureKey).itnervalNumber.IntervalCount.transmissionRisk
-	// When there is > 1 key, keys are comma separated.
-	// Keys must in sorted order based on the sorting of the base64 exposure key.
-	// Regions are uppercased, sorted, and comma sepreated
-	cleartext :=
-		n.appPackageName + "|" +
-			strings.Join(keys, ",") + "|" + // where key is b64key.intervalNum.intervalCount
-			strings.Join(n.regions, ",") + "|" +
-			n.verification
-
-	// Take the sha256 checksum of that data
-	sum := sha256.Sum256([]byte(cleartext))
-	// Base64 encode the result.
-	return base64.StdEncoding.EncodeToString(sum[:])
-}
diff --git a/internal/android/safetynet.go b/internal/android/safetynet.go
index f833301..e1fccc1 100644
--- a/internal/android/safetynet.go
+++ b/internal/android/safetynet.go
@@ -26,6 +26,7 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/base64util"
 	"github.com/google/exposure-notifications-server/internal/logging"
+	"github.com/google/exposure-notifications-server/internal/model"
 
 	"github.com/dgrijalva/jwt-go"
 )
@@ -34,7 +35,7 @@ import (
 type VerifyOpts struct {
 	AppPkgName      string
 	APKDigest       []string
-	Nonce           Noncer
+	Nonce           string
 	CTSProfileMatch bool
 	BasicIntegrity  bool
 	MinValidTime    time.Time
@@ -45,7 +46,7 @@ type VerifyOpts struct {
 // matches the properties that we expect based on the applications APIConfig entry.
 // See https://developer.android.com/training/safetynet/attestation#use-response-server
 // for details on the format of these attestations.
-func ValidateAttestation(ctx context.Context, attestation string, opts VerifyOpts) error {
+func ValidateAttestation(ctx context.Context, attestation string, opts *VerifyOpts) error {
 	defer trace.StartRegion(ctx, "ValidateAttestation").End()
 	logger := logging.FromContext(ctx)
 
@@ -55,7 +56,7 @@ func ValidateAttestation(ctx context.Context, attestation string, opts VerifyOpt
 	}
 
 	// Validate the nonce.
-	if opts.Nonce == nil || opts.Nonce.Nonce() == "" {
+	if opts.Nonce == "" {
 		return fmt.Errorf("missing nonce")
 	}
 	nonceClaimB64, ok := claims["nonce"].(string)
@@ -67,7 +68,7 @@ func ValidateAttestation(ctx context.Context, attestation string, opts VerifyOpt
 		return fmt.Errorf("unable to decode nonce claim data: %w", err)
 	}
 	nonceClaim := string(nonceClaimBytes)
-	nonceCalculated := opts.Nonce.Nonce()
+	nonceCalculated := opts.Nonce
 	if nonceCalculated != nonceClaim {
 		return fmt.Errorf("nonce mismatch: expected %v got %v", nonceCalculated, nonceClaim)
 	}
@@ -136,6 +137,32 @@ func ValidateAttestation(ctx context.Context, attestation string, opts VerifyOpt
 	return nil
 }
 
+// VerifyOptsFor returns the Android SafetyNet verification options to be used
+// based on the API config, request time, and nonce.
+func VerifyOptsFor(c *model.APIConfig, from time.Time, nonce string) *VerifyOpts {
+	digests := make([]string, len(c.ApkDigestSHA256))
+	copy(digests, c.ApkDigestSHA256)
+	rtn := &VerifyOpts{
+		AppPkgName:      c.AppPackageName,
+		CTSProfileMatch: c.CTSProfileMatch,
+		BasicIntegrity:  c.BasicIntegrity,
+		APKDigest:       digests,
+		Nonce:           nonce,
+	}
+
+	// Calculate the valid time window based on now + config options.
+	if c.AllowedPastTime > 0 {
+		minTime := from.Add(-c.AllowedPastTime)
+		rtn.MinValidTime = minTime
+	}
+	if c.AllowedFutureTime > 0 {
+		maxTime := from.Add(c.AllowedFutureTime)
+		rtn.MaxValidTime = maxTime
+	}
+
+	return rtn
+}
+
 // The keyFunc is based on the Android sample code
 // https://github.com/googlesamples/android-play-safetynet/blob/d7513a54e2f28c0dcd7f8d8d0fa03adb5d87b91a/server/java/src/main/java/OfflineVerify.java
 func keyFunc(ctx context.Context, tok *jwt.Token) (interface{}, error) {
diff --git a/internal/apiconfig/config.go b/internal/apiconfig/config.go
new file mode 100644
index 0000000..022e7b0
--- /dev/null
+++ b/internal/apiconfig/config.go
@@ -0,0 +1,30 @@
+// 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 apiconfig
+
+import (
+	"time"
+)
+
+type Config struct {
+	// CacheDuration is the amount of time APIConfigs should be cached before
+	// being re-read from their provider.
+	CacheDuration time.Duration `envconfig:"APICONFIG_CACHE_DURATION" default:"5m"`
+}
+
+// APIConfig implements an interface for setup.
+func (c *Config) APIConfigConfig() *Config {
+	return c
+}
diff --git a/internal/apiconfig/database_provider.go b/internal/apiconfig/database_provider.go
new file mode 100644
index 0000000..3f59476
--- /dev/null
+++ b/internal/apiconfig/database_provider.go
@@ -0,0 +1,141 @@
+// 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 apiconfig
+
+import (
+	"context"
+	"fmt"
+	"sync"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/logging"
+	"github.com/google/exposure-notifications-server/internal/model"
+	"github.com/google/exposure-notifications-server/internal/secrets"
+)
+
+// Compile-time check to assert implementation.
+var _ Provider = (*DatabaseProvider)(nil)
+
+// DatabaseProvider is a Provider that pulls from the database and caches and
+// refreshes values on failure.
+type DatabaseProvider struct {
+	database      *database.DB
+	secretManager secrets.SecretManager
+	cacheDuration time.Duration
+
+	cache     map[string]*cacheItem
+	cacheLock sync.RWMutex
+}
+
+type cacheItem struct {
+	value    *model.APIConfig
+	cachedAt time.Time
+}
+
+// DatabaseProviderOption is used as input to the database provider.
+type DatabaseProviderOption func(*DatabaseProvider) *DatabaseProvider
+
+// WithSecretManager sets the secret manager for resolving secrets.
+func WithSecretManager(sm secrets.SecretManager) DatabaseProviderOption {
+	return func(p *DatabaseProvider) *DatabaseProvider {
+		p.secretManager = sm
+		return p
+	}
+}
+
+// NewDatabaseProvider creates a new Provider that reads from a database.
+func NewDatabaseProvider(ctx context.Context, db *database.DB, config *Config, opts ...DatabaseProviderOption) (Provider, error) {
+	provider := &DatabaseProvider{
+		database:      db,
+		cacheDuration: config.CacheDuration,
+		cache:         make(map[string]*cacheItem),
+	}
+
+	// Apply options.
+	for _, opt := range opts {
+		provider = opt(provider)
+	}
+
+	return provider, nil
+}
+
+// AppConfig returns the config for the given app package name.
+func (p *DatabaseProvider) AppConfig(ctx context.Context, name string) (*model.APIConfig, error) {
+	logger := logging.FromContext(ctx)
+
+	// Acquire a read lock first, which allows concurrent readers, to check if
+	// there's an item in the cache.
+	p.cacheLock.RLock()
+	item, ok := p.cache[name]
+	if ok && time.Since(item.cachedAt) <= p.cacheDuration {
+		if item.value == nil {
+			p.cacheLock.RUnlock()
+			return nil, AppNotFound
+		}
+		p.cacheLock.RUnlock()
+		return item.value, nil
+	}
+	p.cacheLock.RUnlock()
+
+	// Acquire a more aggressive lock now because we're about to mutate. However,
+	// it's possible that a concurrent routine has already mutated between our
+	// read and write locks, so we have to check again.
+	p.cacheLock.Lock()
+	item, ok = p.cache[name]
+	if ok && time.Since(item.cachedAt) <= p.cacheDuration {
+		if item.value == nil {
+			p.cacheLock.Unlock()
+			return nil, AppNotFound
+		}
+		p.cacheLock.Unlock()
+		return item.value, nil
+	}
+
+	// Load config.
+	config, err := p.loadAPIConfigFromDatabase(ctx, name)
+	if err != nil {
+		return nil, fmt.Errorf("apiconfig: %w", err)
+	}
+
+	// Cache configs.
+	logger.Infof("apiconfig: loaded %v, caching for %s", name, p.cacheDuration)
+	p.cache[name] = &cacheItem{
+		value:    config,
+		cachedAt: time.Now(),
+	}
+
+	// Handle not found.
+	if config == nil {
+		p.cacheLock.Unlock()
+		return nil, AppNotFound
+	}
+
+	// Returned config.
+	return config, nil
+}
+
+// loadAPIConfigFromDatabase is a lower-level private API that actually loads and parses
+// a single APIConfigs from the database.
+func (p *DatabaseProvider) loadAPIConfigFromDatabase(ctx context.Context, name string) (*model.APIConfig, error) {
+	logger := logging.FromContext(ctx)
+
+	logger.Infof("apiconfig: loading %v from database", name)
+	config, err := p.database.GetAPIConfig(ctx, p.secretManager, name)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read %v from database: %w", name, err)
+	}
+	return config, nil
+}
diff --git a/internal/apiconfig/memory_provider.go b/internal/apiconfig/memory_provider.go
new file mode 100644
index 0000000..52526be
--- /dev/null
+++ b/internal/apiconfig/memory_provider.go
@@ -0,0 +1,48 @@
+// 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 apiconfig
+
+import (
+	"context"
+
+	"github.com/google/exposure-notifications-server/internal/model"
+)
+
+// Compile-time check to assert implementation.
+var _ Provider = (*MemoryProvider)(nil)
+
+// MemoryProvider is an Provider that stores values in-memory. It is primarily
+// used for testing.
+type MemoryProvider struct {
+	Data map[string]*model.APIConfig
+}
+
+// NewMemoryProvider creates a new Provider that reads from a database.
+func NewMemoryProvider(ctx context.Context, _ *Config) (Provider, error) {
+	provider := &MemoryProvider{
+		Data: make(map[string]*model.APIConfig),
+	}
+
+	return provider, nil
+}
+
+// AppConfig returns the config for the given app package name.
+func (p *MemoryProvider) AppConfig(ctx context.Context, name string) (*model.APIConfig, error) {
+	val, ok := p.Data[name]
+	if !ok {
+		return nil, AppNotFound
+	}
+	return val, nil
+}
diff --git a/internal/apiconfig/provider.go b/internal/apiconfig/provider.go
new file mode 100644
index 0000000..360a3c9
--- /dev/null
+++ b/internal/apiconfig/provider.go
@@ -0,0 +1,34 @@
+// 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 apiconfig
+
+import (
+	"context"
+	"errors"
+
+	"github.com/google/exposure-notifications-server/internal/model"
+)
+
+// AppNotFound is the sentinel error returned when AppConfig fails to find an
+// app with the given name.
+var AppNotFound = errors.New("app not found")
+
+// Provider defines possible APIConfig providers.
+type Provider interface {
+	// AppConfig returns the application-specific configuration for the given
+	// name. An error is returned if the configuration fails to load. An error is
+	// returned if no app with the given name is registered in the system.
+	AppConfig(ctx context.Context, name string) (*model.APIConfig, error)
+}
diff --git a/internal/database/apiconfig.go b/internal/database/apiconfig.go
index 13ee544..eb01e3f 100644
--- a/internal/database/apiconfig.go
+++ b/internal/database/apiconfig.go
@@ -21,12 +21,14 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/ios"
-	"github.com/google/exposure-notifications-server/internal/model/apiconfig"
+	"github.com/google/exposure-notifications-server/internal/model"
 	"github.com/google/exposure-notifications-server/internal/secrets"
+	pgx "github.com/jackc/pgx/v4"
 )
 
-// ReadAPIConfigs loads all APIConfig values from the database.
-func (db *DB) ReadAPIConfigs(ctx context.Context, sm secrets.SecretManager) ([]*apiconfig.APIConfig, error) {
+// GetAPIConfig loads a single API config for the given name. If no row exists,
+// this returns nil.
+func (db *DB) GetAPIConfig(ctx context.Context, sm secrets.SecretManager, name string) (*model.APIConfig, error) {
 	conn, err := db.pool.Acquire(ctx)
 	if err != nil {
 		return nil, fmt.Errorf("acquiring connection: %v", err)
@@ -39,84 +41,74 @@ func (db *DB) ReadAPIConfigs(ctx context.Context, sm secrets.SecretManager) ([]*
 			allowed_past_seconds, allowed_future_seconds, allowed_regions, all_regions,
 			ios_devicecheck_team_id_secret, ios_devicecheck_key_id_secret, ios_devicecheck_private_key_secret
 		FROM
-			APIConfig`
-	rows, err := conn.Query(ctx, query)
-	if err != nil {
-		return nil, err
-	}
-	defer rows.Close()
+			APIConfig
+		WHERE app_package_name = $1`
 
-	// In most instances, we expect a single config entry.
-	var result []*apiconfig.APIConfig
-	for rows.Next() {
-		if err := rows.Err(); err != nil {
-			return nil, fmt.Errorf("iterating rows: %w", err)
-		}
+	row := conn.QueryRow(ctx, query, name)
 
-		var regions []string
-		config := apiconfig.New()
-		var allowedPastSeconds, allowedFutureSeconds *int
-		var deviceCheckTeamIDSecret, deviceCheckKeyIDSecret, deviceCheckPrivateKeySecret sql.NullString
-		if err := rows.Scan(&config.AppPackageName, &config.Platform, &config.ApkDigestSHA256,
-			&config.CTSProfileMatch, &config.BasicIntegrity,
-			&allowedPastSeconds, &allowedFutureSeconds, &regions, &config.AllowAllRegions,
-			&deviceCheckTeamIDSecret, &deviceCheckKeyIDSecret, &deviceCheckPrivateKeySecret); err != nil {
-			return nil, err
+	config := model.NewAPIConfig()
+	var allowedRegions []string
+	var allowedPastSeconds, allowedFutureSeconds *int
+	var deviceCheckTeamIDSecret, deviceCheckKeyIDSecret, deviceCheckPrivateKeySecret sql.NullString
+	if err := row.Scan(
+		&config.AppPackageName, &config.Platform, &config.ApkDigestSHA256, &config.CTSProfileMatch, &config.BasicIntegrity,
+		&allowedPastSeconds, &allowedFutureSeconds, &allowedRegions, &config.AllowAllRegions,
+		&deviceCheckTeamIDSecret, &deviceCheckKeyIDSecret, &deviceCheckPrivateKeySecret,
+	); err != nil {
+		if err == pgx.ErrNoRows {
+			return nil, nil
 		}
+		return nil, err
+	}
 
-		// Convert time in seconds from DB into time.Duration
-		if allowedPastSeconds != nil {
-			d := time.Duration(*allowedPastSeconds) * time.Second
-			config.AllowedPastTime = d
-		}
-		if allowedFutureSeconds != nil {
-			d := time.Duration(*allowedFutureSeconds) * time.Second
-			config.AllowedFutureTime = d
-		}
+	// Convert time in seconds from DB into time.Duration
+	if allowedPastSeconds != nil {
+		d := time.Duration(*allowedPastSeconds) * time.Second
+		config.AllowedPastTime = d
+	}
+	if allowedFutureSeconds != nil {
+		d := time.Duration(*allowedFutureSeconds) * time.Second
+		config.AllowedFutureTime = d
+	}
 
-		// build the regions map
-		for _, r := range regions {
-			config.AllowedRegions[r] = true
-		}
+	// build the regions map
+	for _, r := range allowedRegions {
+		config.AllowedRegions[r] = struct{}{}
+	}
 
-		// Resolve secrets to their plaintext values
-		if v := deviceCheckTeamIDSecret; v.Valid && v.String != "" {
-			plaintext, err := sm.GetSecretValue(ctx, v.String)
-			if err != nil {
-				return nil, fmt.Errorf("ios_devicecheck_team_id_secret at %s (%s): %w",
-					config.AppPackageName, config.Platform, err)
-			}
-			config.DeviceCheckTeamID = plaintext
+	// Resolve secrets to their plaintext values
+	if v := deviceCheckTeamIDSecret; v.Valid && v.String != "" {
+		plaintext, err := sm.GetSecretValue(ctx, v.String)
+		if err != nil {
+			return nil, fmt.Errorf("ios_devicecheck_team_id_secret at %s (%s): %w",
+				config.AppPackageName, config.Platform, err)
 		}
+		config.DeviceCheckTeamID = plaintext
+	}
 
-		if v := deviceCheckKeyIDSecret; v.Valid && v.String != "" {
-			plaintext, err := sm.GetSecretValue(ctx, v.String)
-			if err != nil {
-				return nil, fmt.Errorf("ios_devicecheck_key_id_secret at %s (%s): %w",
-					config.AppPackageName, config.Platform, err)
-			}
-			config.DeviceCheckKeyID = plaintext
+	if v := deviceCheckKeyIDSecret; v.Valid && v.String != "" {
+		plaintext, err := sm.GetSecretValue(ctx, v.String)
+		if err != nil {
+			return nil, fmt.Errorf("ios_devicecheck_key_id_secret at %s (%s): %w",
+				config.AppPackageName, config.Platform, err)
 		}
+		config.DeviceCheckKeyID = plaintext
+	}
 
-		if v := deviceCheckPrivateKeySecret; v.Valid && v.String != "" {
-			plaintext, err := sm.GetSecretValue(ctx, v.String)
-			if err != nil {
-				return nil, fmt.Errorf("ios_devicecheck_private_key_secret at %s (%s): %w",
-					config.AppPackageName, config.Platform, err)
-			}
-
-			key, err := ios.ParsePrivateKey(plaintext)
-			if err != nil {
-				return nil, fmt.Errorf("failed to parse private key at %s (%s): %w",
-					config.AppPackageName, config.Platform, err)
-			}
-			config.DeviceCheckPrivateKey = key
+	if v := deviceCheckPrivateKeySecret; v.Valid && v.String != "" {
+		plaintext, err := sm.GetSecretValue(ctx, v.String)
+		if err != nil {
+			return nil, fmt.Errorf("ios_devicecheck_private_key_secret at %s (%s): %w",
+				config.AppPackageName, config.Platform, err)
 		}
 
-		result = append(result, config)
-	}
-	if err := rows.Err(); err != nil {
-		return nil, err
+		key, err := ios.ParsePrivateKey(plaintext)
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse private key at %s (%s): %w",
+				config.AppPackageName, config.Platform, err)
+		}
+		config.DeviceCheckPrivateKey = key
 	}
-	return result, nil
+
+	return config, nil
 }
diff --git a/internal/dbapiconfig/apiconfig.go b/internal/dbapiconfig/apiconfig.go
deleted file mode 100644
index 7965b4f..0000000
--- a/internal/dbapiconfig/apiconfig.go
+++ /dev/null
@@ -1,126 +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 dbapiconfig
-
-import (
-	"context"
-	"fmt"
-	"sync"
-	"time"
-
-	provider "github.com/google/exposure-notifications-server/internal/config"
-	"github.com/google/exposure-notifications-server/internal/database"
-	"github.com/google/exposure-notifications-server/internal/logging"
-	"github.com/google/exposure-notifications-server/internal/model/apiconfig"
-	"github.com/google/exposure-notifications-server/internal/secrets"
-)
-
-// Compile-time check to assert Config implements the Provider interface.
-var _ provider.Provider = (*dbConfigProvider)(nil)
-
-type dbConfigProvider struct {
-	db            *database.DB
-	sm            secrets.SecretManager
-	refreshPeriod time.Duration
-
-	bypassSafetyNet   bool
-	bypassDeviceCheck bool
-
-	mu           sync.RWMutex
-	lastLoadTime time.Time
-	cache        map[string]*apiconfig.APIConfig
-}
-
-type ConfigOpts struct {
-	RefreshDuration   time.Duration `envconfig:"API_CONFIG_REFRESH" default:"5m"`
-	BypassSafetyNet   bool          `envconfig:"BYPASS_SAFETYNET"`
-	BypassDeviceCheck bool          `envconfig:"BYPASS_DEVICECHECK"`
-}
-
-func NewConfigProvider(db *database.DB, sm secrets.SecretManager, opts *ConfigOpts) (provider.Provider, error) {
-	ctx := context.Background()
-	logger := logging.FromContext(ctx)
-
-	cfg := &dbConfigProvider{
-		db:                db,
-		sm:                sm,
-		refreshPeriod:     opts.RefreshDuration,
-		bypassSafetyNet:   opts.BypassSafetyNet,
-		bypassDeviceCheck: opts.BypassDeviceCheck,
-		cache:             make(map[string]*apiconfig.APIConfig),
-	}
-
-	// TODO (mikehelmick): Load device check private key from secret store.
-
-	if cfg.refreshPeriod <= 0 {
-		return nil, fmt.Errorf("refresh duration must be a positive duration")
-	}
-	if cfg.refreshPeriod > time.Minute*5 {
-		logger.Warnf("config refresh duration is > 5 minutes: %v", cfg.refreshPeriod)
-	}
-
-	if cfg.bypassSafetyNet {
-		logger.Errorf("APIConfig provider will bypass SafetyNet verification for android devices. This should only be done in test environments.")
-	}
-	if cfg.bypassDeviceCheck {
-		logger.Errorf("APIConfig provider will bypass DeviceCheck verification for iOS devices. This should only be done in test environments.")
-	}
-
-	return cfg, nil
-}
-
-func (c *dbConfigProvider) loadConfig(ctx context.Context) error {
-	// In case multiple requests notice expiration simultaneously, only do it once.
-	c.mu.Lock()
-	defer c.mu.Unlock()
-
-	// if the cache isn't expired, don't reload.
-	if time.Since(c.lastLoadTime) < c.refreshPeriod {
-		return nil
-	}
-
-	logger := logging.FromContext(ctx)
-
-	configs, err := c.db.ReadAPIConfigs(ctx, c.sm)
-	if err != nil {
-		return err
-	}
-
-	c.cache = make(map[string]*apiconfig.APIConfig)
-	for _, apiConfig := range configs {
-		apiConfig.BypassSafetyNet = c.bypassSafetyNet
-		c.cache[apiConfig.AppPackageName] = apiConfig
-	}
-	logger.Info("loaded new APIConfig values")
-	c.lastLoadTime = time.Now()
-	return nil
-}
-
-func (c *dbConfigProvider) AppPkgConfig(ctx context.Context, appPkg string) (*apiconfig.APIConfig, error) {
-	if err := c.loadConfig(ctx); err != nil {
-		return nil, err
-	}
-
-	c.mu.RLock()
-	defer c.mu.RUnlock()
-
-	cfg, ok := c.cache[appPkg]
-	if !ok {
-		logger := logging.FromContext(ctx)
-		logger.Errorf("requested config for unconfigured app: %v", appPkg)
-		return nil, nil
-	}
-	return cfg, nil
-}
diff --git a/internal/model/apiconfig.go b/internal/model/apiconfig.go
new file mode 100644
index 0000000..9790d04
--- /dev/null
+++ b/internal/model/apiconfig.go
@@ -0,0 +1,71 @@
+// 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"
+	"time"
+)
+
+const (
+	iosDevice     = "ios"
+	androidDevice = "android"
+)
+
+// APIConfig represents the configuration for a single exposure notification
+// application and their access to and requirements for using the API.
+// DB times of 0 are interpreted to be "unbounded" in that direction.
+type APIConfig struct {
+	AppPackageName  string
+	Platform        string
+	AllowedRegions  map[string]struct{}
+	AllowAllRegions bool
+
+	// SafetyNet configuration.
+	// TODO(sethvargo): Rename these to clarify they are for SafetyNet.
+	AllowedPastTime   time.Duration
+	AllowedFutureTime time.Duration
+	ApkDigestSHA256   []string
+	BasicIntegrity    bool
+	CTSProfileMatch   bool
+
+	// DeviceCheck configuration.
+	DeviceCheckKeyID      string
+	DeviceCheckTeamID     string
+	DeviceCheckPrivateKey *ecdsa.PrivateKey
+}
+
+func NewAPIConfig() *APIConfig {
+	return &APIConfig{
+		AllowedRegions: make(map[string]struct{}),
+	}
+}
+
+// IsIOS returns true if the platform is equal to `iosDevice`
+func (c *APIConfig) IsIOS() bool {
+	return c.Platform == iosDevice
+}
+
+// IsAndroid returns true if the platform is equal to `android`
+func (c *APIConfig) IsAndroid() bool {
+	return c.Platform == androidDevice
+}
+
+// IsAllowedRegion returns true if the region is in the list of allowed regions,
+// false otherwise.
+func (c *APIConfig) IsAllowedRegion(s string) bool {
+	_, ok := c.AllowedRegions[s]
+	return ok
+}
diff --git a/internal/model/apiconfig/config.go b/internal/model/apiconfig/config.go
deleted file mode 100644
index 99ff97f..0000000
--- a/internal/model/apiconfig/config.go
+++ /dev/null
@@ -1,98 +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 apiconfig
-
-import (
-	"crypto/ecdsa"
-	"time"
-
-	"github.com/google/exposure-notifications-server/internal/android"
-)
-
-const (
-	iosDevice     = "ios"
-	androidDevice = "android"
-)
-
-// APIConfig represents the configuration for a single exposure notification
-// application and their access to and requirements for using the API.
-// DB times of 0 are interpreted to be "unbounded" in that direction.
-type APIConfig struct {
-	AppPackageName    string          `db:"app_package_name"`
-	Platform          string          `db:"platform"`
-	ApkDigestSHA256   []string        `db:"apk_digest"`
-	CTSProfileMatch   bool            `db:"cts_profile_match"`
-	BasicIntegrity    bool            `db:"basic_integrity"`
-	AllowedPastTime   time.Duration   `db:"allowed_past_seconds"`
-	AllowedFutureTime time.Duration   `db:"allowed_future_seconds"`
-	AllowedRegions    map[string]bool `db:"allowed_regions"`
-	AllowAllRegions   bool            `db:"all_regions"`
-
-	// BypassSafetyNet is an internal field for testing that bypasses Android
-	// SafetyNet verification. It is not read from a database and is used for
-	// testing only.
-	BypassSafetyNet bool
-
-	// BypassDeviceCheck is an internal field for testing that bypasses iOS
-	// DeviceCheck verification. It is not read from a database and is used for
-	// testing only.
-	BypassDeviceCheck bool
-
-	// DeviceCheck configuration.
-	DeviceCheckKeyID      string
-	DeviceCheckTeamID     string
-	DeviceCheckPrivateKey *ecdsa.PrivateKey
-}
-
-// New creates a new, empty API config
-func New() *APIConfig {
-	return &APIConfig{AllowedRegions: make(map[string]bool)}
-}
-
-// IsIOS returns true if the platform is equal to `iosDevice`
-func (c *APIConfig) IsIOS() bool {
-	return c.Platform == iosDevice
-}
-
-// IsAndroid returns true if the platform is equal to `android`
-func (c *APIConfig) IsAndroid() bool {
-	return c.Platform == androidDevice
-}
-
-// VerifyOpts returns the Android SafetyNet verification options to be used
-// based on the API config.
-func (c *APIConfig) VerifyOpts(from time.Time, noncer android.Noncer) android.VerifyOpts {
-	digests := make([]string, len(c.ApkDigestSHA256))
-	copy(digests, c.ApkDigestSHA256)
-	rtn := android.VerifyOpts{
-		AppPkgName:      c.AppPackageName,
-		CTSProfileMatch: c.CTSProfileMatch,
-		BasicIntegrity:  c.BasicIntegrity,
-		APKDigest:       digests,
-		Nonce:           noncer,
-	}
-
-	// Calculate the valid time window based on now + config options.
-	if c.AllowedPastTime > 0 {
-		minTime := from.Add(-c.AllowedPastTime)
-		rtn.MinValidTime = minTime
-	}
-	if c.AllowedFutureTime > 0 {
-		maxTime := from.Add(c.AllowedFutureTime)
-		rtn.MaxValidTime = maxTime
-	}
-
-	return rtn
-}
diff --git a/internal/config/config.go b/internal/model/apiconfig_test.go
similarity index 69%
rename from internal/config/config.go
rename to internal/model/apiconfig_test.go
index ce8f91d..5b27f41 100644
--- a/internal/config/config.go
+++ b/internal/model/apiconfig_test.go
@@ -12,15 +12,18 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package config
+package model
 
 import (
-	"context"
-
-	"github.com/google/exposure-notifications-server/internal/model/apiconfig"
+	"testing"
 )
 
-// Provider defines possible APIConfig providers.
-type Provider interface {
-	AppPkgConfig(ctx context.Context, appPkg string) (*apiconfig.APIConfig, error)
+func TestBaseAPIConfig(t *testing.T) {
+	cfg := NewAPIConfig()
+	if cfg.IsIOS() {
+		t.Errorf("cfg.IoIOS, got true, want false")
+	}
+	if cfg.IsAndroid() {
+		t.Errorf("cfg.IoAndroid, got true, want false")
+	}
 }
diff --git a/internal/model/exposure.go b/internal/model/exposure.go
index 2151f29..5786af7 100644
--- a/internal/model/exposure.go
+++ b/internal/model/exposure.go
@@ -15,6 +15,8 @@
 package model
 
 import (
+	"crypto/sha256"
+	"encoding/base64"
 	"fmt"
 	"sort"
 	"strings"
@@ -39,7 +41,7 @@ const (
 	MinIntervalCount = 1
 	MaxIntervalCount = 144
 
-	// Self explanitory.
+	// Self explanatory.
 	oneDay = time.Hour * 24
 
 	// interval length
@@ -69,6 +71,50 @@ type Publish struct {
 	Padding                   string        `json:"padding"`
 }
 
+// AndroidNonce returns the Android. This ensures that the data in the request
+// is the same data that was used to create the device attestation.
+func (p *Publish) AndroidNonce() string {
+	// base64 keys are to be lexographically sorted
+	sortedKeys := make([]ExposureKey, len(p.Keys))
+	copy(sortedKeys, p.Keys)
+	sort.Slice(sortedKeys, func(i int, j int) bool {
+		return sortedKeys[i].Key < sortedKeys[j].Key
+	})
+
+	// regions are to be uppercased and then lexographically sorted
+	sortedRegions := make([]string, len(p.Regions))
+	for i, r := range p.Regions {
+		sortedRegions[i] = strings.ToUpper(r)
+	}
+	sort.Strings(sortedRegions)
+
+	keys := make([]string, 0, len(sortedKeys))
+	for _, k := range sortedKeys {
+		keys = append(keys, fmt.Sprintf("%v.%v.%v.%v", k.Key, k.IntervalNumber, k.IntervalCount, k.TransmissionRisk))
+	}
+
+	// The cleartext is a combination of all of the data on the request
+	// in a specific order.
+	//
+	// appPackageName|key[,key]|region[,region]|verificationAuthorityName
+	// Keys are ancoded as
+	//     base64(exposureKey).itnervalNumber.IntervalCount.transmissionRisk
+	// When there is > 1 key, keys are comma separated.
+	// Keys must in sorted order based on the sorting of the base64 exposure key.
+	// Regions are uppercased, sorted, and comma sepreated
+	cleartext :=
+		p.AppPackageName + "|" +
+			strings.Join(keys, ",") + "|" + // where key is b64key.intervalNum.intervalCount
+			strings.Join(sortedRegions, ",") + "|" +
+			p.VerificationPayload
+
+	// Take the sha256 checksum of that data
+	sum := sha256.Sum256([]byte(cleartext))
+
+	// Base64 encode the result.
+	return base64.StdEncoding.EncodeToString(sum[:])
+}
+
 // ExposureKey is the 16 byte key, the start time of the key and the
 // duration of the key. A duration of 0 means 24 hours.
 // - ALL fields are REQUIRED and must meet the constraints below.
diff --git a/internal/publish/config.go b/internal/publish/config.go
index ef0643f..d1579c0 100644
--- a/internal/publish/config.go
+++ b/internal/publish/config.go
@@ -18,33 +18,35 @@ package publish
 import (
 	"time"
 
+	"github.com/google/exposure-notifications-server/internal/apiconfig"
 	"github.com/google/exposure-notifications-server/internal/database"
-	"github.com/google/exposure-notifications-server/internal/dbapiconfig"
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
 // Compile-time check to assert this config matches requirements.
-var _ setup.DBAPIConfigProvider = (*Config)(nil)
+var _ setup.APIConfigProvider = (*Config)(nil)
 var _ setup.DBConfigProvider = (*Config)(nil)
 
 // Config represents the configuration and associated environment variables for
 // the publish components.
 type Config struct {
-	Port                     string        `envconfig:"PORT" default:"8080"`
-	MinRequestDuration       time.Duration `envconfig:"TARGET_REQUEST_DURATION" default:"5s"`
-	MaxKeysOnPublish         int           `envconfig:"MAX_KEYS_ON_PUBLISH" default:"14"`
-	MaxIntervalAge           time.Duration `envconfig:"MAX_INTERVAL_AGE_ON_PUBLISH" default:"360h"`
-	APIConfigRefreshDuration time.Duration `envconfig:"CONFIG_REFRESH_DURATION" default:"5m"`
-	TruncateWindow           time.Duration `envconfig:"TRUNCATE_WINDOW" default:"1h"`
-
-	APIConfigOpts *dbapiconfig.ConfigOpts
-
-	Database *database.Config
+	Port               string        `envconfig:"PORT" default:"8080"`
+	MinRequestDuration time.Duration `envconfig:"TARGET_REQUEST_DURATION" default:"5s"`
+	MaxKeysOnPublish   int           `envconfig:"MAX_KEYS_ON_PUBLISH" default:"14"`
+	MaxIntervalAge     time.Duration `envconfig:"MAX_INTERVAL_AGE_ON_PUBLISH" default:"360h"`
+	TruncateWindow     time.Duration `envconfig:"TRUNCATE_WINDOW" default:"1h"`
+
+	// Bypass flags for local development and testing.
+	BypassSafetyNet   bool `envconfig:"BYPASS_SAFETYNET"`
+	BypassDeviceCheck bool `envconfig:"BYPASS_DEVICECHECK"`
+
+	APIConfig *apiconfig.Config
+	Database  *database.Config
 }
 
-// API returns the configuration for the DB APIConfig provider.
-func (c *Config) API() *dbapiconfig.ConfigOpts {
-	return c.APIConfigOpts
+// APIConfigConfig returns the configuration for apiconfig.
+func (c *Config) APIConfigConfig() *apiconfig.Config {
+	return c.APIConfig
 }
 
 // DB returns the configuration for the databse.
diff --git a/internal/publish/publish.go b/internal/publish/publish.go
index 78d9353..1a322e9 100644
--- a/internal/publish/publish.go
+++ b/internal/publish/publish.go
@@ -21,7 +21,7 @@ import (
 	"net/http"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/config"
+	"github.com/google/exposure-notifications-server/internal/apiconfig"
 	"github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/jsonutil"
 	"github.com/google/exposure-notifications-server/internal/logging"
@@ -63,7 +63,7 @@ type publishHandler struct {
 	serverenv   *serverenv.ServerEnv
 	transformer *model.Transformer
 	database    *database.DB
-	apiconfig   config.Provider
+	apiconfig   apiconfig.Provider
 }
 
 // There is a target normalized latency for this function. This is to help prevent
@@ -83,26 +83,30 @@ func (h *publishHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	cfg, err := h.apiconfig.AppPkgConfig(ctx, data.AppPackageName)
+	appConfig, err := h.apiconfig.AppConfig(ctx, data.AppPackageName)
 	if err != nil {
-		// Log the configuration error, return error to client.
-		// This is retryable, although won't succeed if the error isn't transient.
+		// Config loaded, but app with that name isn't registered. This can also
+		// happen if the app was recently registered but the cache hasn't been
+		// refreshed.
+		if err == apiconfig.AppNotFound {
+			logger.Errorf("unauthorized app: %v", data.AppPackageName)
+			metrics.WriteInt("publish-app-not-authorized", true, 1)
+			// This returns success to the client.
+			w.WriteHeader(http.StatusOK)
+			return
+		}
+
+		// A higher-level configuration error occurred, likely while trying to read
+		// from the database. This is retryable, although won't succeed if the error
+		// isn't transient.
 		logger.Errorf("no API config, dropping data: %v", err)
 		metrics.WriteInt("publish-error-loading-apiconfig", true, 1)
-		http.Error(w, "internal error", http.StatusInternalServerError)
-		return
-	}
-	if cfg == nil {
-		// configs were loaded, but the request app isn't configured.
-		logger.Errorf("unauthorized application: %v", data.AppPackageName)
-		metrics.WriteInt("publish-app-not-authorized", true, 1)
-		// This returns success to the client.
-		w.WriteHeader(http.StatusOK)
+		http.Error(w, http.StatusText(http.StatusInternalServerError),
+			http.StatusInternalServerError)
 		return
 	}
 
-	err = verification.VerifyRegions(cfg, data)
-	if err != nil {
+	if err := verification.VerifyRegions(appConfig, data); err != nil {
 		logger.Errorf("verification.VerifyRegions: %v", err)
 		metrics.WriteInt("publish-region-not-authorized", true, 1)
 		// This returns success to the client.
@@ -110,16 +114,22 @@ func (h *publishHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	if cfg.IsIOS() {
-		if err := verification.VerifyDeviceCheck(ctx, cfg, data); err != nil {
+	if appConfig.IsIOS() {
+		if h.config.BypassDeviceCheck {
+			logger.Errorf("bypassing DeviceCheck for %v", data.AppPackageName)
+			metrics.WriteInt("publish-devicecheck-bypass", true, 1)
+		} else if err := verification.VerifyDeviceCheck(ctx, appConfig, data); err != nil {
 			logger.Errorf("unable to verify devicecheck payload: %v", err)
 			metrics.WriteInt("publish-devicecheck-invalid", true, 1)
 			// Return success to the client.
 			w.WriteHeader(http.StatusOK)
 			return
 		}
-	} else if cfg.IsAndroid() {
-		if err := verification.VerifySafetyNet(ctx, time.Now(), cfg, data); err != nil {
+	} else if appConfig.IsAndroid() {
+		if h.config.BypassSafetyNet {
+			logger.Errorf("bypassing SafetyNet for %v", data.AppPackageName)
+			metrics.WriteInt("publish-safetynet-bypass", true, 1)
+		} else if err := verification.VerifySafetyNet(ctx, time.Now(), appConfig, data); err != nil {
 			logger.Errorf("unable to verify safetynet payload: %v", err)
 			metrics.WriteInt("publish-safetnet-invalid", true, 1)
 			// Return success to the client.
@@ -149,7 +159,8 @@ func (h *publishHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		logger.Errorf("error writing exposure record: %v", err)
 		metrics.WriteInt("publish-db-write-error", true, 1)
 		// This is retryable at the client - database error at the server.
-		http.Error(w, "internal processing error", http.StatusInternalServerError)
+		http.Error(w, http.StatusText(http.StatusInternalServerError),
+			http.StatusInternalServerError)
 		return
 	}
 	metrics.WriteInt("publish-exposures-written", true, len(exposures))
diff --git a/internal/serverenv/env.go b/internal/serverenv/env.go
index 41719ac..79f9911 100644
--- a/internal/serverenv/env.go
+++ b/internal/serverenv/env.go
@@ -20,7 +20,7 @@ import (
 	"crypto"
 	"fmt"
 
-	"github.com/google/exposure-notifications-server/internal/config"
+	"github.com/google/exposure-notifications-server/internal/apiconfig"
 	"github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/metrics"
 	"github.com/google/exposure-notifications-server/internal/secrets"
@@ -37,7 +37,7 @@ type ServerEnv struct {
 	keyManager        signing.KeyManager
 	blobstore         storage.Blobstore
 	exporter          metrics.ExporterFromContext
-	apiConfigProvider config.Provider
+	apiConfigProvider apiconfig.Provider
 	database          *database.DB
 }
 
@@ -69,7 +69,7 @@ func WithDatabase(db *database.DB) Option {
 }
 
 // WithAPIConfigProvider installs a provider of APIConfig.
-func WithAPIConfigProvider(p config.Provider) Option {
+func WithAPIConfigProvider(p apiconfig.Provider) Option {
 	return func(s *ServerEnv) *ServerEnv {
 		s.apiConfigProvider = p
 		return s
@@ -120,7 +120,7 @@ func (s *ServerEnv) Blobstore() storage.Blobstore {
 	return s.blobstore
 }
 
-func (s *ServerEnv) APIConfigProvider() config.Provider {
+func (s *ServerEnv) APIConfigProvider() apiconfig.Provider {
 	return s.apiConfigProvider
 }
 
diff --git a/internal/setup/setup.go b/internal/setup/setup.go
index b9ec56a..cf62b32 100644
--- a/internal/setup/setup.go
+++ b/internal/setup/setup.go
@@ -18,8 +18,8 @@ import (
 	"context"
 	"fmt"
 
+	"github.com/google/exposure-notifications-server/internal/apiconfig"
 	"github.com/google/exposure-notifications-server/internal/database"
-	"github.com/google/exposure-notifications-server/internal/dbapiconfig"
 	"github.com/google/exposure-notifications-server/internal/envconfig"
 	"github.com/google/exposure-notifications-server/internal/metrics"
 	"github.com/google/exposure-notifications-server/internal/secrets"
@@ -34,10 +34,10 @@ type DBConfigProvider interface {
 	DB() *database.Config
 }
 
-// DBAPIConfigProvider signals that the config provided knows how to configure
-// and requires a DB backed APIConfigProvider installed.
-type DBAPIConfigProvider interface {
-	API() *dbapiconfig.ConfigOpts
+// APIConfigProvider signals that the config provided knows how to configure
+// and requires a APIConfigs.
+type APIConfigProvider interface {
+	APIConfigConfig() *apiconfig.Config
 }
 
 // KeyManagerProvider is a marker interface indicating the KeyManagerProvider should be installed.
@@ -89,20 +89,22 @@ func Setup(ctx context.Context, config DBConfigProvider) (*serverenv.ServerEnv,
 		opts = append(opts, serverenv.WithBlobStorage(storage))
 	}
 
+	// Setup the database connection.
 	db, err := database.NewFromEnv(ctx, config.DB())
 	if err != nil {
 		return nil, nil, fmt.Errorf("unable to connect to database: %v", err)
 	}
 	opts = append(opts, serverenv.WithDatabase(db))
 
-	if apicfg, ok := config.(DBAPIConfigProvider); ok {
-		cfgProvider, err := dbapiconfig.NewConfigProvider(db, sm, apicfg.API())
+	// APIConfig must come after database setup due to the dependency.
+	if typ, ok := config.(APIConfigProvider); ok {
+		provider, err := apiconfig.NewDatabaseProvider(ctx, db, typ.APIConfigConfig(), apiconfig.WithSecretManager(sm))
 		if err != nil {
-			// APIConfig must come after DB due to dependency, ensure connection is closed
+			// Ensure the database is closed on an error.
 			defer db.Close(ctx)
 			return nil, nil, fmt.Errorf("unable to create APIConfig provider: %v", err)
 		}
-		opts = append(opts, serverenv.WithAPIConfigProvider(cfgProvider))
+		opts = append(opts, serverenv.WithAPIConfigProvider(provider))
 	}
 
 	return serverenv.New(ctx, opts...), func() { db.Close(ctx) }, nil
diff --git a/internal/verification/verify.go b/internal/verification/verify.go
index a080705..b950600 100644
--- a/internal/verification/verify.go
+++ b/internal/verification/verify.go
@@ -21,9 +21,7 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/android"
 	"github.com/google/exposure-notifications-server/internal/ios"
-	"github.com/google/exposure-notifications-server/internal/logging"
 	"github.com/google/exposure-notifications-server/internal/model"
-	"github.com/google/exposure-notifications-server/internal/model/apiconfig"
 )
 
 var (
@@ -33,7 +31,7 @@ var (
 
 // VerifyRegions checks the request regions against the regions allowed by
 // the configuration for the application.
-func VerifyRegions(cfg *apiconfig.APIConfig, data *model.Publish) error {
+func VerifyRegions(cfg *model.APIConfig, data *model.Publish) error {
 	if cfg == nil {
 		return fmt.Errorf("no allowed regions configured")
 	}
@@ -43,7 +41,7 @@ func VerifyRegions(cfg *apiconfig.APIConfig, data *model.Publish) error {
 	}
 
 	for _, r := range data.Regions {
-		if v, ok := cfg.AllowedRegions[r]; !ok || !v {
+		if !cfg.IsAllowedRegion(r) {
 			return fmt.Errorf("application '%v' tried to write unauthorized region: '%v'", cfg.AppPackageName, r)
 		}
 	}
@@ -54,21 +52,13 @@ func VerifyRegions(cfg *apiconfig.APIConfig, data *model.Publish) error {
 
 // VerifySafetyNet verifies the Android SafetyNet device attestation against the
 // allowed configuration for the application.
-func VerifySafetyNet(ctx context.Context, requestTime time.Time, cfg *apiconfig.APIConfig, data *model.Publish) error {
-	logger := logging.FromContext(ctx)
-
+func VerifySafetyNet(ctx context.Context, requestTime time.Time, cfg *model.APIConfig, publish *model.Publish) error {
 	if cfg == nil {
-		logger.Errorf("safetynet enabled, but no config for application: %v", data.AppPackageName)
-		// TODO(mikehelmick): Should this be a default configuration?
-		return fmt.Errorf("cannot enforce safetynet, no application config")
+		return fmt.Errorf("cannot enforce SafetyNet, missing config")
 	}
 
-	opts := cfg.VerifyOpts(requestTime, android.NewNonce(data))
-	if err := androidValidateAttestation(ctx, data.DeviceVerificationPayload, opts); err != nil {
-		if cfg.BypassSafetyNet {
-			logger.Errorf("bypassing safetynet verification for: '%v'", data.AppPackageName)
-			return nil
-		}
+	opts := android.VerifyOptsFor(cfg, requestTime, publish.AndroidNonce())
+	if err := androidValidateAttestation(ctx, publish.DeviceVerificationPayload, opts); err != nil {
 		return fmt.Errorf("android.ValidateAttestation: %w", err)
 	}
 
@@ -76,11 +66,9 @@ func VerifySafetyNet(ctx context.Context, requestTime time.Time, cfg *apiconfig.
 }
 
 // VerifyDeviceCheck verifies an iOS DeviceCheck token against the Apple API.
-func VerifyDeviceCheck(ctx context.Context, cfg *apiconfig.APIConfig, data *model.Publish) error {
-	logger := logging.FromContext(ctx)
-
+func VerifyDeviceCheck(ctx context.Context, cfg *model.APIConfig, data *model.Publish) error {
 	if cfg == nil {
-		return fmt.Errorf("cannot enforce devicecheck, missing config")
+		return fmt.Errorf("cannot enforce DeviceCheck, missing config")
 	}
 
 	opts := &ios.VerifyOpts{
@@ -90,10 +78,6 @@ func VerifyDeviceCheck(ctx context.Context, cfg *apiconfig.APIConfig, data *mode
 	}
 
 	if err := iosValidateDeviceToken(ctx, data.DeviceVerificationPayload, opts); err != nil {
-		if cfg.BypassDeviceCheck {
-			logger.Errorf("devicecheck failed, but bypass enabled for app: '%v', failure: '%v'", data.AppPackageName, err)
-			return nil
-		}
 		return fmt.Errorf("ios.ValidateDeviceToken: %w", err)
 	}
 
diff --git a/migrations/000018_idx_app_package_name.down.sql b/migrations/000018_idx_app_package_name.down.sql
new file mode 100644
index 0000000..154b5a5
--- /dev/null
+++ b/migrations/000018_idx_app_package_name.down.sql
@@ -0,0 +1,19 @@
+-- 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 INDEX apiconfig_app_package_name_idx;
+
+END;
diff --git a/migrations/000018_idx_app_package_name.up.sql b/migrations/000018_idx_app_package_name.up.sql
new file mode 100644
index 0000000..7a756ad
--- /dev/null
+++ b/migrations/000018_idx_app_package_name.up.sql
@@ -0,0 +1,19 @@
+-- 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 INDEX apiconfig_app_package_name_idx ON APIConfig(app_package_name);
+
+END;


Test patch:
diff --git a/internal/android/nonce_test.go b/internal/android/nonce_test.go
deleted file mode 100644
index aab99f0..0000000
--- a/internal/android/nonce_test.go
+++ /dev/null
@@ -1,94 +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 android
-
-import (
-	"testing"
-
-	"github.com/google/exposure-notifications-server/internal/model"
-)
-
-// Data from this test was generated by the Android reference application.
-func TestGetNonce(t *testing.T) {
-	testData := []struct {
-		Data     model.Publish
-		Expected string
-	}{
-		{
-			Data: model.Publish{
-				Keys: []model.ExposureKey{
-					{
-						Key:              "x21Goi8X9m/glOZ0+wz8fA",
-						IntervalNumber:   263123,
-						IntervalCount:    144,
-						TransmissionRisk: 6,
-					},
-					{
-						Key:              "2mvFSmRsFmJR5r07dxGSjg",
-						IntervalNumber:   263267,
-						IntervalCount:    144,
-						TransmissionRisk: 7,
-					},
-					{
-						Key:              "6bAd3dv7p+VEuaJVkVItaQ",
-						IntervalNumber:   263411,
-						IntervalCount:    27,
-						TransmissionRisk: 7,
-					},
-				},
-				Regions:             []string{"GB", "US"},
-				AppPackageName:      appPackage,
-				VerificationPayload: "QRTH-ROWO-LOLO-FOOB",
-			},
-			Expected: "0kbOC1LIwDc+DQnLOFcIbnxqfKNg58b7WA3vREdd7ZQ=",
-		},
-		{
-			Data: model.Publish{
-				Keys: []model.ExposureKey{
-					{
-						Key:              "zdCW5HrOKbirxmQVc0L/eA",
-						IntervalNumber:   263123,
-						IntervalCount:    144,
-						TransmissionRisk: 8,
-					},
-					{
-						Key:              "t+k51ifogJo9jq3GH9LWGQ",
-						IntervalNumber:   263267,
-						IntervalCount:    144,
-						TransmissionRisk: 8,
-					},
-					{
-						Key:              "3uXRrSlcv1+OMI3oFtdaUw",
-						IntervalNumber:   263411,
-						IntervalCount:    27,
-						TransmissionRisk: 8,
-					},
-				},
-				Regions:             []string{"gB", "us"},
-				AppPackageName:      appPackage,
-				VerificationPayload: "BREA-KMEO-FFAP-IECE",
-			},
-			Expected: "KYCIywFRTwXCnYCF4TTFaH//iMb/fBQENqIhtzrJPxA=",
-		},
-	}
-
-	for _, input := range testData {
-		data := NewNonce(&input.Data)
-		nonce := data.Nonce()
-		if nonce != input.Expected {
-			t.Errorf("getNonce: got '%v', want '%v'", nonce, input.Expected)
-		}
-	}
-}
diff --git a/internal/android/safetynet_test.go b/internal/android/safetynet_test.go
index ac35e0c..8763b3d 100644
--- a/internal/android/safetynet_test.go
+++ b/internal/android/safetynet_test.go
@@ -21,18 +21,13 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/base64util"
 	"github.com/google/exposure-notifications-server/internal/model"
+	"github.com/google/go-cmp/cmp"
 )
 
 const (
 	appPackage = "com.google.android.apps.exposurenotification"
 )
 
-type emptyNonce struct{}
-
-func (e *emptyNonce) Nonce() string {
-	return ""
-}
-
 var (
 	// **This is not a secret value.**
 	// This is a SafetyNet Attestation payload, https://developer.android.com/training/safetynet/attestation#overview
@@ -61,7 +56,7 @@ func TestVerifyAttestation(t *testing.T) {
 		t.Fatalf("error verifying attestation %v", err)
 	}
 
-	expectedNonce := NewNonce(publish).Nonce()
+	expectedNonce := publish.AndroidNonce()
 	actualBytes, err := base64util.DecodeString(claims["nonce"].(string))
 	actualNonce := string(actualBytes)
 	if err != nil {
@@ -73,8 +68,7 @@ func TestVerifyAttestation(t *testing.T) {
 }
 
 func TestValidateAttestation(t *testing.T) {
-	nonce := NewNonce(publish)
-
+	nonce := publish.AndroidNonce()
 	apkDigest := "jqmYEqi9qUvpUe11qMf3v2o6VEQM+5NDee2bz0xdzWc="
 	generateTimeS := int64(1589588408185) / 1000
 	generateTime := time.Unix(generateTimeS, 0)
@@ -82,12 +76,12 @@ func TestValidateAttestation(t *testing.T) {
 	minValidTime := generateTime.Add(-1 * time.Minute)
 
 	tests := []struct {
-		Opts  VerifyOpts
+		Opts  *VerifyOpts
 		Valid bool
 		Error string
 	}{
 		{
-			VerifyOpts{
+			&VerifyOpts{
 				AppPkgName:      appPackage,
 				APKDigest:       []string{"other digest", apkDigest},
 				Nonce:           nonce,
@@ -100,10 +94,10 @@ func TestValidateAttestation(t *testing.T) {
 			"",
 		},
 		{
-			VerifyOpts{
+			&VerifyOpts{
 				AppPkgName:      appPackage,
 				APKDigest:       []string{""},
-				Nonce:           nil,
+				Nonce:           "",
 				CTSProfileMatch: false,
 				BasicIntegrity:  true,
 				MinValidTime:    minValidTime,
@@ -113,20 +107,7 @@ func TestValidateAttestation(t *testing.T) {
 			"missing nonce",
 		},
 		{
-			VerifyOpts{
-				AppPkgName:      appPackage,
-				APKDigest:       []string{""},
-				Nonce:           &emptyNonce{},
-				CTSProfileMatch: false,
-				BasicIntegrity:  true,
-				MinValidTime:    minValidTime,
-				MaxValidTime:    maxValidTime,
-			},
-			false,
-			"missing nonce",
-		},
-		{
-			VerifyOpts{
+			&VerifyOpts{
 				AppPkgName:      appPackage,
 				APKDigest:       []string{""},
 				Nonce:           nonce,
@@ -139,7 +120,7 @@ func TestValidateAttestation(t *testing.T) {
 			"missing timestamp bounds for attestation",
 		},
 		{
-			VerifyOpts{
+			&VerifyOpts{
 				AppPkgName:      appPackage,
 				APKDigest:       []string{""},
 				Nonce:           nonce,
@@ -152,7 +133,7 @@ func TestValidateAttestation(t *testing.T) {
 			"missing timestamp bounds for attestation",
 		},
 		{
-			VerifyOpts{
+			&VerifyOpts{
 				AppPkgName:      appPackage,
 				APKDigest:       []string{apkDigest},
 				Nonce:           nonce,
@@ -165,7 +146,7 @@ func TestValidateAttestation(t *testing.T) {
 			"missing timestamp bounds for attestation",
 		},
 		{
-			VerifyOpts{
+			&VerifyOpts{
 				AppPkgName:      appPackage,
 				APKDigest:       []string{apkDigest},
 				Nonce:           nonce,
@@ -178,7 +159,7 @@ func TestValidateAttestation(t *testing.T) {
 			"attestation is too old, must be newer than 1589588468, was 1589588408",
 		},
 		{
-			VerifyOpts{
+			&VerifyOpts{
 				AppPkgName:      appPackage,
 				APKDigest:       []string{""},
 				Nonce:           nonce,
@@ -206,3 +187,72 @@ func TestValidateAttestation(t *testing.T) {
 		}
 	}
 }
+
+func TestVerifyOptsFor(t *testing.T) {
+	testTime := time.Date(2020, 1, 13, 5, 6, 4, 6, time.Local)
+
+	cases := []struct {
+		cfg  *model.APIConfig
+		opts *VerifyOpts
+	}{
+		{
+			cfg: &model.APIConfig{
+				AppPackageName:    "foo",
+				CTSProfileMatch:   true,
+				BasicIntegrity:    true,
+				AllowedPastTime:   time.Duration(15 * time.Minute),
+				AllowedFutureTime: time.Duration(1 * time.Second),
+			},
+			opts: &VerifyOpts{
+				AppPkgName:      "foo",
+				APKDigest:       []string{},
+				CTSProfileMatch: true,
+				BasicIntegrity:  true,
+				MinValidTime:    testTime.Add(-15 * time.Minute),
+				MaxValidTime:    testTime.Add(1 * time.Second),
+			},
+		},
+		{
+			cfg: &model.APIConfig{
+				AppPackageName:    "foo",
+				CTSProfileMatch:   false,
+				BasicIntegrity:    true,
+				AllowedPastTime:   0,
+				AllowedFutureTime: 0,
+			},
+			opts: &VerifyOpts{
+				AppPkgName:      "foo",
+				APKDigest:       []string{},
+				CTSProfileMatch: false,
+				BasicIntegrity:  true,
+				MinValidTime:    time.Time{},
+				MaxValidTime:    time.Time{},
+			},
+		},
+		{
+			cfg: &model.APIConfig{
+				AppPackageName:    "foo",
+				ApkDigestSHA256:   []string{"bar"},
+				CTSProfileMatch:   false,
+				BasicIntegrity:    true,
+				AllowedPastTime:   0,
+				AllowedFutureTime: 0,
+			},
+			opts: &VerifyOpts{
+				AppPkgName:      "foo",
+				APKDigest:       []string{"bar"},
+				CTSProfileMatch: false,
+				BasicIntegrity:  true,
+				MinValidTime:    time.Time{},
+				MaxValidTime:    time.Time{},
+			},
+		},
+	}
+
+	for i, c := range cases {
+		got := VerifyOptsFor(c.cfg, testTime, "" /* nonce */)
+		if diff := cmp.Diff(c.opts, got); diff != "" {
+			t.Errorf("%v verify opts (-want +got):\n%v", i, diff)
+		}
+	}
+}
diff --git a/internal/database/apiconfig_test.go b/internal/database/apiconfig_test.go
index 58c6e42..2078c6a 100644
--- a/internal/database/apiconfig_test.go
+++ b/internal/database/apiconfig_test.go
@@ -25,7 +25,7 @@ import (
 	"testing"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/model/apiconfig"
+	"github.com/google/exposure-notifications-server/internal/model"
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-cmp/cmp/cmpopts"
 )
@@ -42,7 +42,7 @@ func (s *testSecretManager) GetSecretValue(ctx context.Context, name string) (st
 	return v, nil
 }
 
-func TestReadAPIConfigs(t *testing.T) {
+func TestGetAPIConfig(t *testing.T) {
 	if testDB == nil {
 		t.Skip("no test DB")
 	}
@@ -75,7 +75,7 @@ func TestReadAPIConfigs(t *testing.T) {
 		name string
 		sql  string
 		args []interface{}
-		exp  []*apiconfig.APIConfig
+		exp  *model.APIConfig
 		err  bool
 	}{
 		{
@@ -85,14 +85,12 @@ func TestReadAPIConfigs(t *testing.T) {
 				VALUES ($1, $2, $3)
 			`,
 			args: []interface{}{"myapp", "ios", []string{"US"}},
-			exp: []*apiconfig.APIConfig{
-				{
-					AppPackageName:  "myapp",
-					Platform:        "ios",
-					AllowedRegions:  map[string]bool{"US": true},
-					CTSProfileMatch: true,
-					BasicIntegrity:  true,
-				},
+			exp: &model.APIConfig{
+				AppPackageName:  "myapp",
+				Platform:        "ios",
+				AllowedRegions:  map[string]struct{}{"US": {}},
+				CTSProfileMatch: true,
+				BasicIntegrity:  true,
 			},
 		},
 		{
@@ -104,15 +102,13 @@ func TestReadAPIConfigs(t *testing.T) {
 				) VALUES ($1, $2, $3, $4)
 			`,
 			args: []interface{}{"myapp", "ios", []string{"US"}, 1800},
-			exp: []*apiconfig.APIConfig{
-				{
-					AppPackageName:  "myapp",
-					Platform:        "ios",
-					AllowedRegions:  map[string]bool{"US": true},
-					CTSProfileMatch: true,
-					BasicIntegrity:  true,
-					AllowedPastTime: 30 * time.Minute,
-				},
+			exp: &model.APIConfig{
+				AppPackageName:  "myapp",
+				Platform:        "ios",
+				AllowedRegions:  map[string]struct{}{"US": {}},
+				CTSProfileMatch: true,
+				BasicIntegrity:  true,
+				AllowedPastTime: 30 * time.Minute,
 			},
 		},
 		{
@@ -124,15 +120,13 @@ func TestReadAPIConfigs(t *testing.T) {
 				) VALUES ($1, $2, $3, $4)
 			`,
 			args: []interface{}{"myapp", "ios", []string{"US"}, 1800},
-			exp: []*apiconfig.APIConfig{
-				{
-					AppPackageName:    "myapp",
-					Platform:          "ios",
-					AllowedRegions:    map[string]bool{"US": true},
-					CTSProfileMatch:   true,
-					BasicIntegrity:    true,
-					AllowedFutureTime: 30 * time.Minute,
-				},
+			exp: &model.APIConfig{
+				AppPackageName:    "myapp",
+				Platform:          "ios",
+				AllowedRegions:    map[string]struct{}{"US": {}},
+				CTSProfileMatch:   true,
+				BasicIntegrity:    true,
+				AllowedFutureTime: 30 * time.Minute,
 			},
 		},
 		{
@@ -144,19 +138,23 @@ func TestReadAPIConfigs(t *testing.T) {
 				) VALUES ($1, $2, $3, $4, $5, $6)
 			`,
 			args: []interface{}{"myapp", "ios", []string{"US"}, "team_id", "key_id", "private_key"},
-			exp: []*apiconfig.APIConfig{
-				{
-					AppPackageName:        "myapp",
-					Platform:              "ios",
-					AllowedRegions:        map[string]bool{"US": true},
-					CTSProfileMatch:       true,
-					BasicIntegrity:        true,
-					DeviceCheckTeamID:     "ABCD1234",
-					DeviceCheckKeyID:      "DEFG5678",
-					DeviceCheckPrivateKey: p8PrivateKey,
-				},
+			exp: &model.APIConfig{
+				AppPackageName:        "myapp",
+				Platform:              "ios",
+				AllowedRegions:        map[string]struct{}{"US": {}},
+				CTSProfileMatch:       true,
+				BasicIntegrity:        true,
+				DeviceCheckTeamID:     "ABCD1234",
+				DeviceCheckKeyID:      "DEFG5678",
+				DeviceCheckPrivateKey: p8PrivateKey,
 			},
 		},
+		{
+			name: "not_found",
+			sql:  "",
+			args: nil,
+			exp:  nil,
+		},
 	}
 
 	for _, c := range cases {
@@ -175,14 +173,14 @@ func TestReadAPIConfigs(t *testing.T) {
 				t.Fatal(err)
 			}
 
-			configs, err := testDB.ReadAPIConfigs(ctx, sm)
+			config, err := testDB.GetAPIConfig(ctx, sm, "myapp")
 			if (err != nil) != c.err {
 				t.Fatal(err)
 			}
 
 			// Compare, ignoring the private key part
 			opts := cmpopts.IgnoreTypes(new(ecdsa.PrivateKey))
-			if diff := cmp.Diff(configs, c.exp, opts); diff != "" {
+			if diff := cmp.Diff(config, c.exp, opts); diff != "" {
 				t.Errorf("mismatch (-want, +got):\n%s", diff)
 			}
 
diff --git a/internal/database/environment.go b/internal/database/config.go
similarity index 100%
rename from internal/database/environment.go
rename to internal/database/config.go
diff --git a/internal/database/database_test.go b/internal/database/database_test.go
index aadbdc1..aa851dd 100644
--- a/internal/database/database_test.go
+++ b/internal/database/database_test.go
@@ -111,6 +111,7 @@ func (db *DB) createDatabase(ctx context.Context, name string) error {
 }
 
 func mustExec(t *testing.T, conn *pgxpool.Conn, stmt string, args ...interface{}) {
+	t.Helper()
 	_, err := conn.Exec(context.Background(), stmt, args...)
 	if err != nil {
 		t.Fatalf("executing %s: %v", stmt, err)
@@ -118,6 +119,7 @@ func mustExec(t *testing.T, conn *pgxpool.Conn, stmt string, args ...interface{}
 }
 
 func resetTestDB(t *testing.T) {
+	t.Helper()
 	ctx := context.Background()
 	conn, err := testDB.pool.Acquire(ctx)
 	if err != nil {
diff --git a/internal/export/worker_test.go b/internal/export/worker_test.go
index 650f0c5..cb356e7 100644
--- a/internal/export/worker_test.go
+++ b/internal/export/worker_test.go
@@ -57,6 +57,7 @@ func TestDoNotPadZeroLength(t *testing.T) {
 }
 
 func addExposure(t *testing.T, exposures []*model.Exposure, interval, count int32, risk int) []*model.Exposure {
+	t.Helper()
 	key := make([]byte, model.KeyLength)
 	_, err := rand.Read(key)
 	if err != nil {
diff --git a/internal/jsonutil/unmarshal_test.go b/internal/jsonutil/unmarshal_test.go
index 3a0ac82..81b6b46 100644
--- a/internal/jsonutil/unmarshal_test.go
+++ b/internal/jsonutil/unmarshal_test.go
@@ -158,6 +158,7 @@ func TestValidPublishMessage(t *testing.T) {
 }
 
 func unmarshalTestHelper(t *testing.T, payloads []string, errors []string, expCode int) {
+	t.Helper()
 	for i, testStr := range payloads {
 		body := ioutil.NopCloser(bytes.NewReader([]byte(testStr)))
 		r := httptest.NewRequest("POST", "/", body)
diff --git a/internal/model/apiconfig/config_test.go b/internal/model/apiconfig/config_test.go
deleted file mode 100644
index c2a49b1..0000000
--- a/internal/model/apiconfig/config_test.go
+++ /dev/null
@@ -1,104 +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 apiconfig
-
-import (
-	"testing"
-	"time"
-
-	"github.com/google/exposure-notifications-server/internal/android"
-
-	"github.com/google/go-cmp/cmp"
-)
-
-func TestBaseAPIConfig(t *testing.T) {
-
-	cfg := New()
-	if cfg.IsIOS() {
-		t.Errorf("cfg.IoIOS, got true, want false")
-	}
-	if cfg.IsAndroid() {
-		t.Errorf("cfg.IoAndroid, got true, want false")
-	}
-}
-
-func TestVerifyOpts(t *testing.T) {
-	testTime := time.Date(2020, 1, 13, 5, 6, 4, 6, time.Local)
-
-	cases := []struct {
-		cfg  *APIConfig
-		opts android.VerifyOpts
-	}{
-		{
-			cfg: &APIConfig{
-				AppPackageName:    "foo",
-				CTSProfileMatch:   true,
-				BasicIntegrity:    true,
-				AllowedPastTime:   time.Duration(15 * time.Minute),
-				AllowedFutureTime: time.Duration(1 * time.Second),
-			},
-			opts: android.VerifyOpts{
-				AppPkgName:      "foo",
-				APKDigest:       []string{},
-				CTSProfileMatch: true,
-				BasicIntegrity:  true,
-				MinValidTime:    testTime.Add(-15 * time.Minute),
-				MaxValidTime:    testTime.Add(1 * time.Second),
-			},
-		},
-		{
-			cfg: &APIConfig{
-				AppPackageName:    "foo",
-				CTSProfileMatch:   false,
-				BasicIntegrity:    true,
-				AllowedPastTime:   0,
-				AllowedFutureTime: 0,
-			},
-			opts: android.VerifyOpts{
-				AppPkgName:      "foo",
-				APKDigest:       []string{},
-				CTSProfileMatch: false,
-				BasicIntegrity:  true,
-				MinValidTime:    time.Time{},
-				MaxValidTime:    time.Time{},
-			},
-		},
-		{
-			cfg: &APIConfig{
-				AppPackageName:    "foo",
-				ApkDigestSHA256:   []string{"bar"},
-				CTSProfileMatch:   false,
-				BasicIntegrity:    true,
-				AllowedPastTime:   0,
-				AllowedFutureTime: 0,
-			},
-			opts: android.VerifyOpts{
-				AppPkgName:      "foo",
-				APKDigest:       []string{"bar"},
-				CTSProfileMatch: false,
-				BasicIntegrity:  true,
-				MinValidTime:    time.Time{},
-				MaxValidTime:    time.Time{},
-			},
-		},
-	}
-
-	for i, tst := range cases {
-		got := tst.cfg.VerifyOpts(testTime, nil /* noncer */)
-		if diff := cmp.Diff(tst.opts, got); diff != "" {
-			t.Errorf("%v verify opts (-want +got):\n%v", i, diff)
-		}
-	}
-}
diff --git a/internal/model/exposure_test.go b/internal/model/exposure_test.go
index 7341659..35bc3fc 100644
--- a/internal/model/exposure_test.go
+++ b/internal/model/exposure_test.go
@@ -237,7 +237,82 @@ func TestPublishValidation(t *testing.T) {
 	}
 }
 
+// Data from this test was generated by the Android reference application.
+func TestPublish_AndroidNonce(t *testing.T) {
+	cases := []struct {
+		data *Publish
+		exp  string
+	}{
+		{
+			data: &Publish{
+				Keys: []ExposureKey{
+					{
+						Key:              "x21Goi8X9m/glOZ0+wz8fA",
+						IntervalNumber:   263123,
+						IntervalCount:    144,
+						TransmissionRisk: 6,
+					},
+					{
+						Key:              "2mvFSmRsFmJR5r07dxGSjg",
+						IntervalNumber:   263267,
+						IntervalCount:    144,
+						TransmissionRisk: 7,
+					},
+					{
+						Key:              "6bAd3dv7p+VEuaJVkVItaQ",
+						IntervalNumber:   263411,
+						IntervalCount:    27,
+						TransmissionRisk: 7,
+					},
+				},
+				Regions:             []string{"GB", "US"},
+				AppPackageName:      "com.google.android.apps.exposurenotification",
+				VerificationPayload: "QRTH-ROWO-LOLO-FOOB",
+			},
+			exp: "0kbOC1LIwDc+DQnLOFcIbnxqfKNg58b7WA3vREdd7ZQ=",
+		},
+		{
+			data: &Publish{
+				Keys: []ExposureKey{
+					{
+						Key:              "zdCW5HrOKbirxmQVc0L/eA",
+						IntervalNumber:   263123,
+						IntervalCount:    144,
+						TransmissionRisk: 8,
+					},
+					{
+						Key:              "t+k51ifogJo9jq3GH9LWGQ",
+						IntervalNumber:   263267,
+						IntervalCount:    144,
+						TransmissionRisk: 8,
+					},
+					{
+						Key:              "3uXRrSlcv1+OMI3oFtdaUw",
+						IntervalNumber:   263411,
+						IntervalCount:    27,
+						TransmissionRisk: 8,
+					},
+				},
+				Regions:             []string{"gB", "us"},
+				AppPackageName:      "com.google.android.apps.exposurenotification",
+				VerificationPayload: "BREA-KMEO-FFAP-IECE",
+			},
+			exp: "KYCIywFRTwXCnYCF4TTFaH//iMb/fBQENqIhtzrJPxA=",
+		},
+	}
+
+	for _, c := range cases {
+		c := c
+
+		nonce := c.data.AndroidNonce()
+		if nonce != c.exp {
+			t.Errorf("getNonce: got '%v', want '%v'", nonce, c.exp)
+		}
+	}
+}
+
 func generateKey(t *testing.T) []byte {
+	t.Helper()
 	key := make([]byte, 16)
 	_, err := rand.Read(key)
 	if err != nil {
diff --git a/internal/verification/verify_test.go b/internal/verification/verify_test.go
index d59aff4..f1a1b9c 100644
--- a/internal/verification/verify_test.go
+++ b/internal/verification/verify_test.go
@@ -17,12 +17,12 @@ package verification
 import (
 	"context"
 	"fmt"
+	"strings"
 	"testing"
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/android"
 	"github.com/google/exposure-notifications-server/internal/model"
-	"github.com/google/exposure-notifications-server/internal/model/apiconfig"
 )
 
 const (
@@ -30,21 +30,21 @@ const (
 )
 
 func TestVerifyRegions(t *testing.T) {
-	allRegions := &apiconfig.APIConfig{
+	allRegions := &model.APIConfig{
 		AppPackageName:  appPkgName,
 		AllowAllRegions: true,
 	}
-	usCaRegions := &apiconfig.APIConfig{
+	usCaRegions := &model.APIConfig{
 		AppPackageName: appPkgName,
-		AllowedRegions: make(map[string]bool),
+		AllowedRegions: make(map[string]struct{}),
 	}
-	usCaRegions.AllowedRegions["US"] = true
-	usCaRegions.AllowedRegions["CA"] = true
+	usCaRegions.AllowedRegions["US"] = struct{}{}
+	usCaRegions.AllowedRegions["CA"] = struct{}{}
 
 	cases := []struct {
 		Data *model.Publish
 		Msg  string
-		Cfg  *apiconfig.APIConfig
+		Cfg  *model.APIConfig
 	}{
 		{
 			&model.Publish{Regions: []string{"US"}},
@@ -89,26 +89,21 @@ func TestVerifyRegions(t *testing.T) {
 }
 
 func TestVerifySafetyNet(t *testing.T) {
-	allRegions := &apiconfig.APIConfig{
+	allRegions := &model.APIConfig{
 		AppPackageName:  appPkgName,
 		AllowAllRegions: true,
 	}
-	allRegionsSafetyCheckDisabled := &apiconfig.APIConfig{
-		AppPackageName:  appPkgName,
-		AllowAllRegions: true,
-		BypassSafetyNet: true,
-	}
 
 	cases := []struct {
 		Data              *model.Publish
 		Msg               string
-		Cfg               *apiconfig.APIConfig
+		Cfg               *model.APIConfig
 		AttestationResult error
 	}{
 		{
 			// With no configuration, return err.
 			&model.Publish{Regions: []string{"US"}},
-			"cannot enforce safetynet, no application config",
+			"cannot enforce SafetyNet",
 			nil,
 			nil,
 		}, {
@@ -124,19 +119,12 @@ func TestVerifySafetyNet(t *testing.T) {
 			"android.ValidateAttestation: mocked",
 			allRegions,
 			fmt.Errorf("mocked"),
-		}, {
-			// Verify when ValidateAttestation raises err, with safety check
-			// disabled, return nil.
-			&model.Publish{Regions: []string{"US"}},
-			"",
-			allRegionsSafetyCheckDisabled,
-			fmt.Errorf("mocked"),
 		},
 	}
 
 	for i, c := range cases {
 		var ctx = context.Background()
-		androidValidateAttestation = func(context.Context, string, android.VerifyOpts) error {
+		androidValidateAttestation = func(context.Context, string, *android.VerifyOpts) error {
 			return c.AttestationResult
 		}
 
@@ -148,7 +136,7 @@ func TestVerifySafetyNet(t *testing.T) {
 			t.Errorf("%v got %v, wanted no error", i, err)
 			continue
 		}
-		if err.Error() != c.Msg {
+		if !strings.Contains(err.Error(), c.Msg) {
 			t.Errorf("%v wrong error, got %v, want %v", i, err, c.Msg)
 		}
 	}