Repository: google/exposure-notifications-server
Pull request #508 (2020-06-01 01:23:13)

Hook up health authority verification certificate validation
- Add HTTP level testing to the publish API
 - starts in mem http server, sends request, verifies results in database
- Add additional tests to phaverify
- Add transmission risk override application

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/508#" 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":[]} -->
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/508#" title="Author self-approved">mikehelmick</a>*, *<a href="https://github.com/google/exposure-notifications-server/pull/508#pullrequestreview-421862456" title="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)~~ [mikehelmick,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":[]} -->

Patch:
diff --git a/internal/publish/model/exposure_model.go b/internal/publish/model/exposure_model.go
index 6c70de0..2cb6883 100644
--- a/internal/publish/model/exposure_model.go
+++ b/internal/publish/model/exposure_model.go
@@ -21,6 +21,8 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/base64util"
+
+	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
 )
 
 const (
@@ -75,6 +77,44 @@ type Publish struct {
 	DeviceVerificationPayload string `json:"deviceVerificationPayload"` // DEPRECATED
 }
 
+// ApplyTransmissionRiskOverrides modifies the transmission risk values in the publish request
+// based on the provided TransmissionRiskVector.
+// In the live system, the TransmissionRiskVector values come from a trusted public health authority
+// and are embedded in the verification certificate (JWT) transmitted on the publish request.
+func (p *Publish) ApplyTransmissionRiskOverrides(overrides verifyapi.TransmissionRiskVector) {
+	if len(overrides) == 0 {
+		return
+	}
+	// The default sort order for TransmissionRiskVector is descending by SinceRollingPeriod.
+	sort.Sort(overrides)
+	// Sort the keys with the largest start interval first (descending), same as overrides.
+	sort.Slice(p.Keys, func(i int, j int) bool {
+		return p.Keys[i].IntervalNumber > p.Keys[j].IntervalNumber
+	})
+
+	overrideIdx := 0
+	for i, eKey := range p.Keys {
+		// Advance the overrideIdx until the current key is covered or we exhaust the
+		// override index.
+		for overrideIdx < len(overrides) &&
+			eKey.IntervalNumber+eKey.IntervalCount <= overrides[overrideIdx].SinceRollingPeriod {
+			overrideIdx++
+		}
+
+		// If we've run out of overrides to apply, then we have to break free.
+		if overrideIdx >= len(overrides) {
+			break
+		}
+
+		// Check to see if this key is in the current override.
+		// If the key was EVERY valid during the SinceRollingPeriod then the override applies.
+		if eKey.IntervalNumber+eKey.IntervalCount >= overrides[overrideIdx].SinceRollingPeriod {
+			p.Keys[i].TransmissionRisk = overrides[overrideIdx].TranismissionRisk
+			// don't advance overrideIdx, there might be additional keys in this override.
+		}
+	}
+}
+
 // 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/publish.go b/internal/publish/publish.go
index ed971b2..67c58c9 100644
--- a/internal/publish/publish.go
+++ b/internal/publish/publish.go
@@ -25,12 +25,13 @@ import (
 	"go.opencensus.io/trace"
 
 	"github.com/google/exposure-notifications-server/internal/authorizedapp"
-	"github.com/google/exposure-notifications-server/internal/publish/database"
-
 	"github.com/google/exposure-notifications-server/internal/jsonutil"
 	"github.com/google/exposure-notifications-server/internal/logging"
+	"github.com/google/exposure-notifications-server/internal/publish/database"
 	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/exposure-notifications-server/internal/serverenv"
+	"github.com/google/exposure-notifications-server/internal/verification"
+	verifydb "github.com/google/exposure-notifications-server/internal/verification/database"
 )
 
 // NewHandler creates the HTTP handler for the TTK publishing API.
@@ -58,6 +59,7 @@ func NewHandler(ctx context.Context, config *Config, env *serverenv.ServerEnv) (
 		config:                config,
 		database:              database.New(env.Database()),
 		authorizedAppProvider: env.AuthorizedAppProvider(),
+		verifier:              verification.New(verifydb.New(env.Database())),
 	}, nil
 }
 
