Repository: google/exposure-notifications-server
Pull request #415 (2020-05-22 06:58:09)

Adding method for extracting keys from the export file.
Adding method for extracting keys from the export file.

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

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/415#" title="Author self-approved">mgulimonov</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **mikehelmick**
You can assign the PR to them by writing `/assign @mikehelmick` 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/master/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":["mikehelmick"]} -->
mgulimonov: /assign @mikehelmick
google-prow-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/415#pullrequestreview-417161782" title="Approved">gurayAlsac</a>*, *<a href="https://github.com/google/exposure-notifications-server/pull/415#" title="Author self-approved">mgulimonov</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **mikehelmick**
You can assign the PR to them by writing `/assign @mikehelmick` 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/master/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":["mikehelmick"]} -->
google-prow-robot: New changes are detected. LGTM label has been removed.
google-prow-robot: @mgulimonov: The following test **failed**, say `/retest` to rerun all failed tests:

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

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

<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 -->
odeke-em: LGTM, after addressing my suggestions, thank you @mgulimonov! 
gurayAlsac: /lgtm
/approve
google-prow-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/415#issuecomment-634339107" title="Approved">gurayAlsac</a>*, *<a href="https://github.com/google/exposure-notifications-server/pull/415#" title="Author self-approved">mgulimonov</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **mikehelmick**
You can assign the PR to them by writing `/assign @mikehelmick` 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/master/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":["mikehelmick"]} -->
google-prow-robot: New changes are detected. LGTM label has been removed.
google-prow-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/415#issuecomment-634339107" title="Approved">gurayAlsac</a>*, *<a href="https://github.com/google/exposure-notifications-server/pull/415#" title="Author self-approved">mgulimonov</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/master/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"]} -->
mgulimonov: /assign @mikehelmick

