Repository: google/exposure-notifications-server
Pull request #824 (2020-08-06 04:46:21)

Add new key-rotation service to our build/deploy
Issue https://github.com/google/exposure-notifications-server/issues/791

<!-- Please include the 'why' behind your changes if no issue exists -->
## Proposed Changes

* Include the key-rotation service for build/deploy/promote

**Release Note**

<!-- Enter your extended release note in the below block. If the PR requires
additional action from users switching to the new release, include the string
"action required". If no release note is required, write "NONE". -->

```release-note
Added key-rotation endpoint to build/deploy/promote
```

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

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

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

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

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

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["crwilcox"]} -->
google-oss-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

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

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

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

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

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

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

<details>

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

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

<details>

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

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

<details>

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

Patch:
diff --git a/docs/getting-started/deploying.md b/docs/getting-started/deploying.md
index 55cbbd0..b8cb000 100644
--- a/docs/getting-started/deploying.md
+++ b/docs/getting-started/deploying.md
@@ -42,6 +42,7 @@ options. Configuration options are specified via environment variables.
 | federation-in    | cmd/federation-in    | Pulls federation results from federation partners |
 | federation-out   | cmd/federation-out   | gRPC federation requests listener |
 | generate         | cmd/generate         | Sample service that generates data |
+| key-rotation     | cmd/key-rotation     | Generates new revision keys and retires old ones |
 
 
 ## Before you begin