@@ -67,6 +69,7 @@ type publishHandler struct {
 	transformer           *model.Transformer
 	database              *database.PublishDB
 	authorizedAppProvider authorizedapp.Provider
+	verifier              *verification.Verifier
 }
 
 type response struct {
@@ -82,6 +85,7 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 	defer span.End()
 
 	logger := logging.FromContext(ctx)
+	metrics := h.serverenv.MetricsExporter(ctx)
 
 	var data model.Publish
 	code, err := jsonutil.Unmarshal(w, r, &data)
@@ -135,7 +139,24 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 		}
 	}
 
-	// TODO(helmick): Hook up PHA verification.
+	// Perform health authority certificat verification.
+	overrides, err := h.verifier.VerifyDiagnosisCertificate(ctx, appConfig, &data)
+	if err != nil {
+		if appConfig.BypassHealthAuthorityVerification {
+			logger.Warnf("bypassing health authority certificate verification for app: %v", appConfig.AppPackageName)
+			metrics.WriteInt("publish-health-authority-verification-bypassed", true, 1)
+		} else {
+			message := fmt.Sprintf("unable to validate diagnosis verification: %v", err)
+			logger.Error(message)
+			span.SetStatus(trace.Status{Code: trace.StatusCodeInvalidArgument, Message: message})
+			return response{status: http.StatusUnauthorized, message: message, metric: "publish-bad-verification", count: 1}
+		}
+	}
+
+	// Apply overrides
+	if len(overrides) > 0 {
+		data.ApplyTransmissionRiskOverrides(overrides)
+	}
 
 	batchTime := time.Now()
 	exposures, err := h.transformer.TransformPublish(&data, batchTime)
diff --git a/pkg/api/v1alpha1/verification_types.go b/pkg/api/v1alpha1/verification_types.go
index 579baab..e0569f5 100644
--- a/pkg/api/v1alpha1/verification_types.go
+++ b/pkg/api/v1alpha1/verification_types.go
@@ -40,8 +40,8 @@ var _ sort.Interface = TransmissionRiskVector{}
 
 // TransmissionRiskOverride is an indvidual transmission risk override.
 type TransmissionRiskOverride struct {
-	TranismissionRisk  int `json:"tr"`
-	SinceRollingPeriod int `json:"sinceRollingPeriod"`
+	TranismissionRisk  int   `json:"tr"`
+	SinceRollingPeriod int32 `json:"sinceRollingPeriod"`
 }
 
 func (a TransmissionRiskVector) Len() int {


Test patch:
diff --git a/internal/publish/model/exposure_model_test.go b/internal/publish/model/exposure_model_test.go
index 97f5bc4..1e2f87a 100644
--- a/internal/publish/model/exposure_model_test.go
+++ b/internal/publish/model/exposure_model_test.go
@@ -18,11 +18,13 @@ import (
 	"crypto/rand"
 	"encoding/base64"
 	"fmt"
+	"sort"
 	"strings"
 	"testing"
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/base64util"
+	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
 	"github.com/google/go-cmp/cmp"
 )
 
@@ -450,3 +452,192 @@ func TestTransformOverlapping(t *testing.T) {
 		})
 	}
 }