Patch:
diff --git a/internal/admin/exports/form.go b/internal/admin/exports/form.go
new file mode 100644
index 0000000..a32b639
--- /dev/null
+++ b/internal/admin/exports/form.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, softwar
+// 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 exports
+
+import (
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/admin"
+	"github.com/google/exposure-notifications-server/internal/export/model"
+)
+
+type formData struct {
+	Region       string        `form:"Region"`
+	BucketName   string        `form:"BucketName"`
+	FilenameRoot string        `form:"FilenameRoot"`
+	Period       time.Duration `form:"Period"`
+	FromDate     string        `form:"fromdate"`
+	FromTime     string        `form:"fromtime"`
+	ThruDate     string        `form:"thrudate"`
+	ThruTime     string        `form:"thrutime"`
+	SigInfoIDs   []int64       `form:"siginfo"`
+}
+
+func (f *formData) PopulateExportConfig(ec *model.ExportConfig) error {
+	from, err := admin.CombineDateAndTime(f.FromDate, f.FromTime)
+	if err != nil {
+		return err
+	}
+	thru, err := admin.CombineDateAndTime(f.ThruDate, f.ThruTime)
+	if err != nil {
+		return err
+	}
+
+	ec.BucketName = f.BucketName
+	ec.FilenameRoot = f.FilenameRoot
+	ec.Period = f.Period
+	ec.Region = f.Region
+	ec.From = from
+	ec.Thru = thru
+	ec.SignatureInfoIDs = f.SigInfoIDs
+
+	return nil
+}
diff --git a/internal/admin/exports/save.go b/internal/admin/exports/save.go
new file mode 100644
index 0000000..7349a2e
--- /dev/null
+++ b/internal/admin/exports/save.go
@@ -0,0 +1,94 @@
+// 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, softwar
+// 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 exports
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/gin-gonic/gin"
+	"github.com/google/exposure-notifications-server/internal/admin"
+	"github.com/google/exposure-notifications-server/internal/export/database"
+	"github.com/google/exposure-notifications-server/internal/export/model"
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+)
+
+type saveController struct {
+	config *admin.Config
+	env    *serverenv.ServerEnv
+}
+
+func NewSave(c *admin.Config, env *serverenv.ServerEnv) *saveController {
+	return &saveController{config: c, env: env}
+}
+
+func (v *saveController) Execute(c *gin.Context) {
+	var form formData
+	err := c.Bind(&form)
+	if err != nil {
+		admin.ErrorPage(c, err.Error())
+		return
+	}
+
+	ctx := c.Request.Context()
+	m := admin.TemplateMap{}
+
+	exportDB := database.New(v.env.Database())
+	exportConfig := &model.ExportConfig{}
+	idParam := c.Param("id")
+	if idParam != "0" {
+		cfgID, err := strconv.ParseInt(idParam, 10, 64)
+		if err != nil {
+			admin.ErrorPage(c, "unable to parse `id` param.")
+			return
+		}
+		exportConfig, err = exportDB.GetExportConfig(ctx, cfgID)
+		if err != nil {
+			admin.ErrorPage(c, fmt.Sprintf("Error loading export config: %v", err))
+			return
+		}
+	}
+
+	if err := form.PopulateExportConfig(exportConfig); err != nil {
+		admin.ErrorPage(c, fmt.Sprintf("error processing export config: %v", err))
+	}
+
+	updateFn := exportDB.AddExportConfig
+	if exportConfig.ConfigID != 0 {
+		updateFn = exportDB.UpdateExportConfig
+	}
+	if err := updateFn(ctx, exportConfig); err != nil {
+		admin.ErrorPage(c, fmt.Sprintf("Error writing export config: %v", err))
+		return
+	}
+
+	m.AddSuccess(fmt.Sprintf("Updated export config #%v", exportConfig.ConfigID))
+
+	usedSigInfos := make(map[int64]bool)
+	for _, id := range exportConfig.SignatureInfoIDs {
+		usedSigInfos[id] = true
+	}
+
+	sigInfos, err := exportDB.ListAllSigntureInfos(ctx)
+	if err != nil {
+		admin.ErrorPage(c, fmt.Sprintf("Error reading the database: %v", err))
+	}
+
+	m["export"] = exportConfig
+	m["usedSigInfos"] = usedSigInfos
+	m["siginfos"] = sigInfos
+	c.HTML(http.StatusOK, "export", m)
+}
diff --git a/internal/admin/exports/view.go b/internal/admin/exports/view.go
new file mode 100644
index 0000000..b1a680f
--- /dev/null
+++ b/internal/admin/exports/view.go
@@ -0,0 +1,78 @@
+// 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, softwar
+// 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 exports
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+	"time"
+
+	"github.com/gin-gonic/gin"
+	"github.com/google/exposure-notifications-server/internal/admin"
+	"github.com/google/exposure-notifications-server/internal/export/database"
+	"github.com/google/exposure-notifications-server/internal/export/model"
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+)
+
+type viewController struct {
+	config *admin.Config
+	env    *serverenv.ServerEnv
+}
+
+func NewView(c *admin.Config, env *serverenv.ServerEnv) *viewController {
+	return &viewController{config: c, env: env}
+}
+
+func (v *viewController) Execute(c *gin.Context) {
+	ctx := c.Request.Context()
+	m := admin.TemplateMap{}
+
+	exportDB := database.New(v.env.Database())
+	exportConfig := &model.ExportConfig{}
+	if idParam := c.Param("id"); idParam == "0" {
+		// Default the period to suggest an appropriate value.
+		exportConfig.Period = 24 * time.Hour
+		m.AddJumbotron("Export Config", "Create New Export Config")
+	} else {
+		m.AddJumbotron("Export Config", "Edit Export Config")
+
+		cfgID, err := strconv.ParseInt(idParam, 10, 64)
+		if err != nil {
+			admin.ErrorPage(c, "unable to parse `id` param.")
+			return
+		}
+		exportConfig, err = exportDB.GetExportConfig(ctx, cfgID)
+		if err != nil {
+			admin.ErrorPage(c, fmt.Sprintf("Error loading export config: %v", err))
+			return
+		}
+	}
+
+	usedSigInfos := make(map[int64]bool)
+	for _, id := range exportConfig.SignatureInfoIDs {
+		usedSigInfos[id] = true
+	}
+
+	sigInfos, err := exportDB.ListAllSigntureInfos(ctx)
+	if err != nil {
+		admin.ErrorPage(c, fmt.Sprintf("Error reading the database: %v", err))
+	}
+
+	m["export"] = exportConfig
+	m["usedSigInfos"] = usedSigInfos
+	m["siginfos"] = sigInfos
+	c.HTML(http.StatusOK, "export", m)
+}
diff --git a/internal/admin/siginfo/form.go b/internal/admin/siginfo/form.go
index 555ef4c..9508291 100644
--- a/internal/admin/siginfo/form.go
+++ b/internal/admin/siginfo/form.go
@@ -17,6 +17,7 @@ package siginfo
 import (
 	"time"
 
+	"github.com/google/exposure-notifications-server/internal/admin"
 	"github.com/google/exposure-notifications-server/internal/export/model"
 )
 