diff --git a/internal/debugger/handler_debug.go b/internal/debugger/handler_debug.go
index 77777ad..010903b 100644
--- a/internal/debugger/handler_debug.go
+++ b/internal/debugger/handler_debug.go
@@ -47,6 +47,7 @@ func (s *Server) handleDebug(ctx context.Context) http.HandlerFunc {
 		"federationin",
 		"federationout",
 		"generate",
+		"key-rotation",
 	}
 
 	type response struct {
diff --git a/internal/verification/phaverify.go b/internal/verification/phaverify.go
index c268ae4..c3ac111 100644
--- a/internal/verification/phaverify.go
+++ b/internal/verification/phaverify.go
@@ -138,12 +138,17 @@ func (v *Verifier) VerifyDiagnosisCertificate(ctx context.Context, authApp *aamo
 	if err != nil {
 		return nil, fmt.Errorf("error decoding HMAC secret from publish request: %w", err)
 	}
-	wantHMAC, err := utils.CalculateExposureKeyHMAC(publish.Keys, secret)
+	// Allow the HMAC to be calculated without transmission risk values IFF all transmission risks are zero.
+	validHMACs, err := utils.CalculateAllAllowedExposureKeyHMAC(publish.Keys, secret)
 	if err != nil {
 		return nil, fmt.Errorf("calculating expected HMAC: %w", err)
 	}
 
-	if !hmac.Equal(wantHMAC, jwtHMAC) {
+	valid := false
+	for _, wantHMAC := range validHMACs {
+		valid = valid || hmac.Equal(wantHMAC, jwtHMAC)
+	}
+	if !valid {
 		return nil, fmt.Errorf("HMAC mismatch, publish request does not match disgnosis verification certificate")
 	}
 
diff --git a/pkg/verification/utils.go b/pkg/verification/utils.go
index 78a1230..b4da823 100644
--- a/pkg/verification/utils.go
+++ b/pkg/verification/utils.go
@@ -52,9 +52,9 @@ func CalculateExpsureKeyHMACv1Alpha1(legacyKeys []v1alpha1.ExposureKey, secret [
 	return CalculateExposureKeyHMAC(keys, secret)
 }
 
-// CalculateExposureKeyHMAC will calculate the verification protocol HMAC value.
-// Input keys are already to be base64 encoded. They will be sorted if necessary.
-func CalculateExposureKeyHMAC(keys []verifyapi.ExposureKey, secret []byte) ([]byte, error) {
+// CalculateAllAllowedExposureKeyHMAC calculates the main HMAC and the optional HMAC. The optional HMAC
+// is only valid if the transmission risks are all zero.
+func CalculateAllAllowedExposureKeyHMAC(keys []verifyapi.ExposureKey, secret []byte) ([][]byte, error) {
 	if len(keys) == 0 {
 		return nil, fmt.Errorf("cannot calculate hmac on empty exposure keys")
 	}
@@ -65,9 +65,15 @@ func CalculateExposureKeyHMAC(keys []verifyapi.ExposureKey, secret []byte) ([]by
 
 	// Build the cleartext.
 	perKeyText := make([]string, 0, len(keys))
+	altPerKeyText := make([]string, 0, len(keys))
+	calculateAlt := true
 	for _, ek := range keys {
 		perKeyText = append(perKeyText,
 			fmt.Sprintf("%s.%d.%d.%d", ek.Key, ek.IntervalNumber, ek.IntervalCount, ek.TransmissionRisk))
+		altPerKeyText = append(altPerKeyText,
+			fmt.Sprintf("%s.%d.%d", ek.Key, ek.IntervalNumber, ek.IntervalCount))
+		// The alt HMAC is only valid of all transmission risk are "omitted" (set to zero).
+		calculateAlt = calculateAlt && ek.TransmissionRisk == 0
 	}
 
 	cleartext := strings.Join(perKeyText, ",")
@@ -75,6 +81,26 @@ func CalculateExposureKeyHMAC(keys []verifyapi.ExposureKey, secret []byte) ([]by
 	if _, err := mac.Write([]byte(cleartext)); err != nil {
 		return nil, fmt.Errorf("failed to write hmac: %w", err)
 	}
+	results := [][]byte{mac.Sum(nil)}
+
+	if calculateAlt {
+		altCleartext := strings.Join(altPerKeyText, ",")
+		mac := hmac.New(sha256.New, secret)
+		if _, err := mac.Write([]byte(altCleartext)); err != nil {
+			return nil, fmt.Errorf("failed to write hmac: %w", err)
+		}
+		results = append(results, mac.Sum(nil))
+	}
 
-	return mac.Sum(nil), nil
+	return results, nil
+}
+
+// CalculateExposureKeyHMAC will calculate the verification protocol HMAC value.
+// Input keys are already to be base64 encoded. They will be sorted if necessary.
+func CalculateExposureKeyHMAC(keys []verifyapi.ExposureKey, secret []byte) ([]byte, error) {
+	results, err := CalculateAllAllowedExposureKeyHMAC(keys, secret)
+	if err != nil {
+		return nil, err
+	}
+	return results[0], nil
 }
diff --git a/scripts/build b/scripts/build
index edb6351..ca5d3a5 100755
--- a/scripts/build
+++ b/scripts/build
@@ -17,7 +17,7 @@
 set -eEuo pipefail
 
 ROOT="$(cd "$(dirname "$0")/.." &>/dev/null; pwd -P)"
-ALL_SERVICES="cleanup-export,cleanup-exposure,debugger,export,exposure,federationin,federationout,generate"
+ALL_SERVICES="cleanup-export,cleanup-exposure,debugger,export,exposure,federationin,federationout,generate,key-rotation"
 
 if [ -n "$(git status --porcelain)" ]; then
   echo "✋ Uncommitted local changes!" >&2
diff --git a/scripts/deploy b/scripts/deploy
index 1ef7b41..643fc5c 100755
--- a/scripts/deploy
+++ b/scripts/deploy
@@ -17,7 +17,7 @@
 set -eEuo pipefail
 
 ROOT="$(cd "$(dirname "$0")/.." &>/dev/null; pwd -P)"
-ALL_SERVICES="cleanup-export,cleanup-exposure,export,exposure,federationin,federationout,generate"
+ALL_SERVICES="cleanup-export,cleanup-exposure,export,exposure,federationin,federationout,generate,key-rotation"
 
 if [ -z "${PROJECT_ID:-}" ]; then
   echo "✋ Missing PROJECT_ID!" >&2
diff --git a/scripts/promote b/scripts/promote
index 3765cef..76896bf 100755
--- a/scripts/promote
+++ b/scripts/promote
@@ -17,7 +17,7 @@
 set -eEuo pipefail
 
 ROOT="$(cd "$(dirname "$0")/.." &>/dev/null; pwd -P)"
-ALL_SERVICES="cleanup-export,cleanup-exposure,export,exposure,federationin,federationout,generate"
+ALL_SERVICES="cleanup-export,cleanup-exposure,export,exposure,federationin,federationout,generate,key-rotation"
 
 if [ -z "${PROJECT_ID:-}" ]; then
   echo "✋ Missing PROJECT_ID!" >&2
diff --git a/terraform/database.tf b/terraform/database.tf
index fc80235..2fd3350 100644
--- a/terraform/database.tf
+++ b/terraform/database.tf
@@ -42,6 +42,7 @@ resource "google_sql_database_instance" "db-inst" {
 
     backup_configuration {
       enabled    = true
+      location   = var.cloudsql_backup_location
       start_time = "02:00"
     }
 
diff --git a/terraform/service_keyrotation.tf b/terraform/service_keyrotation.tf
new file mode 100644
index 0000000..b89b331
--- /dev/null
+++ b/terraform/service_keyrotation.tf
@@ -0,0 +1,167 @@
+# Copyright 2020 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+#
+# Create and deploy the service
+#
+
+resource "google_service_account" "key-rotation" {
+  project      = data.google_project.project.project_id
+  account_id   = "en-key-rotation-sa"
+  display_name = "Exposure Notification Revision-Key Rotation"
+}
+
+resource "google_service_account_iam_member" "cloudbuild-deploy-key-rotation" {
+  service_account_id = google_service_account.key-rotation.id
+  role               = "roles/iam.serviceAccountUser"
+  member             = "serviceAccount:${data.google_project.project.number}@cloudbuild.gserviceaccount.com"
+
+  depends_on = [
+    google_project_service.services["cloudbuild.googleapis.com"],
+  ]
+}
+
+resource "google_secret_manager_secret_iam_member" "key-rotation-db" {
+  for_each = toset([
+    "sslcert",
+    "sslkey",
+    "sslrootcert",
+    "password",
+  ])
+
+  secret_id = google_secret_manager_secret.db-secret[each.key].id
+  role      = "roles/secretmanager.secretAccessor"
+  member    = "serviceAccount:${google_service_account.key-rotation.email}"
+}
+
+resource "google_project_iam_member" "key-rotation-observability" {
+  for_each = toset([
+    "roles/cloudtrace.agent",
+    "roles/logging.logWriter",
+    "roles/monitoring.metricWriter",
+    "roles/stackdriver.resourceMetadata.writer",
+  ])
+
+  project = var.project
+  role    = each.key
+  member  = "serviceAccount:${google_service_account.key-rotation.email}"
+}
+
+resource "google_cloud_run_service" "key-rotation" {
+  name     = "key-rotation"
+  location = var.cloudrun_location
+
+  autogenerate_revision_name = true
+
+  template {
+    spec {
+      service_account_name = google_service_account.key-rotation.email
+
+      containers {
+        image = "gcr.io/${data.google_project.project.project_id}/github.com/google/exposure-notifications-server/cmd/key-rotation:initial"
+
+        resources {
+          limits = {
+            cpu    = "2000m"
+            memory = "1G"
+          }
+        }
+
+        dynamic "env" {
+          for_each = merge(
+            local.common_cloudrun_env_vars,
+
+            // This MUST come last to allow overrides!
+            lookup(var.service_environment, "key_rotation", {}),
+          )
+
+          content {
+            name  = env.key
+            value = env.value
+          }
+        }
+      }
+    }
+
+    metadata {
+      annotations = {
+        "autoscaling.knative.dev/maxScale" : "1",
+        "run.googleapis.com/vpc-access-connector" : google_vpc_access_connector.connector.id
+      }
+    }
+  }
+
+  depends_on = [
+    google_project_service.services["run.googleapis.com"],
+    google_secret_manager_secret_iam_member.key-rotation-db,
+    null_resource.build,
+  ]
+
+  lifecycle {
+    ignore_changes = [
+      template[0].metadata[0].annotations,
+      template[0].spec[0].containers[0].image,
+    ]
+  }
+}
+
+
+#
+# Create scheduler job to invoke the service on a fixed interval.
+#
+
+resource "google_service_account" "key-rotation-invoker" {
+  project      = data.google_project.project.project_id
+  account_id   = "en-key-rotation-invoker-sa"
+  display_name = "Exposure Notification Key Rotation Invoker"
+}
+
+resource "google_cloud_run_service_iam_member" "key-rotation-invoker" {
+  project  = google_cloud_run_service.key-rotation.project
+  location = google_cloud_run_service.key-rotation.location
+  service  = google_cloud_run_service.key-rotation.name
+  role     = "roles/run.invoker"
+  member   = "serviceAccount:${google_service_account.key-rotation-invoker.email}"
+}
+
+# Schedule to run daily
+# Note that this endpoints default configuration only rotates keys weekly
+# but we over-schedule to ensure they succeed on time.
+
+resource "google_cloud_scheduler_job" "key-rotation-worker" {
+  name             = "key-rotation-worker"
+  region           = var.cloudscheduler_location
+  schedule         = "0 0 * * *"
+  time_zone        = "America/Los_Angeles"
+  attempt_deadline = "600s"
+
+  retry_config {
+    retry_count = 3
+  }
+
+  http_target {
+    http_method = "POST"
+    uri         = "${google_cloud_run_service.key-rotation.status.0.url}/rotate-keys"
+    oidc_token {
+      audience              = google_cloud_run_service.key-rotation.status.0.url
+      service_account_email = google_service_account.key-rotation-invoker.email
+    }
+  }
+
+  depends_on = [
+    google_app_engine_application.app,
+    google_cloud_run_service_iam_member.key-rotation-invoker,
+    google_project_service.services["cloudscheduler.googleapis.com"],
+  ]
+}
diff --git a/terraform/vars.tf b/terraform/vars.tf
index f7065f3..93e21bc 100644
--- a/terraform/vars.tf
+++ b/terraform/vars.tf
@@ -100,6 +100,13 @@ variable "cloudsql_max_connections" {
   description = "Maximum number of allowed connections. If you change to a smaller instance size, you must lower this number."
 }
 
+variable "cloudsql_backup_location" {
+  type    = string
+  default = "us"
+
+  description = "Location in which to backup the database."
+}
+
 variable "generate_cron_schedule" {
   type    = string
   default = "0 0 1 1 0"


Test patch:
diff --git a/internal/verification/phaverify_test.go b/internal/verification/phaverify_test.go
index 9c376f3..cc75f04 100644
--- a/internal/verification/phaverify_test.go
+++ b/internal/verification/phaverify_test.go
@@ -77,161 +77,180 @@ func TestVerifyCertificate(t *testing.T) {
 		},
 	}
 
-	for version := 0; version <= 1; version++ {
-		for _, tc := range cases {
-			tc := tc
+	for iteration := 0; iteration < 2; iteration++ {
+		for version := 0; version <= 1; version++ {
+			for _, tc := range cases {
+				tc := tc
 
-			vname := "v1alpha1"
-			if version == 1 {
-				vname = "v1"
-			}
+				vname := "v1alpha1"
+				if version == 1 {
+					vname = "v1"
+				}
+				mod := "withTR"
+				if iteration == 0 {
+					mod = "withoutTR"
+				}
 
-			t.Run(tc.Name+"_"+vname, func(t *testing.T) {
-				t.Parallel()
+				t.Run(tc.Name+"_"+vname+"_"+mod, func(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()
+					// 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)
-				}
+					// 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)
 
-				hak := model.HealthAuthorityKey{
-					Version:      "v1",
-					From:         time.Now(),
-					PublicKeyPEM: pemPublicKey,
-				}
-				if err := haDB.AddHealthAuthorityKey(ctx, &healthAuthority, &hak); err != nil {
-					t.Fatal(err)
-				}
+					// Set up database. Create HealthAuthority + HAKey for the test.
+					testDB := coredb.NewTestDatabase(t)
+					ctx := context.Background()
+					haDB := database.New(testDB)
 
-				// 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 := verifyapi.Publish{
-					Keys: []verifyapi.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,
-						},
-					},
-				}
+					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)
+					}
 
-				// Calculate the HMAC.
-				hmac, err := utils.CalculateExposureKeyHMAC(publish.Keys, hmacKeyBytes)
-				if 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)
+					}
 
-				var claims jwt.Claims
-				if version == 0 {
-					v1alpha1claims := v1alpha1.NewVerificationClaims()
-					v1alpha1claims.Audience = "exposure-notifications-server"
-					v1alpha1claims.Issuer = "doh.my.gov"
-					v1alpha1claims.IssuedAt = time.Now().Add(tc.Warp).Unix()
-					v1alpha1claims.ExpiresAt = time.Now().Add(tc.Warp).Add(5 * time.Minute).Unix()
-					v1alpha1claims.SignedMAC = tc.MacAdjustment + base64.StdEncoding.EncodeToString(hmac) // would be generated on the client and passed through.
-					v1alpha1claims.ReportType = "confirmed"
-					v1alpha1claims.SymptomOnsetInterval = 250250
-					// contains legacy transmission risk field, but will be an empty array, just there.
-					claims = v1alpha1claims
-				} else {
-					v1claims := verifyapi.NewVerificationClaims()
-					v1claims.Audience = "exposure-notifications-server"
-					v1claims.Issuer = "doh.my.gov"
-					v1claims.IssuedAt = time.Now().Add(tc.Warp).Unix()
-					v1claims.ExpiresAt = time.Now().Add(tc.Warp).Add(5 * time.Minute).Unix()
-					v1claims.SignedMAC = tc.MacAdjustment + base64.StdEncoding.EncodeToString(hmac)
-					v1claims.ReportType = "confirmed"
-					v1claims.SymptomOnsetInterval = 250250
-					claims = v1claims
-				}
+					// Build the verification certificate.
+					hmacKeyBytes := make([]byte, 32)
+					if _, err := rand.Read(hmacKeyBytes); err != nil {
+						t.Fatal(err)
+					}
+					hmacKeyB64 := base64.StdEncoding.EncodeToString(hmacKeyBytes)
 
-				token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
-				token.Header["kid"] = "v1" // matches the key configured above.
-				jwtText, err := token.SignedString(privateKey)
-				if err != nil {
-					t.Fatal(err)
-				}
+					// Fake authorized app.
+					authApp := aamodel.NewAuthorizedApp()
+					authApp.AllowedHealthAuthorityIDs[healthAuthority.ID] = struct{}{}
 
-				// Insert this data into the publish request.
-				publish.VerificationPayload = jwtText
-				publish.HMACKey = tc.MacKeyAdjustment + hmacKeyB64
+					// Build a sample certificate.
+					publish := verifyapi.Publish{
+						Keys: []verifyapi.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,
+							},
+						},
+					}
+					// Iteration 0 uses the "alt" hmac.
+					if iteration == 0 {
+						for i := range publish.Keys {
+							publish.Keys[i].TransmissionRisk = 0
+						}
+					}
 
-				// Actually test the verify code.
-				verifier, err := New(haDB, &Config{time.Nanosecond})
-				if err != nil {
-					t.Fatal(err)
-				}
-				verifiedClaims, 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())
+					// Calculate the HMAC.
+					allHMACs, err := utils.CalculateAllAllowedExposureKeyHMAC(publish.Keys, hmacKeyBytes)
+					if err != nil {
+						t.Fatal(err)
+					}
+					hmac := allHMACs[0]
+					if iteration == 0 {
+						if l := len(allHMACs); l != 2 {
+							t.Fatalf("expected 2 hmacs, got: %v", l)
+						}
+						hmac = allHMACs[1]
 					}
-				} else if tc.Error != "" {
-					t.Fatalf("wanted error '%v', but got nil", tc.Error)
-				}
 
-				if tc.Error == "" {
-					if verifiedClaims == nil {
-						t.Fatalf("verified claims are nil")
+					var claims jwt.Claims
+					if version == 0 {
+						v1alpha1claims := v1alpha1.NewVerificationClaims()
+						v1alpha1claims.Audience = "exposure-notifications-server"
+						v1alpha1claims.Issuer = "doh.my.gov"
+						v1alpha1claims.IssuedAt = time.Now().Add(tc.Warp).Unix()
+						v1alpha1claims.ExpiresAt = time.Now().Add(tc.Warp).Add(5 * time.Minute).Unix()
+						v1alpha1claims.SignedMAC = tc.MacAdjustment + base64.StdEncoding.EncodeToString(hmac) // would be generated on the client and passed through.
+						v1alpha1claims.ReportType = "confirmed"
+						v1alpha1claims.SymptomOnsetInterval = 250250
+						// contains legacy transmission risk field, but will be an empty array, just there.
+						claims = v1alpha1claims
+					} else {
+						v1claims := verifyapi.NewVerificationClaims()
+						v1claims.Audience = "exposure-notifications-server"
+						v1claims.Issuer = "doh.my.gov"
+						v1claims.IssuedAt = time.Now().Add(tc.Warp).Unix()
+						v1claims.ExpiresAt = time.Now().Add(tc.Warp).Add(5 * time.Minute).Unix()
+						v1claims.SignedMAC = tc.MacAdjustment + base64.StdEncoding.EncodeToString(hmac)
+						v1claims.ReportType = "confirmed"
+						v1claims.SymptomOnsetInterval = 250250
+						claims = v1claims
 					}
 
-					want := &VerifiedClaims{
-						HealthAuthorityID:    healthAuthority.ID,
-						ReportType:           "confirmed",
-						SymptomOnsetInterval: 250250,
+					token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+					token.Header["kid"] = "v1" // matches the key configured above.
+					jwtText, err := token.SignedString(privateKey)
+					if err != nil {
+						t.Fatal(err)
 					}
-					if diff := cmp.Diff(want, verifiedClaims); diff != "" {
-						t.Errorf("claims mismatch (-want, +got):\n%s", diff)
+
+					// Insert this data into the publish request.
+					publish.VerificationPayload = jwtText
+					publish.HMACKey = tc.MacKeyAdjustment + hmacKeyB64
+
+					// Actually test the verify code.
+					verifier, err := New(haDB, &Config{time.Nanosecond})
+					if err != nil {
+						t.Fatal(err)
 					}
-				}
-			})
+					verifiedClaims, 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 tc.Error == "" {
+						if verifiedClaims == nil {
+							t.Fatalf("verified claims are nil")
+						}
+
+						want := &VerifiedClaims{
+							HealthAuthorityID:    healthAuthority.ID,
+							ReportType:           "confirmed",
+							SymptomOnsetInterval: 250250,
+						}
+						if diff := cmp.Diff(want, verifiedClaims); diff != "" {
+							t.Errorf("claims mismatch (-want, +got):\n%s", diff)
+						}
+					}
+				})
+			}
 		}
 	}
 }