+
+func TestApplyOverrides(t *testing.T) {
+	cases := []struct {
+		Name      string
+		Publish   Publish
+		Overrides verifyapi.TransmissionRiskVector
+		Want      []ExposureKey
+	}{
+		{
+			Name: "no overrides",
+			Publish: Publish{
+				Keys: []ExposureKey{
+					{
+						Key:              "A",
+						IntervalNumber:   1,
+						IntervalCount:    2,
+						TransmissionRisk: 1,
+					},
+				},
+			},
+			Overrides: make([]verifyapi.TransmissionRiskOverride, 0),
+			Want: []ExposureKey{
+				{
+					Key:              "A",
+					IntervalNumber:   1,
+					IntervalCount:    2,
+					TransmissionRisk: 1,
+				},
+			},
+		},
+		{
+			Name: "aligned interval transmission risks",
+			Publish: Publish{
+				Keys: []ExposureKey{
+					{
+						Key:              "A",
+						IntervalNumber:   1,
+						IntervalCount:    2,
+						TransmissionRisk: 1,
+					},
+					{
+						Key:              "B",
+						IntervalNumber:   3,
+						IntervalCount:    2,
+						TransmissionRisk: 1,
+					},
+					{
+						Key:              "C",
+						IntervalNumber:   5,
+						IntervalCount:    2,
+						TransmissionRisk: 1,
+					},
+				},
+			},
+			Overrides: []verifyapi.TransmissionRiskOverride{
+				{
+					SinceRollingPeriod: 5,
+					TranismissionRisk:  5,
+				},
+				{
+					SinceRollingPeriod: 3,
+					TranismissionRisk:  3,
+				},
+				{
+					SinceRollingPeriod: 0,
+					TranismissionRisk:  0,
+				},
+			},
+			Want: []ExposureKey{
+				{
+					Key:              "A",
+					IntervalNumber:   1,
+					IntervalCount:    2,
+					TransmissionRisk: 0,
+				},
+				{
+					Key:              "B",
+					IntervalNumber:   3,
+					IntervalCount:    2,
+					TransmissionRisk: 3,
+				},
+				{
+					Key:              "C",
+					IntervalNumber:   5,
+					IntervalCount:    2,
+					TransmissionRisk: 5,
+				},
+			},
+		},
+		{
+			Name: "unaligned, with 0 fallback.",
+			Publish: Publish{
+				Keys: []ExposureKey{
+					{
+						Key:              "A",
+						IntervalNumber:   1,
+						IntervalCount:    2,
+						TransmissionRisk: 1,
+					},
+					{
+						Key:              "B",
+						IntervalNumber:   3,
+						IntervalCount:    2,
+						TransmissionRisk: 1,
+					},
+					{
+						Key:              "C",
+						IntervalNumber:   5,
+						IntervalCount:    2,
+						TransmissionRisk: 1,
+					},
+				},
+			},
+			Overrides: []verifyapi.TransmissionRiskOverride{
+				{
+					SinceRollingPeriod: 4,
+					TranismissionRisk:  5, // anything effective at time >= 4 gets TR 5.
+				},
+				{
+					SinceRollingPeriod: 0,
+					TranismissionRisk:  2, // 2 since beginning of time.
+				},
+			},
+			Want: []ExposureKey{
+				{
+					Key:              "A",
+					IntervalNumber:   1,
+					IntervalCount:    2,
+					TransmissionRisk: 2,
+				},
+				{
+					Key:              "B",
+					IntervalNumber:   3,
+					IntervalCount:    2,
+					TransmissionRisk: 5,
+				},
+				{
+					Key:              "C",
+					IntervalNumber:   5,
+					IntervalCount:    2,
+					TransmissionRisk: 5,
+				},
+			},
+		},
+		{
+			Name: "overrides run out",
+			Publish: Publish{
+				Keys: []ExposureKey{
+					{
+						Key:              "A",
+						IntervalNumber:   1,
+						IntervalCount:    2,
+						TransmissionRisk: 1,
+					},
+				},
+			},
+			Overrides: []verifyapi.TransmissionRiskOverride{
+				{
+					SinceRollingPeriod: 4,
+					TranismissionRisk:  5, // anything effective at time >= 4 gets TR 5.
+				},
+			},
+			Want: []ExposureKey{
+				{
+					Key:              "A",
+					IntervalNumber:   1,
+					IntervalCount:    2,
+					TransmissionRisk: 1,
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.Name, func(t *testing.T) {
+			tc.Publish.ApplyTransmissionRiskOverrides(tc.Overrides)
+			sorter := cmp.Transformer("Sort", func(in []ExposureKey) []ExposureKey {
+				out := append([]ExposureKey(nil), in...) // Copy input to avoid mutating it
+				sort.Slice(out, func(i int, j int) bool {
+					return strings.Compare(out[i].Key, out[j].Key) <= 0
+				})
+				return out
+			})
+			if diff := cmp.Diff(tc.Want, tc.Publish.Keys, sorter); diff != "" {
+				t.Errorf("mismatch (-want, +got):\n%s", diff)
+			}
+		})
+	}
+}
diff --git a/internal/publish/publish_test.go b/internal/publish/publish_test.go
new file mode 100644
index 0000000..813b698
--- /dev/null
+++ b/internal/publish/publish_test.go
@@ -0,0 +1,496 @@
+// 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 publish
+
+import (
+	"bytes"
+	"context"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/json"
+	"encoding/pem"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"sort"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/authorizedapp"
+	aadb "github.com/google/exposure-notifications-server/internal/authorizedapp/database"
+	aamodel "github.com/google/exposure-notifications-server/internal/authorizedapp/model"
+	"github.com/google/exposure-notifications-server/internal/base64util"
+	coredb "github.com/google/exposure-notifications-server/internal/database"
+	pubdb "github.com/google/exposure-notifications-server/internal/publish/database"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+	"github.com/google/exposure-notifications-server/internal/util"
+	verdb "github.com/google/exposure-notifications-server/internal/verification/database"
+	vermodel "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"
+
+	"github.com/dgrijalva/jwt-go"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+)
+
+// Holds a single signing key and the PEM public key.
+// Each test case has it's own key issued.
+type signingKey struct {
+	Key       *ecdsa.PrivateKey
+	PublicKey string
+}
+
+func newSigningKey(t *testing.T) *signingKey {
+	t.Helper()
+	privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	publicKey := privateKey.Public()
+	x509EncodedPub, err := x509.MarshalPKIXPublicKey(publicKey)
+	if err != nil {
+		t.Fatal(err)
+	}
+	pemEncodedPub := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: x509EncodedPub})
+	pemPublicKey := string(pemEncodedPub)
+
+	return &signingKey{
+		Key:       privateKey,
+		PublicKey: pemPublicKey,
+	}
+}
+
+type jwtConfig struct {
+	HealthAuthority    *vermodel.HealthAuthority
+	HealthAuthorityKey *vermodel.HealthAuthorityKey
+	Publish            *model.Publish
+	Key                *ecdsa.PrivateKey
+	JWTWarp            time.Duration
+	Overrides          verifyapi.TransmissionRiskVector
+}
+
+// Based on the publish request, generate a JWT as if it came from the
+// authorized health authority.
+func issueJWT(t *testing.T, cfg jwtConfig) (jwtText, hmacKey string) {
+	t.Helper()
+
+	hmacKeyBytes := make([]byte, 32)
+	if _, err := rand.Read(hmacKeyBytes); err != nil {
+		t.Fatal(err)
+	}
+	hmacKey = base64.StdEncoding.EncodeToString(hmacKeyBytes)
+
+	hmacBytes, err := utils.CalculateExposureKeyHMAC(cfg.Publish.Keys, hmacKeyBytes)
+	if err != nil {
+		t.Fatal(err)
+	}
+	hmac := base64.StdEncoding.EncodeToString(hmacBytes)
+
+	claims := verifyapi.NewVerificationClaims()
+	claims.Audience = cfg.HealthAuthority.Audience
+	claims.Issuer = cfg.HealthAuthority.Issuer
+	claims.IssuedAt = time.Now().Add(cfg.JWTWarp).Unix()
+	claims.ExpiresAt = time.Now().Add(cfg.JWTWarp).Add(5 * time.Minute).Unix()
+	claims.SignedMAC = hmac
+	claims.KeyVersion = cfg.HealthAuthorityKey.Version
+	claims.TransmissionRisks = cfg.Overrides
+
+	token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+	jwtText, err = token.SignedString(cfg.Key)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return
+}
+
+func TestPublishWithBypass(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		Name               string
+		ContentType        string // if blank, application/json
+		SigningKey         *signingKey
+		HealthAuthority    *vermodel.HealthAuthority    // Automatically linked to keys.
+		HealthAuthorityKey *vermodel.HealthAuthorityKey // Automatically linked to SigningKey
+		AuthorizedApp      *aamodel.AuthorizedApp       // Automatically linked to health authorities.
+		Publish            model.Publish
+		JWTTiming          time.Duration
+		Overrides          verifyapi.TransmissionRiskVector
+		WantTRAdjustment   []int
+		Code               int
+		Error              string
+	}{
+		{
+			Name: "successful insert, bypass HA verification",
+			AuthorizedApp: func() *aamodel.AuthorizedApp {
+				authApp := aamodel.NewAuthorizedApp()
+				authApp.AppPackageName = "com.example.health"
+				authApp.BypassHealthAuthorityVerification = true
+				authApp.AllowedRegions["US"] = struct{}{}
+				return authApp
+			}(),
+			Publish: model.Publish{
+				Keys:           util.GenerateExposureKeys(2, 5, false),
+				Regions:        []string{"US"},
+				AppPackageName: "com.example.health",
+			},
+			Code: http.StatusOK,
+		},
+		{
+			Name:        "invalid content type",
+			ContentType: "application/pdf",
+			Code:        http.StatusBadRequest,
+			Error:       "content-type is not application/json",
+		},
+		{
+			Name: "bad app package name",
+			AuthorizedApp: func() *aamodel.AuthorizedApp {
+				authApp := aamodel.NewAuthorizedApp()
+				authApp.AppPackageName = "com.example.health"
+				authApp.BypassHealthAuthorityVerification = true
+				authApp.AllowedRegions["US"] = struct{}{}
+				return authApp
+			}(),
+			Publish: model.Publish{
+				Keys:           util.GenerateExposureKeys(2, 5, false),
+				Regions:        []string{"US"},
+				AppPackageName: "com.example.health.WRONG",
+			},
+			Code:  http.StatusUnauthorized,
+			Error: "unauthorized app",
+		},
+		{
+			Name: "write to unauthorized region",
+			AuthorizedApp: func() *aamodel.AuthorizedApp {
+				authApp := aamodel.NewAuthorizedApp()
+				authApp.AppPackageName = "com.example.health"
+				authApp.BypassHealthAuthorityVerification = true
+				authApp.AllowedRegions["US"] = struct{}{}
+				return authApp
+			}(),
+			Publish: model.Publish{
+				Keys:           util.GenerateExposureKeys(2, 5, false),
+				Regions:        []string{"CA"},
+				AppPackageName: "com.example.health",
+			},
+			Code:  http.StatusUnauthorized,
+			Error: "tried to write to unauthorized region CA",
+		},
+		{
+			Name: "bad HA certificate",
+			AuthorizedApp: func() *aamodel.AuthorizedApp {
+				authApp := aamodel.NewAuthorizedApp()
+				authApp.AppPackageName = "com.example.health"
+				authApp.AllowedRegions["US"] = struct{}{}
+				return authApp
+			}(),
+			Publish: model.Publish{
+				Keys:                util.GenerateExposureKeys(2, 5, false),
+				Regions:             []string{"US"},
+				AppPackageName:      "com.example.health",
+				VerificationPayload: "totally not a JWT",
+			},
+			Code:  http.StatusUnauthorized,
+			Error: "unable to validate diagnosis verification: token contains an invalid number of segments",
+		},
+		{
+			Name:       "valid HA certificate",
+			SigningKey: newSigningKey(t),
+			HealthAuthority: &vermodel.HealthAuthority{
+				Issuer:   "doh.my.gov",
+				Audience: "unit.test.server",
+				Name:     "Unit Test Gov DOH",
+			},
+			HealthAuthorityKey: &vermodel.HealthAuthorityKey{
+				Version: "v1",
+				From:    time.Now().Add(-1 * time.Minute),
+			},
+			AuthorizedApp: func() *aamodel.AuthorizedApp {
+				authApp := aamodel.NewAuthorizedApp()
+				authApp.AppPackageName = "com.example.health"
+				authApp.AllowedRegions["US"] = struct{}{}
+				return authApp
+			}(),
+			Publish: model.Publish{
+				Keys:                util.GenerateExposureKeys(2, 5, false),
+				Regions:             []string{"US"},
+				AppPackageName:      "com.example.health",
+				VerificationPayload: "totally not a JWT",
+			},
+			Code: http.StatusOK,
+		},
+		{
+			Name:       "valid HA certificate with overrides",
+			SigningKey: newSigningKey(t),
+			HealthAuthority: &vermodel.HealthAuthority{
+				Issuer:   "doh.my.gov",
+				Audience: "unit.test.server",
+				Name:     "Unit Test Gov DOH",
+			},
+			HealthAuthorityKey: &vermodel.HealthAuthorityKey{
+				Version: "v1",
+				From:    time.Now().Add(-1 * time.Minute),
+			},
+			AuthorizedApp: func() *aamodel.AuthorizedApp {
+				authApp := aamodel.NewAuthorizedApp()
+				authApp.AppPackageName = "com.example.health"
+				authApp.AllowedRegions["US"] = struct{}{}
+				return authApp
+			}(),
+			Publish: model.Publish{
+				Keys:                util.GenerateExposureKeys(2, 5, false),
+				Regions:             []string{"US"},
+				AppPackageName:      "com.example.health",
+				VerificationPayload: "totally not a JWT",
+			},
+			Overrides: []verifyapi.TransmissionRiskOverride{
+				{
+					TranismissionRisk:  8,
+					SinceRollingPeriod: 0,
+				},
+			},
+			WantTRAdjustment: []int{8, 8}, // 2 entries, both override to 8
+			Code:             http.StatusOK,
+		},
+		{
+			Name:       "certificate in future",
+			SigningKey: newSigningKey(t),
+			HealthAuthority: &vermodel.HealthAuthority{
+				Issuer:   "doh.my.gov",
+				Audience: "unit.test.server",
+				Name:     "Unit Test Gov DOH",
+			},
+			HealthAuthorityKey: &vermodel.HealthAuthorityKey{
+				Version: "v1",
+				From:    time.Now().Add(-1 * time.Minute),
+			},
+			AuthorizedApp: func() *aamodel.AuthorizedApp {
+				authApp := aamodel.NewAuthorizedApp()
+				authApp.AppPackageName = "com.example.health"
+				authApp.AllowedRegions["US"] = struct{}{}
+				return authApp
+			}(),
+			Publish: model.Publish{
+				Keys:                util.GenerateExposureKeys(2, 5, false),
+				Regions:             []string{"US"},
+				AppPackageName:      "com.example.health",
+				VerificationPayload: "totally not a JWT",
+			},
+			JWTTiming: time.Hour,
+			Code:      http.StatusUnauthorized,
+			Error:     "unable to validate diagnosis verification: Token used before issued",
+		},
+		{
+			Name:       "certificate expired",
+			SigningKey: newSigningKey(t),
+			HealthAuthority: &vermodel.HealthAuthority{
+				Issuer:   "doh.my.gov",
+				Audience: "unit.test.server",
+				Name:     "Unit Test Gov DOH",
+			},
+			HealthAuthorityKey: &vermodel.HealthAuthorityKey{
+				Version: "v1",
+				From:    time.Now().Add(-1 * time.Minute),
+			},
+			AuthorizedApp: func() *aamodel.AuthorizedApp {
+				authApp := aamodel.NewAuthorizedApp()
+				authApp.AppPackageName = "com.example.health"
+				authApp.AllowedRegions["US"] = struct{}{}
+				return authApp
+			}(),
+			Publish: model.Publish{
+				Keys:                util.GenerateExposureKeys(2, 5, false),
+				Regions:             []string{"US"},
+				AppPackageName:      "com.example.health",
+				VerificationPayload: "totally not a JWT",
+			},
+			JWTTiming: -6 * time.Minute,
+			Code:      http.StatusUnauthorized,
+			Error:     "unable to validate diagnosis verification: token is expired by 1m",
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.Name, func(t *testing.T) {
+			// Database init for all modules that will be used.
+			testDB := coredb.NewTestDatabase(t)
+			ctx := context.Background()
+
+			// And set up publish handler up front.
+			config := Config{}
+			config.MinRequestDuration = time.Millisecond
+			config.AuthorizedApp.CacheDuration = time.Nanosecond
+			config.TruncateWindow = time.Second
+			config.DebugAPIResponses = true
+			config.MaxKeysOnPublish = 14
+			config.MaxIntervalAge = 14 * 24 * time.Hour
+			aaProvider, err := authorizedapp.NewDatabaseProvider(ctx, testDB, config.AuthorizedAppConfig())
+			if err != nil {
+				t.Fatal(err)
+			}
+			env := serverenv.New(ctx,
+				serverenv.WithDatabase(testDB),
+				serverenv.WithAuthorizedAppProvider(aaProvider))
+			// Some config overrides for test.
+			handler, err := NewHandler(ctx, &config, env)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			// See if there is a health authority to set up.
+			if tc.HealthAuthority != nil {
+				verDB := verdb.New(testDB)
+				if err := verDB.AddHealthAuthority(ctx, tc.HealthAuthority); err != nil {
+					t.Fatal(err)
+				}
+				if tc.HealthAuthorityKey != nil {
+					if tc.SigningKey == nil {
+						t.Fatal("test cases that have health authority keys registered must provide a siningKey as well")
+					}
+					// Join in the public key.
+					tc.HealthAuthorityKey.PublicKeyPEM = tc.SigningKey.PublicKey
+					if err := verDB.AddHealthAuthorityKey(ctx, tc.HealthAuthority, tc.HealthAuthorityKey); err != nil {
+						t.Fatal(err)
+					}
+				}
+			}
+
+			// Insert the authorized app for the test case, if one exists.
+			if tc.AuthorizedApp != nil {
+				appDB := aadb.New(env.Database())
+				// join in the health authority, if there is one for this test.
+				if tc.HealthAuthority != nil {
+					tc.AuthorizedApp.AllowedHealthAuthorityIDs[tc.HealthAuthority.ID] = struct{}{}
+				}
+				if err := appDB.InsertAuthorizedApp(ctx, tc.AuthorizedApp); err != nil {
+					t.Fatal(err)
+				}
+			}
+			pubDB := pubdb.New(env.Database())
+
+			// If verification is being used. The JWT and HMAC Salt must be incorporated.
+			if tc.HealthAuthority != nil {
+				cfg := jwtConfig{
+					HealthAuthority:    tc.HealthAuthority,
+					HealthAuthorityKey: tc.HealthAuthorityKey,
+					Publish:            &tc.Publish,
+					Key:                tc.SigningKey.Key,
+					JWTWarp:            tc.JWTTiming,
+					Overrides:          tc.Overrides,
+				}
+				verification, salt := issueJWT(t, cfg)
+				tc.Publish.VerificationPayload = verification
+				tc.Publish.HMACKey = salt
+			}
+
+			// Marshal the provided publish request.
+			jsonString, err := json.Marshal(tc.Publish)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			server := httptest.NewServer(handler)
+			defer server.Close()
+
+			// make the request
+			contentType := "application/json"
+			if tc.ContentType != "" {
+				contentType = tc.ContentType
+			}
+			resp, err := server.Client().Post(server.URL, contentType, strings.NewReader(string(jsonString)))
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			// For non success status, check that they body contains the expected message
+			respBytes, err := ioutil.ReadAll(resp.Body)
+			if err != nil {
+				t.Fatal(err)
+			}
+			respBody := string(respBytes)
+			if resp.StatusCode != tc.Code {
+				t.Fatalf("http response code want: %v, got %v. Extended: %v", tc.Code, resp.StatusCode, respBody)
+			}
+
+			if resp.StatusCode == http.StatusOK {
+				// For success requests, verify that the exposures were inserted.
+				criteria := pubdb.IterateExposuresCriteria{
+					IncludeRegions: []string{"US"},
+					SinceTimestamp: time.Now().Add(-1 * time.Minute),
+					UntilTimestamp: time.Now().Add(time.Minute),
+				}
+
+				got := make([]*model.Exposure, 0, len(tc.Publish.Keys))
+				_, err = pubDB.IterateExposures(ctx, criteria, func(ex *model.Exposure) error {
+					got = append(got, ex)
+					return nil
+				})
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				want := make([]*model.Exposure, 0, len(tc.Publish.Keys))
+				for _, k := range tc.Publish.Keys {
+					if key, err := base64util.DecodeString(k.Key); err != nil {
+						t.Fatal(err)
+					} else {
+						want = append(want,
+							&model.Exposure{
+								ExposureKey:      key,
+								AppPackageName:   tc.Publish.AppPackageName,
+								TransmissionRisk: k.TransmissionRisk,
+								IntervalNumber:   k.IntervalNumber,
+								IntervalCount:    k.IntervalCount,
+								Regions:          tc.Publish.Regions,
+								LocalProvenance:  true,
+								FederationSyncID: 0,
+							})
+					}
+				}
+
+				// Adjust expectations based on transmission risk overrides placed in JWT.
+				for i, a := range tc.WantTRAdjustment {
+					want[i].TransmissionRisk = a
+				}
+
+				sorter := cmp.Transformer("Sort", func(in []*model.Exposure) []*model.Exposure {
+					out := append([]*model.Exposure(nil), in...) // Copy input to avoid mutating it
+					sort.Slice(out, func(i int, j int) bool {
+						return bytes.Compare(out[i].ExposureKey, out[j].ExposureKey) <= 0
+					})
+					return out
+				})
+				ignoreCreatedAt := cmpopts.IgnoreFields(*want[0], "CreatedAt")
+
+				if diff := cmp.Diff(want, got, sorter, ignoreCreatedAt); diff != "" {
+					t.Errorf("mismatch (-want, +got):\n%s", diff)
+				}
+			} else {
+				if !strings.Contains(respBody, tc.Error) {
+					t.Errorf("missing error text '%v', got '%v'", tc.Error, respBody)
+				}
+			}
+		})
+	}
+}
diff --git a/internal/verification/phaverify_test.go b/internal/verification/phaverify_test.go
index 8a3cba1..516f0d3 100644
--- a/internal/verification/phaverify_test.go
+++ b/internal/verification/phaverify_test.go
@@ -25,6 +25,7 @@ import (
 	"crypto/x509"
 	"encoding/base64"
 	"encoding/pem"
+	"strings"
 	"testing"
 	"time"
 
@@ -40,115 +41,154 @@ import (
 	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,
-			},
+	cases := []struct {
+		Name             string
+		Warp             time.Duration
+		MacAdjustment    string
+		MacKeyAdjustment string
+		Error            string
+	}{
+		{
+			Name: "happy path, valid cert",
+		},
+		{
+			Name:  "past",
+			Warp:  -1 * time.Hour,
+			Error: "token is expired by",
+		},
+		{
+			Name:  "future",
+			Warp:  1 * time.Hour,
+			Error: "Token used before issued",
+		},
+		{
+			Name:          "invalid hmac",
+			MacAdjustment: "iruinedit",
+			Error:         "HMAC mismatch, publish request does not match disgnosis verification certificate",
+		},
+		{
+			Name:             "invalid hmac",
+			MacKeyAdjustment: "4",
+			Error:            "HMAC mismatch, publish request does not match disgnosis verification certificate",
 		},
 	}
 
-	// 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)
+	for _, tc := range cases {
+		t.Run(tc.Name, func(t *testing.T) {
+			// 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().Add(tc.Warp).Unix()
+			claims.ExpiresAt = time.Now().Add(tc.Warp).Add(5 * time.Minute).Unix()
+			claims.SignedMAC = tc.MacAdjustment + 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 = tc.MacKeyAdjustment + hmacKeyB64
+
+			// Actually test the verify code.
+			verifier := New(haDB)
+			overrides, err := verifier.VerifyDiagnosisCertificate(ctx, authApp, &publish)
+			if err != nil {
+				if !strings.Contains(err.Error(), tc.Error) {
+					t.Fatalf("wanted error '%v', got error '%v'", tc.Error, err.Error())
+				}
+			} else if tc.Error != "" {
+				t.Fatalf("wanted error '%v', but got nil", tc.Error)
+			}
+			if len(overrides) != 0 {
+				t.Errorf("wanted no overrides, got %v", overrides)
+			}
+		})
 	}
 }