Repository: google/exposure-notifications-server
Pull request #478 (2020-05-28 22:00:58)

add shared API definitions for PHA verification protocol
shared type definitions for #349 

Reason these are in /pkg

The idea is that these type definitions will be shared with the TEK verification server

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

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

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

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

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

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->

Patch:
diff --git a/go.mod b/go.mod
index 9421345..828e4b6 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.14
 require (
 	cloud.google.com/go v0.57.0
 	cloud.google.com/go/storage v1.6.0
+	contrib.go.opencensus.io/exporter/ocagent v0.7.0
 	contrib.go.opencensus.io/exporter/stackdriver v0.13.1
 	github.com/Azure/azure-sdk-for-go v42.3.0+incompatible
 	github.com/Azure/go-autorest/autorest v0.10.2 // indirect
@@ -63,12 +64,10 @@ require (
 	github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c // indirect
 	go.opencensus.io v0.22.3
 	go.uber.org/zap v1.14.1
-	golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2 // indirect
-	golang.org/x/sys v0.0.0-20200519105757-fe76b779f299 // indirect
 	golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 // indirect
 	golang.org/x/tools v0.0.0-20200502202811-ed308ab3e770 // indirect
 	google.golang.org/api v0.25.0
-	google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013
+	google.golang.org/genproto v0.0.0-20200527145253-8367513e4ece
 	google.golang.org/grpc v1.29.1
 	google.golang.org/protobuf v1.24.0
 	gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
diff --git a/go.sum b/go.sum
index 02eaa90..485d188 100644
--- a/go.sum
+++ b/go.sum
@@ -40,6 +40,8 @@ cloud.google.com/go/storage v1.6.0 h1:UDpwYIwla4jHGzZJaEJYx1tOejbgSoNqsAfHAUYe2r
 cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
 code.cloudfoundry.org/gofileutils v0.0.0-20170111115228-4d0c80011a0f h1:UrKzEwTgeiff9vxdrfdqxibzpWjxLnuXDI5m6z3GJAk=
 code.cloudfoundry.org/gofileutils v0.0.0-20170111115228-4d0c80011a0f/go.mod h1:sk5LnIjB/nIEU7yP5sDQExVm62wu0pBh3yrElngUisI=
+contrib.go.opencensus.io/exporter/ocagent v0.7.0 h1:BEfdCTXfMV30tLZD8c9n64V/tIZX5+9sXiuFLnrr1k8=
+contrib.go.opencensus.io/exporter/ocagent v0.7.0/go.mod h1:IshRmMJBhDfFj5Y67nVhMYTTIze91RUeT73ipWKs/GY=
 contrib.go.opencensus.io/exporter/stackdriver v0.13.1 h1:RX9W6FelAqTVnBi/bRXJLXr9n18v4QkQwZYIdnNS51I=
 contrib.go.opencensus.io/exporter/stackdriver v0.13.1/go.mod h1:z2tyTZtPmQ2HvWH4cOmVDgtY+1lomfKdbLnkJvZdc8c=
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
@@ -135,6 +137,7 @@ github.com/aliyun/alibaba-cloud-sdk-go v0.0.0-20190620160927-9418d7b0cd0f/go.mod
 github.com/aliyun/alibaba-cloud-sdk-go v1.61.213 h1:O97p1t39u8Rr3QrZx4ELSk03KXcD+fqhmH4drYJvAF8=
 github.com/aliyun/alibaba-cloud-sdk-go v1.61.213/go.mod h1:pUKYbK5JQ+1Dfxk80P0qxGqe5dkxDoabbZS7zOcouyA=
 github.com/aliyun/aliyun-oss-go-sdk v0.0.0-20190307165228-86c17b95fcd5/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
+github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
 github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
 github.com/apple/foundationdb/bindings/go v0.0.0-20190411004307-cd5c9d91fad2/go.mod h1:OMVSB21p9+xQUIqlGizHPZfjK+SHws1ht+ZytVDoz9U=
 github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
@@ -462,6 +465,8 @@ github.com/grpc-ecosystem/grpc-gateway v1.6.2/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpg
 github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
 github.com/grpc-ecosystem/grpc-gateway v1.9.5 h1:UImYN5qQ8tuGpGE16ZmjvcTtTw24zw1QAp/SlnNrZhI=
 github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
+github.com/grpc-ecosystem/grpc-gateway v1.14.6 h1:8ERzHx8aj1Sc47mu9n/AksaKCSWrMchFtkdrS4BIj5o=
+github.com/grpc-ecosystem/grpc-gateway v1.14.6/go.mod h1:zdiPV4Yse/1gnckTHtghG4GkDEdKCRJduHpTxT3/jcw=
 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
 github.com/hashicorp/consul-template v0.25.0/go.mod h1:/vUsrJvDuuQHcxEw0zik+YXTS7ZKWZjQeaQhshBmfH0=
@@ -959,6 +964,7 @@ github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40T
 github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
 github.com/remyoudompheng/bigfft v0.0.0-20190728182440-6a916e37a237/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
+github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
 github.com/rs/zerolog v1.4.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
@@ -1177,6 +1183,7 @@ golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLL
 golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20191002035440-2ec189313ef0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -1260,8 +1267,8 @@ golang.org/x/sys v0.0.0-20200413165638-669c56c373c4 h1:opSr2sbRXk5X5/givKrrKj9HX
 golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200519105757-fe76b779f299 h1:DYfZAGf2WMFjMxbgTjaC+2HC7NkNAQs+6Q8b9WEB/F4=
-golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200523222454-059865788121 h1:rITEj+UZHYC927n8GT97eC3zrpzXdb/voyeOuVKS46o=
+golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1405,6 +1412,8 @@ google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884 h1:fiNLklpBwWK1mth
 google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 h1:+kGHl1aib/qcwaRi1CbqBZ1rk19r85MNUf8HaBghugY=
 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
+google.golang.org/genproto v0.0.0-20200527145253-8367513e4ece h1:1YM0uhfumvoDu9sx8+RyWwTI63zoCQvI23IYFRlvte0=
+google.golang.org/genproto v0.0.0-20200527145253-8367513e4ece/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
 google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio=
@@ -1479,6 +1488,7 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD
 gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
diff --git a/internal/admin/authorizedapps/form.go b/internal/admin/authorizedapps/form.go
index d6108a2..6251adb 100644
--- a/internal/admin/authorizedapps/form.go
+++ b/internal/admin/authorizedapps/form.go
@@ -73,11 +73,11 @@ func (f *formData) PopulateAuthorizedApp(a *model.AuthorizedApp) error {
 	var err error
 	a.SafetyNetPastTime, err = time.ParseDuration(f.SafetyNetPastTime)
 	if err != nil {
-		return fmt.Errorf("Failed to parse durection for SafetyNetPastTime: %w", err)
+		return fmt.Errorf("failed to parse durection for SafetyNetPastTime: %w", err)
 	}
 	a.SafetyNetFutureTime, err = time.ParseDuration(f.SafetyNetFutureTime)
 	if err != nil {
-		return fmt.Errorf("Failed to parse durection for SafetyNetFutureTime: %w", err)
+		return fmt.Errorf("failed to parse durection for SafetyNetFutureTime: %w", err)
 	}
 	// DeviceCheck
 	a.DeviceCheckDisabled = f.DeviceCheckDisabled
diff --git a/internal/admin/siginfo/save.go b/internal/admin/siginfo/save.go
index fcde5c7..0c2c654 100644
--- a/internal/admin/siginfo/save.go
+++ b/internal/admin/siginfo/save.go
@@ -56,6 +56,10 @@ func (h *saveController) Execute(c *gin.Context) {
 	}
 	if sigID != 0 {
 		sigInfo, err = exportDB.GetSignatureInfo(ctx, int64(sigID))
+		if err != nil {
+			admin.ErrorPage(c, fmt.Sprintf("error processing signature info: %v", err))
+			return
+		}
 	}
 	if err := form.PopulateSigInfo(sigInfo); err != nil {
 		admin.ErrorPage(c, fmt.Sprintf("error processing signature info: %v", err))
diff --git a/internal/authorizedapp/model/authorized_app_model.go b/internal/authorizedapp/model/authorized_app_model.go
index 238677c..fde9c3d 100644
--- a/internal/authorizedapp/model/authorized_app_model.go
+++ b/internal/authorizedapp/model/authorized_app_model.go
@@ -21,9 +21,9 @@ import (
 )
 
 const (
-	bothPlatforms = "both"
-	iosDevice     = "ios"
-	androidDevice = "android"
+	BothPlatforms = "both"
+	IosDevice     = "ios"
+	AndroidDevice = "android"
 )
 
 // AuthorizedApp represents the configuration for a single exposure notification
@@ -102,18 +102,18 @@ func (c *AuthorizedApp) Validate() []string {
 
 // IsIOS returns true if the platform is equal to `iosDevice`
 func (c *AuthorizedApp) IsIOS() bool {
-	return c.Platform == iosDevice || c.Platform == bothPlatforms
+	return c.Platform == IosDevice
 }
 
 // IsAndroid returns true if the platform is equal to `android`
 func (c *AuthorizedApp) IsAndroid() bool {
-	return c.Platform == androidDevice || c.Platform == bothPlatforms
+	return c.Platform == AndroidDevice
 }
 
 // IsDualPlatform returns true if the platform is equal to 'both'
 // i.e. AppPackageName and BundleID are the same.
 func (c *AuthorizedApp) IsDualPlatform() bool {
-	return c.Platform == bothPlatforms
+	return c.Platform == BothPlatforms
 }
 
 func (c *AuthorizedApp) RegionsOnePerLine() string {
diff --git a/internal/export/database/export.go b/internal/export/database/export.go
index d068e7f..194a261 100644
--- a/internal/export/database/export.go
+++ b/internal/export/database/export.go
@@ -28,10 +28,6 @@ import (
 	pgx "github.com/jackc/pgx/v4"
 )
 
-const (
-	bucketEnvVar = "EXPORT_BUCKET"
-)
-
 type ExportDB struct {
 	db *database.DB
 }
diff --git a/internal/export/exportfile.go b/internal/export/exportfile.go
index 5267bee..d4ab48c 100644
--- a/internal/export/exportfile.go
+++ b/internal/export/exportfile.go
@@ -93,7 +93,7 @@ func MarshalExportFile(eb *model.ExportBatch, exposures []*publishmodel.Exposure
 func UnmarshalExportFile(zippedProtoPayload []byte) (*export.TemporaryExposureKeyExport, error) {
 	zp, err := zip.NewReader(bytes.NewReader(zippedProtoPayload), int64(len(zippedProtoPayload)))
 	if err != nil {
-		return nil, fmt.Errorf("Can't read payload: %v", err)
+		return nil, fmt.Errorf("can't read payload: %v", err)
 	}
 
 	for _, file := range zp.File {
@@ -102,7 +102,7 @@ func UnmarshalExportFile(zippedProtoPayload []byte) (*export.TemporaryExposureKe
 		}
 	}
 
-	return nil, fmt.Errorf("Payload is invalid: no %v file was found", exportBinaryName)
+	return nil, fmt.Errorf("payload is invalid: no %v file was found", exportBinaryName)
 }
 
 func unmarshalContent(file *zip.File) (*export.TemporaryExposureKeyExport, error) {
diff --git a/internal/export/model/export_model.go b/internal/export/model/export_model.go
index 52d8058..68387e6 100644
--- a/internal/export/model/export_model.go
+++ b/internal/export/model/export_model.go
@@ -173,7 +173,7 @@ func toHTMLTime(t time.Time) string {
 }
 
 func effectiveInputRegions(outRegion string, inRegions []string) []string {
-	if inRegions != nil && len(inRegions) > 0 {
+	if len(inRegions) > 0 {
 		return inRegions
 	}
 	return []string{outRegion}
diff --git a/internal/observability/observability.go b/internal/observability/observability.go
index b4e67f1..e04e8d7 100644
--- a/internal/observability/observability.go
+++ b/internal/observability/observability.go
@@ -18,6 +18,7 @@ import (
 	"os"
 	"sync"
 
+	"contrib.go.opencensus.io/exporter/ocagent"
 	"contrib.go.opencensus.io/exporter/stackdriver"
 	"go.opencensus.io/plugin/ocgrpc"
 	"go.opencensus.io/plugin/ochttp"
@@ -68,6 +69,13 @@ func exporter() traceAndViewExporter {
 		// TODO: Add other trace and view exporters and break out of this.
 		return nil
 
+	case "ocagent":
+		oce, err := ocagent.NewExporter(ocagent.WithInsecure(), ocagent.WithAddress("localhost:55678"))
+		if err != nil {
+			panic(err)
+		}
+		return oce
+
 	case "stackdriver":
 		sde, err := stackdriver.NewExporter(stackdriver.Options{
 			ProjectID: os.Getenv("PROJECT_ID"),
diff --git a/internal/publish/model/exposure_model.go b/internal/publish/model/exposure_model.go
index ff8467a..a0cb785 100644
--- a/internal/publish/model/exposure_model.go
+++ b/internal/publish/model/exposure_model.go
@@ -22,6 +22,7 @@ import (
 	"strings"
 	"time"
 
+	authapp "github.com/google/exposure-notifications-server/internal/authorizedapp/model"
 	"github.com/google/exposure-notifications-server/internal/base64util"
 )
 
@@ -54,6 +55,7 @@ const (
 // AppPackageName: The identifier for the mobile application.
 //  - Android: The App Package AppPackageName
 //  - iOS: The BundleID
+// Platform: "ios" or "android"
 // TransmissionRisk: An integer from 0-8 (inclusive) that represents
 //  the transmission risk for this publish.
 // Verification: The attestation payload for this request. (iOS or Android specific)
@@ -71,6 +73,14 @@ type Publish struct {
 	Padding                   string        `json:"padding"`
 }
 
+func (p *Publish) IsIOS() bool {
+	return strings.ToLower(p.Platform) == authapp.IosDevice
+}
+
+func (p *Publish) IsAndroid() bool {
+	return strings.ToLower(p.Platform) == authapp.AndroidDevice
+}
+
 // 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 {
diff --git a/internal/publish/publish.go b/internal/publish/publish.go
index 9d3fca0..be0c4cc 100644
--- a/internal/publish/publish.go
+++ b/internal/publish/publish.go
@@ -126,7 +126,11 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 		return response{status: http.StatusUnauthorized, message: message, metric: "publish-region-not-authorized", count: 1}
 	}
 
-	if appConfig.IsIOS() {
+	// If an AuthorizedApp is configured for a single platform only, we can ignore
+	// the incoming platform field on the Publish request.
+	// If the AuthorizedApp is dual platform, then let the in coming Publish.Platform
+	// decide which attestation validation is used.
+	if appConfig.IsIOS() || (appConfig.IsDualPlatform() && data.IsIOS()) {
 		span.AddAttributes(trace.StringAttribute("app_platform", "ios"))
 		if appConfig.DeviceCheckDisabled {
 			logger.Errorf("skipping DeviceCheck for %v (disabled)", data.AppPackageName)
@@ -137,7 +141,7 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 			span.SetStatus(trace.Status{Code: trace.StatusCodePermissionDenied, Message: message})
 			return response{status: http.StatusUnauthorized, message: message, metric: "publish-devicecheck-invalid", count: 1}
 		}
-	} else if appConfig.IsAndroid() {
+	} else if appConfig.IsAndroid() || (appConfig.IsDualPlatform() && data.IsAndroid()) {
 		span.AddAttributes(trace.StringAttribute("app_platform", "android"))
 		if appConfig.SafetyNetDisabled {
 			message := fmt.Sprintf("skipping SafetyNet for %v (disabled)", data.AppPackageName)
@@ -153,8 +157,8 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 	} else {
 		message := fmt.Sprintf("invalid AuthorizedApp config %v: invalid platform %v", data.AppPackageName, data.Platform)
 		logger.Error(message)
-		span.SetStatus(trace.Status{Code: trace.StatusCodeInternal, Message: message})
-		return response{status: http.StatusInternalServerError, message: message, metric: "publish-authorizedapp-missing-platform", count: 1}
+		span.SetStatus(trace.Status{Code: trace.StatusCodeInvalidArgument, Message: message})
+		return response{status: http.StatusBadRequest, message: message, metric: "publish-authorizedapp-missing-platform", count: 1}
 	}
 
 	batchTime := time.Now()
diff --git a/pkg/api/v1alpha1/verification_types.go b/pkg/api/v1alpha1/verification_types.go
new file mode 100644
index 0000000..55a01f2
--- /dev/null
+++ b/pkg/api/v1alpha1/verification_types.go
@@ -0,0 +1,55 @@
+// 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 v1alpha1 contains API definitions that can be used outside of this
+// codebase in their alpha form.
+// These APIs are not considered to be stable at HEAD.
+package v1alpha1
+
+import "sort"
+
+const (
+	exposureKeyHMACClaim          = "tekmac"
+	healthAuthorityDataClaim      = "phadata"
+	transmissionRiskOverrideClaim = "tris"
+	keyVersionClaim               = "keyVersion"
+)
+
+// TransmissionRiskVector is an additional set of claims that can be
+// included in the verification certificatin for a diagnosis as recieved
+// from a trusted public health authority.
+type TransmissionRiskVector []TransmissionRiskOverride
+
+// Compile time check that TranismissionRiskVector implements the sort interface.
+var _ sort.Interface = TransmissionRiskVector{}
+
+// TransmissionRiskOverride is an indvidual transmission risk override.
+type TransmissionRiskOverride struct {
+	TranismissionRisk  int `json:"tr"`
+	SinceRollingPeriod int `json:"sinceRollingPeriod"`
+}
+
+func (a TransmissionRiskVector) Len() int {
+	return len(a)
+}
+
+// This sorts the TransmissionRiskVector vector with the largest SinceRollingPeriod
+// value first. Descending sort.
+func (a TransmissionRiskVector) Less(i, j int) bool {
+	return a[i].SinceRollingPeriod > a[j].SinceRollingPeriod
+}
+
+func (a TransmissionRiskVector) Swap(i, j int) {
+	a[i], a[j] = a[j], a[i]
+}
diff --git a/terraform/service_cleanup_export.tf b/terraform/service_cleanup_export.tf
index 6002848..9027cdf 100644
--- a/terraform/service_cleanup_export.tf
+++ b/terraform/service_cleanup_export.tf
@@ -78,6 +78,14 @@ resource "google_cloud_run_service" "cleanup-export" {
             value = env.value["value"]
           }
         }
+
+        dynamic "env" {
+          for_each = lookup(var.service_environment, "cleanup_export", {})
+          content {
+            name  = env.key
+            value = env.value
+          }
+        }
       }
     }
 
diff --git a/terraform/service_cleanup_exposure.tf b/terraform/service_cleanup_exposure.tf
index 517e4d1..f99bc10 100644
--- a/terraform/service_cleanup_exposure.tf
+++ b/terraform/service_cleanup_exposure.tf
@@ -72,6 +72,14 @@ resource "google_cloud_run_service" "cleanup-exposure" {
             value = env.value["value"]
           }
         }
+
+        dynamic "env" {
+          for_each = lookup(var.service_environment, "cleanup_exposure", {})
+          content {
+            name  = env.key
+            value = env.value
+          }
+        }
       }
     }
 
diff --git a/terraform/service_export.tf b/terraform/service_export.tf
index 4b70dcf..a1a7107 100644
--- a/terraform/service_export.tf
+++ b/terraform/service_export.tf
@@ -85,14 +85,12 @@ resource "google_cloud_run_service" "export" {
           }
         }
 
-        env {
-          name  = "EXPORT_FILE_MAX_RECORDS"
-          value = "100"
-        }
-
-        env {
-          name  = "EXPORT_BUCKET"
-          value = google_storage_bucket.export.name
+        dynamic "env" {
+          for_each = lookup(var.service_environment, "export", {})
+          content {
+            name  = env.key
+            value = env.value
+          }
         }
       }
 
diff --git a/terraform/service_exposure.tf b/terraform/service_exposure.tf
index d7dec02..7c28722 100644
--- a/terraform/service_exposure.tf
+++ b/terraform/service_exposure.tf
@@ -72,6 +72,14 @@ resource "google_cloud_run_service" "exposure" {
             value = env.value["value"]
           }
         }
+
+        dynamic "env" {
+          for_each = lookup(var.service_environment, "exposure", {})
+          content {
+            name  = env.key
+            value = env.value
+          }
+        }
       }
     }
 
diff --git a/terraform/service_federationin.tf b/terraform/service_federationin.tf
index a35c998..7bda2b6 100644
--- a/terraform/service_federationin.tf
+++ b/terraform/service_federationin.tf
@@ -72,6 +72,14 @@ resource "google_cloud_run_service" "federationin" {
             value = env.value["value"]
           }
         }
+
+        dynamic "env" {
+          for_each = lookup(var.service_environment, "federationin", {})
+          content {
+            name  = env.key
+            value = env.value
+          }
+        }
       }
     }
 
diff --git a/terraform/service_federationout.tf b/terraform/service_federationout.tf
index 53fce8d..5fcd400 100644
--- a/terraform/service_federationout.tf
+++ b/terraform/service_federationout.tf
@@ -72,6 +72,14 @@ resource "google_cloud_run_service" "federationout" {
             value = env.value["value"]
           }
         }
+
+        dynamic "env" {
+          for_each = lookup(var.service_environment, "federationout", {})
+          content {
+            name  = env.key
+            value = env.value
+          }
+        }
       }
     }
 