diff --git a/pkg/verification/utils_test.go b/pkg/verification/utils_test.go
index b60adf6..a1a94af 100644
--- a/pkg/verification/utils_test.go
+++ b/pkg/verification/utils_test.go
@@ -19,9 +19,10 @@ import (
 	"testing"
 
 	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1"
+	"github.com/google/go-cmp/cmp"
 )
 
-func TestCalculateHac(t *testing.T) {
+func TestCalculateHMAC(t *testing.T) {
 	secret := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
 
 	eKeys := []verifyapi.ExposureKey{
@@ -52,3 +53,43 @@ func TestCalculateHac(t *testing.T) {
 		t.Fatalf("incorrect mac, want: %v, got %v", want, mac)
 	}
 }
+
+func TestCalculateDualHMACs(t *testing.T) {
+	secret := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
+
+	eKeys := []verifyapi.ExposureKey{
+		{
+			Key:              "z2Cx9hdz2SlxZ8GEgqTYpA==",
+			IntervalNumber:   1,
+			IntervalCount:    144,
+			TransmissionRisk: 0,
+		},
+		{
+			Key:              "dPCphLzfG4uzXneNimkPRQ==",
+			IntervalNumber:   144,
+			IntervalCount:    144,
+			TransmissionRisk: 0,
+		},
+	}
+
+	macs, err := CalculateAllAllowedExposureKeyHMAC(eKeys, secret)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if l := len(macs); l != 2 {
+		t.Fatalf("2 hmac values expected, got: %v", l)
+	}
+
+	got := make([]string, len(macs))
+	for i, mac := range macs {
+		got[i] = base64.StdEncoding.EncodeToString(mac)
+	}
+	want := []string{
+		"mFNI9zhTA17n4ndd1Vu5tsn4XQX0gPrIKyjeh/9noUE=",
+		"J9S7xVebH/66dm14wGsIcAnSUR3PHD4NtDQEOGZbDUo=",
+	}
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Errorf("claims mismatch (-want, +got):\n%s", diff)
+	}
+}