@@ -31,14 +32,7 @@ type formData struct {
 }
 
 func (f *formData) EndTimestamp() (time.Time, error) {
-	if f.EndDate == "" {
-		// Return zero time.
-		return time.Time{}, nil
-	}
-	if f.EndTime == "" {
-		f.EndTime = "00:00"
-	}
-	return time.Parse("2006-01-02 15:04", f.EndDate+" "+f.EndTime)
+	return admin.CombineDateAndTime(f.EndDate, f.EndTime)
 }
 
 func (f *formData) PopulateSigInfo(si *model.SignatureInfo) error {
diff --git a/internal/admin/siginfo/view.go b/internal/admin/siginfo/view.go
index 4caeee1..9dd029d 100644
--- a/internal/admin/siginfo/view.go
+++ b/internal/admin/siginfo/view.go
@@ -39,12 +39,14 @@ func (v *viewController) Execute(c *gin.Context) {
 	m := admin.TemplateMap{}
 
 	sigInfo := &model.SignatureInfo{}
-	if sigID := c.Param("id"); sigID == "0" {
+	if IDParam := c.Param("id"); IDParam == "0" {
 		m.AddJumbotron("Signature Info", "Create New Signature Info")
 		m["new"] = true
 	} else {
+		m.AddJumbotron("Signature Info", "Edit Signature Info")
+
 		exportDB := database.New(v.env.Database())
-		sigID, err := strconv.ParseInt(sigID, 10, 64)
+		sigID, err := strconv.ParseInt(IDParam, 10, 64)
 		if err != nil {
 			admin.ErrorPage(c, "Unable to parse `id` param.")
 			return
diff --git a/internal/admin/time.go b/internal/admin/time.go
new file mode 100644
index 0000000..93c6287
--- /dev/null
+++ b/internal/admin/time.go
@@ -0,0 +1,30 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, softwar
+// 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 admin
+
+import "time"
+
+// CombineDateAndTime takes values from date and time HTML inputs and combines
+// them to a single date time.
+func CombineDateAndTime(dateS, timeS string) (time.Time, error) {
+	if dateS == "" {
+		// Return zero time.
+		return time.Time{}, nil
+	}
+	if timeS == "" {
+		timeS = "00:00"
+	}
+	return time.Parse("2006-01-02 15:04", dateS+" "+timeS)
+}
diff --git a/internal/export/database/export.go b/internal/export/database/export.go
index 2f709d2..0219c62 100644
--- a/internal/export/database/export.go
+++ b/internal/export/database/export.go
@@ -16,7 +16,6 @@ package database
 
 import (
 	"context"
-	"errors"
 	"fmt"
 	"math/rand"
 	"time"
@@ -31,7 +30,6 @@ import (
 
 const (
 	bucketEnvVar = "EXPORT_BUCKET"
-	oneDay       = 24 * time.Hour
 )
 
 type ExportDB struct {
@@ -46,14 +44,8 @@ func New(db *database.DB) *ExportDB {
 
 // AddExportConfig creates a new ExportConfig record from which batch jobs are created.
 func (db *ExportDB) AddExportConfig(ctx context.Context, ec *model.ExportConfig) error {
-	if ec.Period > oneDay {
-		return errors.New("maximum period is 24h")
-	}
-	if ec.Period == 0 {
-		return errors.New("period must be non-zero")
-	}
-	if int64(oneDay.Seconds())%int64(ec.Period.Seconds()) != 0 {
-		return errors.New("period must divide equally into 24 hours (e.g., 2h, 4h, 12h, 15m, 30m)")
+	if err := ec.Validate(); err != nil {
+		return err
 	}
 
 	var thru *time.Time
@@ -78,6 +70,53 @@ func (db *ExportDB) AddExportConfig(ctx context.Context, ec *model.ExportConfig)
 	})
 }
 
+// UpdateExportConfig updates an existing ExportConfig record from which batch jobs are created.
+func (db *ExportDB) UpdateExportConfig(ctx context.Context, ec *model.ExportConfig) error {
+	if err := ec.Validate(); err != nil {
+		return err
+	}
+
+	var thru *time.Time
+	if !ec.Thru.IsZero() {
+		thru = &ec.Thru
+	}
+	return db.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
+		result, err := tx.Exec(ctx, `
+			UPDATE
+				ExportConfig
+			SET
+				bucket_name = $1, filename_root = $2, period_seconds = $3, region = $4, from_timestamp = $5, thru_timestamp = $6, signature_info_ids = $7
+			WHERE config_id = $8
+		`, ec.BucketName, ec.FilenameRoot, int(ec.Period.Seconds()), ec.Region,
+			ec.From, thru, ec.SignatureInfoIDs, ec.ConfigID)
+		if err != nil {
+			return fmt.Errorf("updating signatureinfo: %w", err)
+		}
+		if result.RowsAffected() != 1 {
+			return fmt.Errorf("no rows updated")
+		}
+		return nil
+	})
+}
+
+func (db *ExportDB) GetExportConfig(ctx context.Context, id int64) (*model.ExportConfig, error) {
+	conn, err := db.db.Pool.Acquire(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("acquiring connection: %w", err)
+	}
+	defer conn.Release()
+
+	row := conn.QueryRow(ctx, `
+		SELECT
+			config_id, bucket_name, filename_root, period_seconds, region, from_timestamp, thru_timestamp, signature_info_ids
+		FROM
+			ExportConfig
+		WHERE
+			config_id = $1`, id)
+
+	return scanOneExportConfig(row)
+}
+
 func (db *ExportDB) GetAllExportConfigs(ctx context.Context) ([]*model.ExportConfig, error) {
 	conn, err := db.db.Pool.Acquire(ctx)
 	if err != nil {
@@ -223,12 +262,12 @@ func (db *ExportDB) ListAllSigntureInfos(ctx context.Context) ([]*model.Signatur
 	defer conn.Release()
 
 	rows, err := conn.Query(ctx, `
-		SELECT
-			id, signing_key, app_package_name, bundle_id, signing_key_version, signing_key_id, thru_timestamp
-		FROM
-			SignatureInfo
-		ORDER BY signing_key_id ASC, signing_key_version ASC, thru_timestamp DESC, app_package_name ASC, bundle_id ASC
-		`)
+    SELECT
+      id, signing_key, app_package_name, bundle_id, signing_key_version, signing_key_id, thru_timestamp
+    FROM
+      SignatureInfo
+    ORDER BY signing_key_id ASC, signing_key_version ASC, thru_timestamp DESC, app_package_name ASC, bundle_id ASC
+  `)
 	if err != nil {
 		return nil, err
 	}
diff --git a/internal/export/exportfile.go b/internal/export/exportfile.go
index 8981b98..483fd2d 100644
--- a/internal/export/exportfile.go
+++ b/internal/export/exportfile.go
@@ -21,6 +21,7 @@ import (
 	"crypto/rand"
 	"crypto/sha256"
 	"fmt"
+	"io/ioutil"
 	"sort"
 
 	"github.com/google/exposure-notifications-server/internal/export/model"
@@ -32,7 +33,6 @@ import (
 )
 
 const (
-	fixedHeaderWidth     = 16
 	exportBinaryName     = "export.bin"
 	exportSignatureName  = "export.sig"
 	defaultIntervalCount = 144
@@ -40,6 +40,11 @@ const (
 	algorithm = "1.2.840.10045.4.3.2"
 )
 
+var (
+	fixedHeader      = []byte("EK Export v1    ")
+	fixedHeaderWidth = 16
+)
+
 type ExportSigners struct {
 	SignatureInfo *model.SignatureInfo
 	Signer        crypto.Signer
@@ -84,8 +89,50 @@ func MarshalExportFile(eb *model.ExportBatch, exposures []*publishmodel.Exposure
 	return buf.Bytes(), nil
 }
 
+// Unmarshal extracts the protobuf encoded exposure key present in the zip archived payload.
+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)
+	}
+
+	for _, file := range zp.File {
+		if file.Name == exportBinaryName {
+			return unmarshalContent(file)
+		}
+	}
+
+	return nil, fmt.Errorf("Payload is invalid: no %v file was found", exportBinaryName)
+}
+
+func unmarshalContent(file *zip.File) (*export.TemporaryExposureKeyExport, error) {
+	f, err := file.Open()
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+
+	content, err := ioutil.ReadAll(f)
+	if err != nil {
+		return nil, err
+	}
+
+	prefix := content[:fixedHeaderWidth]
+	if !bytes.Equal(prefix, fixedHeader) {
+		return nil, fmt.Errorf("unknown prefix: %v", string(prefix))
+	}
+
+	message := new(export.TemporaryExposureKeyExport)
+	err = proto.Unmarshal(content[fixedHeaderWidth:], message)
+	if err != nil {
+		return nil, err
+	}
+
+	return message, nil
+}
+
 func marshalContents(eb *model.ExportBatch, exposures []*publishmodel.Exposure, batchNum int32, batchSize int32, signers []ExportSigners) ([]byte, error) {
-	exportBytes := []byte("EK Export v1    ")
+	exportBytes := fixedHeader
 	if len(exportBytes) != fixedHeaderWidth {
 		return nil, fmt.Errorf("incorrect header length: %d", len(exportBytes))
 	}
diff --git a/internal/export/model/export_model.go b/internal/export/model/export_model.go
index 59f21f7..b0e5fa3 100644
--- a/internal/export/model/export_model.go
+++ b/internal/export/model/export_model.go
@@ -15,6 +15,7 @@
 package model
 
 import (
+	"errors"
 	"time"
 )
 
@@ -25,6 +26,10 @@ var (
 	ExportBatchDeleted  = "DELETED"
 )
 
+const (
+	oneDay = 24 * time.Hour
+)
+
 type ExportConfig struct {
 	ConfigID         int64         `db:"config_id"`
 	BucketName       string        `db:"bucket_name"`
@@ -36,6 +41,19 @@ type ExportConfig struct {
 	SignatureInfoIDs []int64       `db:"signature_info_ids"`
 }
 
+func (ec *ExportConfig) Validate() error {
+	if ec.Period > oneDay {
+		return errors.New("maximum period is 24h")
+	}
+	if ec.Period == 0 {
+		return errors.New("period must be non-zero")
+	}
+	if int64(oneDay.Seconds())%int64(ec.Period.Seconds()) != 0 {
+		return errors.New("period must divide equally into 24 hours (e.g., 2h, 4h, 12h, 15m, 30m)")
+	}
+	return nil
+}
+
 func (e *ExportConfig) FormattedFromTime() string {
 	return e.From.Format(time.UnixDate)
 }
@@ -47,6 +65,22 @@ func (e *ExportConfig) FormattedThruTime() string {
 	return e.Thru.Format(time.UnixDate)
 }
 
+func (e *ExportConfig) FromHTMLDate() string {
+	return toHTMLDate(e.From)
+}
+
+func (e *ExportConfig) FromHTMLTime() string {
+	return toHTMLTime(e.From)
+}
+
+func (e *ExportConfig) ThruHTMLDate() string {
+	return toHTMLDate(e.Thru)
+}
+
+func (e *ExportConfig) ThruHTMLTime() string {
+	return toHTMLTime(e.Thru)
+}
+
 type ExportBatch struct {
 	BatchID          int64     `db:"batch_id" json:"batchID"`
 	ConfigID         int64     `db:"config_id" json:"configID"`
@@ -90,16 +124,24 @@ func (s *SignatureInfo) FormattedEndTimestamp() string {
 
 // HTMLEndDate returns EndDate in a format for the HTML date input default value.
 func (s *SignatureInfo) HTMLEndDate() string {
-	if s.EndTimestamp.IsZero() {
-		return ""
-	}
-	return s.EndTimestamp.UTC().Format("2006-01-02")
+	return toHTMLDate(s.EndTimestamp)
 }
 
 // HTMLEndTime returns EndDate in a format for the HTML time input default value.
 func (s *SignatureInfo) HTMLEndTime() string {
-	if s.EndTimestamp.IsZero() {
+	return toHTMLTime(s.EndTimestamp)
+}
+
+func toHTMLDate(t time.Time) string {
+	if t.IsZero() {
+		return ""
+	}
+	return t.UTC().Format("2006-01-02")
+}
+
+func toHTMLTime(t time.Time) string {
+	if t.IsZero() {
 		return ""
 	}
-	return s.EndTimestamp.UTC().Format("15:04")
+	return t.UTC().Format("15:04")
 }
diff --git a/tools/admin-console/main.go b/tools/admin-console/main.go
index b47018a..34fbb72 100644
--- a/tools/admin-console/main.go
+++ b/tools/admin-console/main.go
@@ -23,6 +23,7 @@ import (
 	"github.com/gin-gonic/gin"
 	"github.com/google/exposure-notifications-server/internal/admin"
 	"github.com/google/exposure-notifications-server/internal/admin/authorizedapps"
+	"github.com/google/exposure-notifications-server/internal/admin/exports"
 	"github.com/google/exposure-notifications-server/internal/admin/index"
 	"github.com/google/exposure-notifications-server/internal/admin/siginfo"
 	"github.com/google/exposure-notifications-server/internal/setup"
@@ -51,6 +52,12 @@ func main() {
 	saveAppController := authorizedapps.NewSave(&config, env)
 	router.POST("/app", saveAppController.Execute)
 
+	// Export Config Handling.
+	exportConfigController := exports.NewView(&config, env)
+	router.GET("/exports/:id", exportConfigController.Execute)
+	saveExportConfigController := exports.NewSave(&config, env)
+	router.POST("/exports/:id", saveExportConfigController.Execute)
+
 	// Signature Info.
 	sigInfoController := siginfo.NewView(&config, env)
 	router.GET("/siginfo/:id", sigInfoController.Execute)
diff --git a/tools/admin-console/templates/export.html b/tools/admin-console/templates/export.html
new file mode 100644
index 0000000..cb12876
--- /dev/null
+++ b/tools/admin-console/templates/export.html
@@ -0,0 +1,118 @@
+{{define "export"}}
+{{template "top" .}}
+
+{{if eq 0 .export.ConfigID}}
+<h2>Create New Export Config</h2>
+{{else}}
+<h2>Edit Export Config # {{.export.ConfigID}}</h2>
+
+<div class="alert alert-warning" role="alert">
+<p>You are editing an export config. Any changes to this config will only
+	impact future export batches for this config.</p>
+</div>
+{{end}}
+
+<div class="alert alert-info" role="alert">
+  Export configurations cannot be deleted. Instead they are expired by
+  setting and end date and time.
+</div>
+
+<form method="POST" action="/exports/{{.export.ConfigID}}" class="form-horizontal">
+  <div class="form-group row">
+    <label class="control-label col-sm-3" for="Region">Region:</label>
+    <div class="col-sm-6">
+      <input type="text" id="Region" name="Region" size="5" value="{{.export.Region}}">
+      <small id="RegionHelpBlock" class="form-text text-muted">The region of this export.
+        Must corresponding to regions accepted in Authorized Apps config, otherwise the export
+				will never have any data.
+			</small>
+    </div>
+  </div>
+
+	<div class="form-group row">
+		<label class="control-label col-sm-3" for="BucketName">Cloud Storage Bucket Name:</label>
+		<div class="col-sm-6">
+			<input type="text" id="BucketName" name="BucketName" size="50" value="{{.export.BucketName}}">
+			<small id="BucketNameHelpBlock" class="form-text text-muted">The bucket name to use when
+				storing exports on cloud based storage. For local filesystem, this is the path.
+			</small>
+		</div>
+	</div>
+	<div class="form-group row">
+		<label class="control-label col-sm-3" for="FilenameRoot">Filename root:</label>
+		<div class="col-sm-6">
+			<input type="text" id="FilenameRoot" name="FilenameRoot" size="50" value="{{.export.FilenameRoot}}">
+			<small id="FilenameRootHelpBlock" class="form-text text-muted">Common prefix for all filenames
+				that are part of this export.
+			</small>
+		</div>
+	</div>
+
+	<div class="form-group row">
+		<label class="control-label col-sm-3" for="Period">Export period:</label>
+		<div class="col-sm-6">
+			<input type="text" id="Period" name="Period" size="10" value="{{.export.Period}}">
+		  <small id="PeriodHelpBlock" class="form-text text-muted">
+        <strong>Devices can only import 15 (iOS) or 20 (Android) exports a day. With this in mind
+				a period of no less than <em>4h</em> is recommended.</strong>
+			  Valid time units are "s", "m", "h".</small>
+		</div>
+	</div>
+
+	<div class="form-group row">
+		<label class="control-label col-sm-3" for="fromdate">Valid From Date/Time:</label>
+		<div class="col-sm-6">
+			<input type="date" id="fromdate" name="fromdate" value="{{.export.FromHTMLDate}}" min="2020-05-01" max="2029-12-21-" />
+			<input type="time" id="fromtime" name="fromtime" value="{{.export.FromHTMLTime}}" />
+			<a href="https://www.timeanddate.com/worldclock/timezone/utc" target="_blank">UTC</a>
+
+			<small id="fromdateHelpBlock" class="form-text text-muted">Start time of export. Normally left blank.</small>
+		</div>
+	</div>
+	<div class="form-group row">
+		<label class="control-label col-sm-3" for="thrudate">Valid Thru Date/Time:</label>
+		<div class="col-sm-6">
+			<input type="date" id="thrudate" name="thrudate" value="{{.export.ThruHTMLDate}}" min="2020-05-01" max="2029-12-21-" />
+			<input type="time" id="thrutime" name="thrutime" value="{{.export.ThruHTMLTime}}" />
+			<a href="https://www.timeanddate.com/worldclock/timezone/utc" target="_blank">UTC</a>
+
+			<small id="thrudateHelpBlock" class="form-text text-muted">Generate exports until time.
+			 Normally left blank at the start, can be set to stop generating an export.</small>
+		</div>
+	</div>
+
+	<hr/>
+	<div class="alert alert-info" role="alert">
+	  Select the keys used to sign exports of this type. It is possible to have more than one
+		selected at a time. Having multiple signers is the correct way to rotate a signing key.
+		The old one should not be decomissions until Apple and Google have confirmed that the
+		new public key has been rolled out.
+	</div>
+
+	{{if .siginfos}}
+	<div class="form-group row">
+		<label class="control-label col-sm-3" for="siginfos">Signing Keys To Use:</label>
+		<div class="col-sm-6">
+    {{range .siginfos}}
+		<div class="form-check">
+      <input class="form-check-input" type="checkbox" name="siginfo" value="{{.ID}}" id="sic{{.ID}}" {{if index $.usedSigInfos .ID}}checked{{end}}>
+      <label class="form-check-label" for="sic{{.ID}}">
+				{{.ID}}: KeyID: {{.SigningKeyID}} | Version: {{.SigningKeyVersion}} | Thru: {{.FormattedEndTimestamp}} <br/> SigningKey: {{.SigningKey}}
+      </label>
+    </div>
+		{{end}}
+		</div>
+	</div>
+	{{else}}
+	  <div class="alert alert-danger" role="alert">
+    There are no Signature Info configurations. You will not be able to sign your
+		exports until one is created.
+    </div>
+	{{end}}
+
+	<hr/>
+  <button type="submit" class="btn btn-outline-primary" name="TODO" value="save">Save Changes</button>
+  <a href="/" class="btn btn-outline-secondary">Cancel</a>
+ </form>
+{{template "bottom" .}}
+{{end}}
diff --git a/tools/admin-console/templates/index.html b/tools/admin-console/templates/index.html
index 0f3294c..82b7438 100644
--- a/tools/admin-console/templates/index.html
+++ b/tools/admin-console/templates/index.html
@@ -18,19 +18,20 @@
 <ul>
  {{if .exports}}
    {{range .exports}}
-     <li><a href="#">{{.ConfigID}}</a> Region: {{.Region}} | Bucket: {{.BucketName}} | From: {{.FormattedFromTime}} | Thru: {{.FormattedThruTime}}</li>
+     <li><a href="/exports/{{.ConfigID}}">{{.ConfigID}}</a> Region: {{.Region}} | Bucket: {{.BucketName}} | From: {{.FormattedFromTime}} | Thru: {{.FormattedThruTime}}</li>
    {{end}}
  {{else}}
    <li><i>There are no export configurations</i></li>
  {{end}}
 </ul>
+<a href="/exports/0"  class="btn btn-outline-primary">Create new Export Config</a>
 
 <hr/>
 <h2>Signature Info</h2>
 <ul>
   {{if .siginfos}}
     {{range .siginfos}}
-      <li><a href="/siginfo/{{.ID}}">{{.ID}}: KeyID: {{.SigningKeyID}}</a> | Version: {{.SigningKeyVersion}} | Thru: {{.FormattedEndTimestamp}} | SigningKey: {{.SigningKey}}</li>
+      <li><a href="/siginfo/{{.ID}}">{{.ID}}: KeyID: {{.SigningKeyID}}</a> | Version: {{.SigningKeyVersion}} | Thru: {{.FormattedEndTimestamp}} <br/> SigningKey: {{.SigningKey}}</li>
     {{end}}
   {{else}}
     <li><i>There are no signature info configurations.</i></li>
diff --git a/tools/admin-console/templates/top.html b/tools/admin-console/templates/top.html
index f968965..5af4c4d 100644
--- a/tools/admin-console/templates/top.html
+++ b/tools/admin-console/templates/top.html
@@ -28,9 +28,9 @@
 <div class="jumbotron">
   <h1 class="display-4">{{.jumbotron}}</h1>
 	{{if .jumbotronsub}}
-  <h2 class="display-4">{{.jumbotronsub}}</h2>
+  <h3 class="display-4">{{.jumbotronsub}}</h3>
 	{{end}}
-  <hr class="my-4">
+  <hr class="my-4"/>
 </div>
 {{end}}
 


Test patch:
diff --git a/internal/admin/authorizedapps/view_test.go b/internal/admin/authorizedapps/view_test.go
index 35d7417..2d56938 100644
--- a/internal/admin/authorizedapps/view_test.go
+++ b/internal/admin/authorizedapps/view_test.go
@@ -27,7 +27,7 @@ func TestRenderAuthorizedApps(t *testing.T) {
 	// If this test fails, it's likely that you changed something in
 	//  internal/authorizedapp/model/
 	// And whatever you changed is used in the
-	//  tools/admin-console/templates/app_view.html
+	//  tools/admin-console/templates/authorizedapp.html
 	// That is what caused the test failure.
 	m := admin.TemplateMap{}
 	authorizedApp := model.NewAuthorizedApp()
diff --git a/internal/admin/exports/view_test.go b/internal/admin/exports/view_test.go
new file mode 100644
index 0000000..e366abf
--- /dev/null
+++ b/internal/admin/exports/view_test.go
@@ -0,0 +1,53 @@
+// 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, softwar
+// 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 exports
+
+import (
+	"net/http/httptest"
+	"testing"
+
+	"github.com/google/exposure-notifications-server/internal/admin"
+	"github.com/google/exposure-notifications-server/internal/export/model"
+)
+
+func TestRenderSignatureInfo(t *testing.T) {
+	// Hello developer!
+	// If this test fails, it's likely that you changed something in
+	//  internal/authorizedapp/model/
+	// And whatever you changed is used in the
+	//  tools/admin-console/templates/export.html
+	// That is what caused the test failure.
+	m := admin.TemplateMap{}
+	exportConfig := &model.ExportConfig{}
+	m["export"] = exportConfig
+
+	sigInfos := []*model.SignatureInfo{
+		&model.SignatureInfo{ID: 5},
+	}
+	usedSigInfos := map[int64]bool{5: true}
+	m["usedSigInfos"] = usedSigInfos
+	m["siginfos"] = sigInfos
+
+	recorder := httptest.NewRecorder()
+	config := admin.Config{
+		TemplatePath: "../../../tools/admin-console/templates",
+		TopFile:      "top",
+		BotFile:      "bottom",
+	}
+	err := config.RenderTemplate(recorder, "export", m)
+	if err != nil {
+		t.Fatalf("error rendering template: %v", err)
+	}
+}
diff --git a/internal/admin/siginfo/view_test.go b/internal/admin/siginfo/view_test.go
new file mode 100644
index 0000000..6d509c4
--- /dev/null
+++ b/internal/admin/siginfo/view_test.go
@@ -0,0 +1,46 @@
+// 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, softwar
+// 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 siginfo
+
+import (
+	"net/http/httptest"
+	"testing"
+
+	"github.com/google/exposure-notifications-server/internal/admin"
+	"github.com/google/exposure-notifications-server/internal/export/model"
+)
+
+func TestRenderSignatureInfo(t *testing.T) {
+	// Hello developer!
+	// If this test fails, it's likely that you changed something in
+	//  internal/authorizedapp/model/
+	// And whatever you changed is used in the
+	//  tools/admin-console/templates/siginfo.html
+	// That is what caused the test failure.
+	m := admin.TemplateMap{}
+	sigInfo := &model.SignatureInfo{}
+	m["siginfo"] = sigInfo
+
+	recorder := httptest.NewRecorder()
+	config := admin.Config{
+		TemplatePath: "../../../tools/admin-console/templates",
+		TopFile:      "top",
+		BotFile:      "bottom",
+	}
+	err := config.RenderTemplate(recorder, "siginfo", m)
+	if err != nil {
+		t.Fatalf("error rendering template: %v", err)
+	}
+}
diff --git a/internal/export/database/export_test.go b/internal/export/database/export_test.go
index 926935e..2a0fbce 100644
--- a/internal/export/database/export_test.go
+++ b/internal/export/database/export_test.go
@@ -117,11 +117,12 @@ func TestLookupSignatureInfos(t *testing.T) {
 	}
 }
 
-func TestAddExportConfig(t *testing.T) {
+func TestAddGetUpdateExportConfig(t *testing.T) {
 	t.Parallel()
 
 	testDB := database.NewTestDatabase(t)
 	ctx := context.Background()
+	exportDB := New(testDB)
 
 	fromTime := time.Now()
 	thruTime := fromTime.Add(6 * time.Hour)
@@ -134,34 +135,32 @@ func TestAddExportConfig(t *testing.T) {
 		Thru:             thruTime,
 		SignatureInfoIDs: []int64{42, 84},
 	}
-	if err := New(testDB).AddExportConfig(ctx, want); err != nil {
-		t.Fatal(err)
-	}
-	conn, err := testDB.Pool.Acquire(ctx)
-	if err != nil {
+	if err := exportDB.AddExportConfig(ctx, want); err != nil {
 		t.Fatal(err)
 	}
-	defer conn.Release()
-	var (
-		got   model.ExportConfig
-		psecs int
-	)
-	err = conn.QueryRow(ctx, `
-		SELECT
-			config_id, bucket_name, filename_root, period_seconds, region, from_timestamp, thru_timestamp, signature_info_ids
-		FROM
-			ExportConfig
-		WHERE
-			config_id = $1
-	`, want.ConfigID).Scan(&got.ConfigID, &got.BucketName, &got.FilenameRoot, &psecs, &got.Region, &got.From, &got.Thru, &got.SignatureInfoIDs)
+
+	got, err := exportDB.GetExportConfig(ctx, want.ConfigID)
 	if err != nil {
 		t.Fatal(err)
 	}
-	got.Period = time.Duration(psecs) * time.Second
 
 	want.From = want.From.Truncate(time.Microsecond)
 	want.Thru = want.Thru.Truncate(time.Microsecond)
-	if diff := cmp.Diff(want, &got); diff != "" {
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Errorf("mismatch (-want, +got):\n%s", diff)
+	}
+
+	// Now update it.
+	want.Period = 15 * time.Minute
+	want.Thru = time.Time{}
+	want.SignatureInfoIDs = []int64{1, 2, 3, 4, 5}
+
+	if err := exportDB.UpdateExportConfig(ctx, want); err != nil {
+		t.Fatal(err)
+	}
+
+	got, err = exportDB.GetExportConfig(ctx, want.ConfigID)
+	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
new file mode 100644
index 0000000..4a3e46b
--- /dev/null
+++ b/internal/export/exportfile_test.go
@@ -0,0 +1,140 @@
+// 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 export
+
+import (
+	"crypto"
+	"io"
+	"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"
+)
+
+func TestMarshalUnmarshalExportFile(t *testing.T) {
+	batchStartTime := time.Date(2020, 5, 1, 0, 0, 0, 0, time.UTC).Truncate(time.Second)
+	batchEndTime := batchStartTime.Add(1 * time.Hour)
+
+	batch := &model.ExportBatch{
+		BatchID:          1,
+		ConfigID:         1,
+		BucketName:       "test-bucket",
+		FilenameRoot:     "files",
+		StartTimestamp:   batchStartTime,
+		EndTimestamp:     batchEndTime,
+		Region:           "US",
+		Status:           "",
+		LeaseExpires:     time.Time{},
+		SignatureInfoIDs: []int64{1, 2},
+	}
+
+	exposures := []*publishmodel.Exposure{
+		{
+			ExposureKey:      []byte("ABC"),
+			Regions:          []string{"US"},
+			IntervalNumber:   18,
+			IntervalCount:    0,
+			CreatedAt:        batchStartTime,
+			LocalProvenance:  true,
+			TransmissionRisk: 8,
+		},
+		{
+			ExposureKey:      []byte("DEF"),
+			Regions:          []string{"CA"},
+			IntervalNumber:   118,
+			IntervalCount:    1,
+			CreatedAt:        batchEndTime,
+			LocalProvenance:  true,
+			TransmissionRisk: 1,
+		},
+	}
+
+	signatureInfo := &model.SignatureInfo{
+		SigningKey:        "/kms/project/key/1",
+		SigningKeyVersion: "1",
+		SigningKeyID:      "310",
+		EndTimestamp:      batchEndTime,
+	}
+
+	signer := &customTestSigner{}
+
+	blob, err := MarshalExportFile(batch, exposures, 1, 1, []ExportSigners{
+		{SignatureInfo: signatureInfo,
+			Signer: signer}})
+	if err != nil {
+		t.Fatalf("Can't marshal export file, %v", err)
+	}
+
+	got, err := UnmarshalExportFile(blob)
+	if err != nil {
+		t.Fatalf("Unmarshal failed: %v", err)
+	}
+
+	infos := []*export.SignatureInfo{
+		{
+			VerificationKeyVersion: proto.String("1"),
+			VerificationKeyId:      proto.String("310"),
+			SignatureAlgorithm:     proto.String("1.2.840.10045.4.3.2"),
+		},
+	}
+
+	keys := []*export.TemporaryExposureKey{
+		{
+			KeyData:                    []byte("ABC"),
+			TransmissionRiskLevel:      proto.Int32(8),
+			RollingStartIntervalNumber: proto.Int32(18),
+			RollingPeriod:              proto.Int32(0),
+		},
+		{
+			KeyData:                    []byte("DEF"),
+			TransmissionRiskLevel:      proto.Int32(1),
+			RollingStartIntervalNumber: proto.Int32(118),
+			RollingPeriod:              proto.Int32(1),
+		},
+	}
+
+	keyExport := &export.TemporaryExposureKeyExport{
+		StartTimestamp: proto.Uint64(uint64(batchStartTime.Unix())),
+		EndTimestamp:   proto.Uint64(uint64(batchEndTime.Unix())),
+		Region:         proto.String("US"),
+		BatchNum:       proto.Int32(1),
+		BatchSize:      proto.Int32(1),
+		SignatureInfos: infos,
+		Keys:           keys,
+	}
+
+	ignoredTemporaryExposureKeyExportFields := cmpopts.IgnoreUnexported(export.TemporaryExposureKeyExport{})
+	ignoredSignatureInfoFields := cmpopts.IgnoreUnexported(export.SignatureInfo{})
+	ignoredTemporaryExposureKeyFields := cmpopts.IgnoreUnexported(export.TemporaryExposureKey{})
+	diff := cmp.Diff(keyExport, got, ignoredTemporaryExposureKeyExportFields, ignoredSignatureInfoFields, ignoredTemporaryExposureKeyFields)
+	if diff != "" {
+		t.Fatalf("unmarshal mismatch (-want +got):\n%v", diff)
+	}
+}
+
+type customTestSigner struct {
+	sig []byte
+	pub crypto.PublicKey
+}
+
+func (s *customTestSigner) Public() crypto.PublicKey { return s.pub }
+func (s *customTestSigner) Sign(io.Reader, []byte, crypto.SignerOpts) ([]byte, error) {
+	return s.sig, nil
+}