diff --git a/terraform/service_generate.tf b/terraform/service_generate.tf
index ac6c52c..a8d7a40 100644
--- a/terraform/service_generate.tf
+++ b/terraform/service_generate.tf
@@ -72,6 +72,14 @@ resource "google_cloud_run_service" "generate" {
             value = env.value["value"]
           }
         }
+
+        dynamic "env" {
+          for_each = lookup(var.service_environment, "generate", {})
+          content {
+            name  = env.key
+            value = env.value
+          }
+        }
       }
     }
 
diff --git a/terraform/vars.tf b/terraform/vars.tf
index 3518023..8f9d1fe 100644
--- a/terraform/vars.tf
+++ b/terraform/vars.tf
@@ -64,6 +64,13 @@ variable "registry_cleanup_cron_schedule" {
   description = "Schedule to execute cleanup of old Container Registry images."
 }
 
+variable "service_environment" {
+  type    = map(map(string))
+  default = {}
+
+  description = "Per-service environment overrides."
+}
+
 terraform {
   required_providers {
     google      = "~> 3.20"
diff --git a/tools/export-analyzer/main.go b/tools/export-analyzer/main.go
index 7713664..10afae1 100644
--- a/tools/export-analyzer/main.go
+++ b/tools/export-analyzer/main.go
@@ -46,5 +46,8 @@ func main() {
 	}
 
 	prettyJSON, err := json.MarshalIndent(keyExport, "", "  ")
+	if err != nil {
+		log.Fatalf("error pretty printing export: %v", err)
+	}
 	log.Printf("%v", string(prettyJSON))
 }


Test patch:
diff --git a/internal/export/database/export_test.go b/internal/export/database/export_test.go
index 6c09a39..3f13f15 100644
--- a/internal/export/database/export_test.go
+++ b/internal/export/database/export_test.go
@@ -162,6 +162,9 @@ func TestAddGetUpdateExportConfig(t *testing.T) {
 	}
 
 	got, err = exportDB.GetExportConfig(ctx, want.ConfigID)
+	if err != nil {
+		t.Fatal(err)
+	}
 	if diff := cmp.Diff(want, got); diff != "" {
 		t.Errorf("mismatch (-want, +got):\n%s", diff)
 	}
diff --git a/internal/export/exportfile_test.go b/internal/export/exportfile_test.go
index c5ca044..34fc761 100644
--- a/internal/export/exportfile_test.go
+++ b/internal/export/exportfile_test.go
@@ -20,12 +20,12 @@ import (
 	"testing"
 	"time"
 
-	"github.com/golang/protobuf/proto"
 	"github.com/google/exposure-notifications-server/internal/export/model"
 	"github.com/google/exposure-notifications-server/internal/pb/export"
 	publishmodel "github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-cmp/cmp/cmpopts"
+	"google.golang.org/protobuf/proto"
 )
 
 func TestMarshalUnmarshalExportFile(t *testing.T) {
diff --git a/internal/export/model/export_model_test.go b/internal/export/model/export_model_test.go
index cb8a72e..bd04689 100644
--- a/internal/export/model/export_model_test.go
+++ b/internal/export/model/export_model_test.go
@@ -21,7 +21,7 @@ import (
 	"github.com/google/go-cmp/cmp"
 )
 
-func TextExportRegions(t *testing.T) {
+func TestExportRegions(t *testing.T) {
 	cases := []struct {
 		name         string
 		region       string
diff --git a/internal/federationin/database/federationin_test.go b/internal/federationin/database/federationin_test.go
index b574534..af58470 100644
--- a/internal/federationin/database/federationin_test.go
+++ b/internal/federationin/database/federationin_test.go
@@ -21,7 +21,6 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/database"
-	coredb "github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/federationin/model"
 
 	"github.com/google/go-cmp/cmp"
@@ -44,7 +43,7 @@ func TestFederationIn(t *testing.T) {
 		LastTimestamp:  ts,
 	}
 	// GetFederationQuery should fail if not found.
-	if _, err := New(testDB).GetFederationInQuery(ctx, want.QueryID); !errors.Is(err, coredb.ErrNotFound) {
+	if _, err := New(testDB).GetFederationInQuery(ctx, want.QueryID); !errors.Is(err, database.ErrNotFound) {
 		t.Errorf("got %v, want ErrNotFound", err)
 	}
 
@@ -74,7 +73,7 @@ func TestFederationIn(t *testing.T) {
 	}
 
 	// GetFederationSync should fail if not found.
-	if _, err := New(testDB).GetFederationInSync(ctx, 1); !errors.Is(err, coredb.ErrNotFound) {
+	if _, err := New(testDB).GetFederationInSync(ctx, 1); !errors.Is(err, database.ErrNotFound) {
 		t.Errorf("got %v, want ErrNotFound", err)
 	}
 
diff --git a/internal/federationout/database/federationout_test.go b/internal/federationout/database/federationout_test.go
index 9024d69..6c16817 100644
--- a/internal/federationout/database/federationout_test.go
+++ b/internal/federationout/database/federationout_test.go
@@ -20,7 +20,6 @@ import (
 	"testing"
 
 	"github.com/google/exposure-notifications-server/internal/database"
-	coredb "github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/federationin/model"
 
 	"github.com/google/go-cmp/cmp"
@@ -43,7 +42,7 @@ func TestFederationOutAuthorization(t *testing.T) {
 	}
 
 	// GetFederationOutAuthorization should fail if not found.
-	if _, err := New(testDB).GetFederationOutAuthorization(ctx, want.Issuer, want.Subject); !errors.Is(err, coredb.ErrNotFound) {
+	if _, err := New(testDB).GetFederationOutAuthorization(ctx, want.Issuer, want.Subject); !errors.Is(err, database.ErrNotFound) {
 		t.Errorf("got %v, want ErrNotFound", err)
 	}
 	// Add a query, then get it.
diff --git a/pkg/api/v1alpha1/verification_types_test.go b/pkg/api/v1alpha1/verification_types_test.go
new file mode 100644
index 0000000..97caf0e
--- /dev/null
+++ b/pkg/api/v1alpha1/verification_types_test.go
@@ -0,0 +1,38 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package v1alpha1
+
+import (
+	"sort"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestTransmissionRiskVectorSort(t *testing.T) {
+
+	got := TransmissionRiskVector{
+		{0, 0},
+		{3, 100},
+		{5, 200},
+	}
+	sort.Sort(got)
+
+	want := TransmissionRiskVector{{5, 200}, {3, 100}, {0, 0}}
+
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Errorf("sort(TransmissionRiskVector) mismatch (-want +got):\n%v", diff)
+	}
+}