Repository: google/exposure-notifications-server
Pull request #772 (2020-07-25 15:59:06)

Install gcloud in presubmit test image, use gcloud authenticating with docker
Mako microservice image used for performance test needs authentication to be pulled from GCR, this PR adds steps for authenticating with GCR.

Like the docker-in-docker setup steps, gcloud installation command also comes out of https://github.com/kubernetes/test-infra/blob/2388ca759f423413495bd7ab42b20f9f39ec7700/images/bootstrap/Dockerfile#L54. 

/assign @sethvargo 
/cc @stati0n 

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/772#" title="Author self-approved">chaodaiG</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"]} -->

Patch:
diff --git a/docs/getting-started/publishing-temporary-exposure-keys.md b/docs/getting-started/publishing-temporary-exposure-keys.md
index 46d13f2..3f70114 100644
--- a/docs/getting-started/publishing-temporary-exposure-keys.md
+++ b/docs/getting-started/publishing-temporary-exposure-keys.md
@@ -51,6 +51,19 @@ In addition to the above configurations,
 	`hmackey` must be able to be used to calculate the HMAC value as present in
 	the certificate.
 
+## Chaff Requests
+
+It may be possible for a server operator or network observer to glean
+information about client's communicating with the server. To minimize this, the
+exposure service supports chaff (fake) requests. **Your client should send
+regular chaff requests.** To send a chaff request:
+
+1.  Create a real request with a real request body.
+
+1.  Set the `X-Chaff` header on the request.
+
+1.  Receive and **discard** the response. **Do not process the response!**
+
 ## Server Access Configuration
 
 In order for your application to publish keys to the server, the server
diff --git a/go.mod b/go.mod
index 226b944..d700e0d 100644
--- a/go.mod
+++ b/go.mod
@@ -47,6 +47,7 @@ require (
 	github.com/kr/text v0.2.0 // indirect
 	github.com/lib/pq v1.5.2 // indirect
 	github.com/lstoll/awskms v0.0.0-20191202211033-9042d2a6f52c
+	github.com/mikehelmick/go-chaff v0.3.0
 	github.com/mitchellh/cli v1.1.1 // indirect
 	github.com/mitchellh/go-testing-interface v1.14.1 // indirect
 	github.com/mitchellh/mapstructure v1.3.1 // indirect
diff --git a/go.sum b/go.sum
index 16bf036..56df072 100644
--- a/go.sum
+++ b/go.sum
@@ -903,6 +903,8 @@ github.com/michaelklishin/rabbit-hole v0.0.0-20191008194146-93d9988f0cd5/go.mod
 github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
 github.com/miekg/dns v1.1.15 h1:CSSIDtllwGLMoA6zjdKnaE6Tx6eVUxQ29LUgGetiDCI=
 github.com/miekg/dns v1.1.15/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
+github.com/mikehelmick/go-chaff v0.3.0 h1:bnU5oF8onKCoe9B/iQ22dvhtzSlEcLJ1R82b97T4BAU=
+github.com/mikehelmick/go-chaff v0.3.0/go.mod h1:mFry3zNW17oxNGmZpQV3PEOmzTNyly3nLDYawCT/iCE=
 github.com/mitchellh/cli v1.0.0 h1:iGBIsUe3+HZ/AD/Vd7DErOt5sU9fa8Uj7A2s1aggv1Y=
 github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
 github.com/mitchellh/cli v1.1.1 h1:J64v/xD7Clql+JVKSvkYojLOXu1ibnY9ZjGLwSt/89w=
diff --git a/internal/federationin/federationin.go b/internal/federationin/federationin.go
index 91fd186..8fb835a 100644
--- a/internal/federationin/federationin.go
+++ b/internal/federationin/federationin.go
@@ -57,7 +57,7 @@ var (
 
 type (
 	fetchFn               func(context.Context, *pb.FederationFetchRequest, ...grpc.CallOption) (*pb.FederationFetchResponse, error)
-	insertExposuresFn     func(context.Context, []*publishmodel.Exposure) (int, error)
+	insertExposuresFn     func(context.Context, []*publishmodel.Exposure, *pb.RevisionTokenData, bool) (int, error)
 	startFederationSyncFn func(context.Context, *model.FederationInQuery, time.Time) (int64, database.FinalizeSyncFn, error)
 )
 
@@ -293,7 +293,7 @@ func pull(ctx context.Context, metrics metrics.Exporter, opts *pullOptions) (err
 
 					if len(exposures) == fetchBatchSize {
 						n := 0
-						if n, err = opts.deps.insertExposures(ctx, exposures); err != nil {
+						if n, err = opts.deps.insertExposures(ctx, exposures, nil, false); err != nil {
 							metrics.WriteInt("federation-pull-inserts", false, len(exposures))
 							return fmt.Errorf("inserting %d exposures: %w", len(exposures), err)
 						}
@@ -305,7 +305,7 @@ func pull(ctx context.Context, metrics metrics.Exporter, opts *pullOptions) (err
 		}
 		if len(exposures) > 0 {
 			n := 0
-			if n, err = opts.deps.insertExposures(ctx, exposures); err != nil {
+			if n, err = opts.deps.insertExposures(ctx, exposures, nil, false); err != nil {
 				metrics.WriteInt("federation-pull-inserts", false, len(exposures))
 				return fmt.Errorf("inserting %d exposures: %w", len(exposures), err)
 			}
diff --git a/internal/generate/handler.go b/internal/generate/handler.go
index 33a9fda..3e78e85 100644
--- a/internal/generate/handler.go
+++ b/internal/generate/handler.go
@@ -147,7 +147,7 @@ func (h *generateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		n, err := h.database.InsertAndReviseExposures(ctx, exposures)
+		n, err := h.database.InsertAndReviseExposures(ctx, exposures, nil, false)
 		if err != nil {
 			message := fmt.Sprintf("error writing exposure record: %v", err)
 			logger.Errorf(message)
@@ -182,7 +182,8 @@ func (h *generateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 				return
 			}
 
-			n, err := h.database.InsertAndReviseExposures(ctx, exposures)
+			// Bypass revision token enforcement on generated data.
+			n, err := h.database.InsertAndReviseExposures(ctx, exposures, nil, false)
 			if err != nil {
 				message := fmt.Sprintf("error writing exposure record: %v", err)
 				logger.Errorf(message)
diff --git a/internal/integration/helpers.go b/internal/integration/helpers.go
index 444e165..3a3706c 100644
--- a/internal/integration/helpers.go
+++ b/internal/integration/helpers.go
@@ -18,6 +18,7 @@ import (
 	"context"
 	"crypto/rand"
 	"crypto/sha256"
+	"encoding/base64"
 	"fmt"
 	"net/http"
 	"os"
@@ -31,6 +32,7 @@ import (
 	"github.com/google/exposure-notifications-server/internal/export"
 	"github.com/google/exposure-notifications-server/internal/federationin"
 	"github.com/google/exposure-notifications-server/internal/publish"
+	revdb "github.com/google/exposure-notifications-server/internal/revision/database"
 	"github.com/google/exposure-notifications-server/internal/serverenv"
 	"github.com/google/exposure-notifications-server/internal/storage"
 	"github.com/google/exposure-notifications-server/pkg/keys"
@@ -124,10 +126,24 @@ func testServer(tb testing.TB) (*serverenv.ServerEnv, *http.Client) {
 
 	db := database.NewTestDatabase(tb)
 
-	km, err := keys.NewNoop(ctx)
+	km, err := keys.NewInMemory(ctx)
 	if err != nil {
 		tb.Fatal(err)
 	}
+	if err := km.AddEncryptionKey("tokenkey"); err != nil {
+		tb.Fatal(err)
+	}
+	if err := km.AddSigningKey("signingkey"); err != nil {
+		tb.Fatal(err)
+	}
+	// create an initial revision key.
+	revisionDB, err := revdb.New(db, &revdb.KMSConfig{WrapperKeyID: "tokenkey", KeyManager: km})
+	if err != nil {
+		tb.Fatal(err)
+	}
+	if _, err := revisionDB.CreateRevisionKey(ctx); err != nil {
+		tb.Fatal(err)
+	}
 
 	sm, err := secrets.NewNoop(ctx)
 	if err != nil {
@@ -206,6 +222,10 @@ func testServer(tb testing.TB) (*serverenv.ServerEnv, *http.Client) {
 		MaxIntervalAge:           360 * time.Hour,
 		CreatedAtTruncateWindow:  1 * time.Second,
 		ReleaseSameDayKeys:       true,
+		RevisionTokenKeyID:       "tokenkey",
+		RevisionTokenAAD:         base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3}),
+		RevisionKeyCacheDuration: time.Second,
+		RevisionTokenMinLength:   28,
 	}
 
 	publishHandler, err := publish.NewHandler(ctx, publishConfig, env)
diff --git a/internal/publish/config.go b/internal/publish/config.go
index f7c6565..181f3b6 100644
--- a/internal/publish/config.go
+++ b/internal/publish/config.go
@@ -24,6 +24,8 @@ import (
 	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/exposure-notifications-server/internal/setup"
 	"github.com/google/exposure-notifications-server/internal/verification"
+	"github.com/google/exposure-notifications-server/pkg/base64util"
+	"github.com/google/exposure-notifications-server/pkg/keys"
 	"github.com/google/exposure-notifications-server/pkg/secrets"
 )
 
@@ -33,6 +35,7 @@ var _ setup.DatabaseConfigProvider = (*Config)(nil)
 var _ setup.SecretManagerConfigProvider = (*Config)(nil)
 var _ setup.ObservabilityExporterConfigProvider = (*Config)(nil)
 var _ model.TransformerConfig = (*Config)(nil)
+var _ setup.KeyManagerConfigProvider = (*Config)(nil)
 
 // Config represents the configuration and associated environment variables for
 // the publish components.
@@ -40,6 +43,7 @@ type Config struct {
 	AuthorizedApp         authorizedapp.Config
 	Database              database.Config
 	SecretManager         secrets.Config
+	KeyManager            keys.Config
 	Verification          verification.Config
 	ObservabilityExporter observability.Config
 
@@ -52,9 +56,10 @@ type Config struct {
 	CreatedAtTruncateWindow      time.Duration `env:"TRUNCATE_WINDOW, default=1h"`
 
 	// Crypto key to use for wrapping/unwrapping the revision token cipher blocks.
-	RevisionTokenKeyID         string `env:"REVISION_TOKEN_KEY_ID"`
-	RevisionKeyWrapperAAD      string `env:"REVISION_KEY_WRAPPER_AAD"` // must be base64 encoded, may come from secret://
-	BypassRevisionCertificates bool   `env:"BYPASS_REVISION_CERTIFICATES, default=false"`
+	RevisionTokenKeyID       string        `env:"REVISION_TOKEN_KEY_ID"`
+	RevisionTokenAAD         string        `env:"REVISION_TOKEN_AAD"` // must be base64 encoded, may come from secret://
+	RevisionKeyCacheDuration time.Duration `env:"REVISION_KEY_CACHE_DURATION, default=1m"`
+	RevisionTokenMinLength   uint          `env:"REVISION_TOKEN_MIN_LENGTH, default=28"`
 
 	// Flags for local development and testing. This will cause still valid keys
 	// to not be embargoed.
@@ -62,6 +67,10 @@ type Config struct {
 	ReleaseSameDayKeys bool `env:"DEBUG_RELEASE_SAME_DAY_KEYS"`
 }
 
+func (c *Config) RevisionTokenADDBytes() ([]byte, error) {
+	return base64util.DecodeString(c.RevisionTokenAAD)
+}
+
 func (c *Config) MaxExposureKeys() uint {
 	return c.MaxKeysOnPublish
 }
@@ -101,3 +110,7 @@ func (c *Config) SecretManagerConfig() *secrets.Config {
 func (c *Config) ObservabilityExporterConfig() *observability.Config {
 	return &c.ObservabilityExporter
 }
+
+func (c *Config) KeyManagerConfig() *keys.Config {
+	return &c.KeyManager
+}
diff --git a/internal/publish/database/exposure.go b/internal/publish/database/exposure.go
index 9e29ed7..95f8c6c 100644
--- a/internal/publish/database/exposure.go
+++ b/internal/publish/database/exposure.go
@@ -25,6 +25,8 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/logging"
+	"github.com/google/exposure-notifications-server/internal/pb"
 	"github.com/google/exposure-notifications-server/internal/publish/model"
 	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
 	"github.com/google/exposure-notifications-server/pkg/base64util"
@@ -326,7 +328,8 @@ func executeReviseExposure(ctx context.Context, tx pgx.Tx, stmtName string, exp
 }
 
 // InsertAndReviseExposures transactionally revises and inserts a set of keys as necessary.
-func (db *PublishDB) InsertAndReviseExposures(ctx context.Context, incoming []*model.Exposure) (int, error) {
+func (db *PublishDB) InsertAndReviseExposures(ctx context.Context, incoming []*model.Exposure, token *pb.RevisionTokenData, tokenRequired bool) (int, error) {
+	logger := logging.FromContext(ctx)
 	updated := 0
 	err := db.db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
 		// Read any existing TEKs FOR UPDATE in this transaction.
@@ -339,6 +342,39 @@ func (db *PublishDB) InsertAndReviseExposures(ctx context.Context, incoming []*m
 			return fmt.Errorf("unable to check for existing records")
 		}
 
+		// See if the revision token is relevant. We only need to check it if keys are being revised.
+		if len(existing) > 0 {
+			// Turn the token data into a map for easy comparison.
+			allowedRevisions := make(map[string]*pb.RevisableKey)
+			if token != nil {
+				for _, rk := range token.RevisableKeys {
+					allowedRevisions[base64.StdEncoding.EncodeToString(rk.TemporaryExposureKey)] = rk
+				}
+			}
+			// Check that any existing exposures are present in the token. It doesn't mater if they
+			// would be materially changed or not (the revise keys steps below)
+			for k, v := range existing {
+				if rk, ok := allowedRevisions[k]; !ok {
+					// user sent in an existing key they they do not have the token for.
+					message := "attempt to revise key not in revision token."
+					logger.Errorf(message)
+					if tokenRequired {
+						return fmt.Errorf(message)
+					}
+				} else {
+					// Validate that the key parameters haven't changed.
+					if v.IntervalNumber != rk.IntervalNumber || v.IntervalCount != rk.IntervalCount {
+						message := "revision token metadata mismatch"
+						logger.Errorf(message)
+						if tokenRequired {
+							return fmt.Errorf(message)
+						}
+					}
+				}
+			}
+			// Revision token is valid for this request, not required, or bypassed.
+		}
+
 		// Run through the merge logic.
 		exposures, err := model.ReviseKeys(ctx, existing, incoming)
 		if err != nil {
diff --git a/internal/publish/publish.go b/internal/publish/publish.go
index 7714eb3..a8b9d7b 100644
--- a/internal/publish/publish.go
+++ b/internal/publish/publish.go
@@ -17,6 +17,8 @@ package publish
 
 import (
 	"context"
+	"encoding/base64"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"net/http"
@@ -27,12 +29,17 @@ import (
 	"github.com/google/exposure-notifications-server/internal/authorizedapp"
 	"github.com/google/exposure-notifications-server/internal/jsonutil"
 	"github.com/google/exposure-notifications-server/internal/logging"
+	"github.com/google/exposure-notifications-server/internal/pb"
 	"github.com/google/exposure-notifications-server/internal/publish/database"
 	"github.com/google/exposure-notifications-server/internal/publish/model"
+	"github.com/google/exposure-notifications-server/internal/revision"
+	revisiondb "github.com/google/exposure-notifications-server/internal/revision/database"
 	"github.com/google/exposure-notifications-server/internal/serverenv"
 	"github.com/google/exposure-notifications-server/internal/verification"
 	verifydb "github.com/google/exposure-notifications-server/internal/verification/database"
 	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
+	"github.com/google/exposure-notifications-server/pkg/base64util"
+	"github.com/mikehelmick/go-chaff"
 )
 
 // NewHandler creates the HTTP handler for the TTK publishing API.
@@ -63,11 +70,31 @@ func NewHandler(ctx context.Context, config *Config, env *serverenv.ServerEnv) (
 		return nil, fmt.Errorf("verification.New: %w", err)
 	}
 
+	aadBytes, err := config.RevisionTokenADDBytes()
+	if err != nil {
+		return nil, fmt.Errorf("must provide ADD for revision token encryption in REVISION_TOKEN_AAD env variable: %w", err)
+	}
+	revisionKeyConfig := revisiondb.KMSConfig{
+		WrapperKeyID: config.RevisionTokenKeyID,
+		KeyManager:   env.GetKeyManager(),
+	}
+	revisionDB, err := revisiondb.New(env.Database(), &revisionKeyConfig)
+	if err != nil {
+		return nil, fmt.Errorf("revisiondb.New: %w", err)
+	}
+	tm, err := revision.New(ctx, revisionDB, config.RevisionKeyCacheDuration, config.RevisionTokenMinLength)
+	if err != nil {
+		return nil, fmt.Errorf("revision.New: %w", err)
+	}
+
 	return &publishHandler{
 		serverenv:             env,
 		transformer:           transformer,
 		config:                config,
 		database:              database.New(env.Database()),
+		tracker:               chaff.New(),
+		tokenManager:          tm,
+		tokenAAD:              aadBytes,
 		authorizedAppProvider: env.AuthorizedAppProvider(),
 		verifier:              verifier,
 	}, nil
@@ -78,16 +105,18 @@ type publishHandler struct {
 	serverenv             *serverenv.ServerEnv
 	transformer           *model.Transformer
 	database              *database.PublishDB
+	tokenManager          *revision.TokenManager
+	tracker               *chaff.Tracker
+	tokenAAD              []byte
 	authorizedAppProvider authorizedapp.Provider
 	verifier              *verification.Verifier
 }
 
 type response struct {
 	status      int
-	message     string
+	pubResponse *verifyapi.PublishResponse
 	metric      string
 	count       int // metricCount
-	errorInProd bool
 }
 
 func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) response {
@@ -102,7 +131,10 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 	if err != nil {
 		message := fmt.Sprintf("error unmarshaling API call, code: %v: %v", code, err)
 		span.SetStatus(trace.Status{Code: trace.StatusCodeInternal, Message: message})
-		return response{status: http.StatusBadRequest, message: message, metric: "publish-bad-json", count: 1}
+		return response{
+			status:      http.StatusBadRequest,
+			pubResponse: &verifyapi.PublishResponse{Error: message},
+			metric:      "publish-bad-json", count: 1}
 	}
 
 	appConfig, err := h.authorizedAppProvider.AppConfig(ctx, data.AppPackageName)
@@ -113,7 +145,9 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 		if errors.Is(err, authorizedapp.ErrAppNotFound) {
 			message := fmt.Sprintf("unauthorized app: %v", data.AppPackageName)
 			span.SetStatus(trace.Status{Code: trace.StatusCodeInternal, Message: message})
-			return response{status: http.StatusUnauthorized, message: message, metric: "publish-app-not-authorized", count: 1}
+			return response{status: http.StatusUnauthorized,
+				pubResponse: &verifyapi.PublishResponse{Error: message},
+				metric:      "publish-app-not-authorized", count: 1}
 		}
 
 		// A higher-level configuration error occurred, likely while trying to read
@@ -125,10 +159,9 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 		span.SetStatus(trace.Status{Code: trace.StatusCodeInternal, Message: message})
 		return response{
 			status:      http.StatusUnauthorized,
-			message:     message,
+			pubResponse: &verifyapi.PublishResponse{Error: message},
 			metric:      "publish-error-loading-authorizedapp",
 			count:       1,
-			errorInProd: true,
 		}
 	}
 
@@ -139,10 +172,10 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 			message := fmt.Sprintf("verifying allowed regions: %v", err)
 			span.SetStatus(trace.Status{Code: trace.StatusCodePermissionDenied, Message: message})
 			return response{
-				status:  http.StatusUnauthorized,
-				message: message,
-				metric:  "publish-region-not-authorized",
-				count:   1,
+				status:      http.StatusUnauthorized,
+				pubResponse: &verifyapi.PublishResponse{Error: message},
+				metric:      "publish-region-not-authorized",
+				count:       1,
 			}
 		}
 	}
@@ -156,7 +189,25 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 		} else {
 			message := fmt.Sprintf("unable to validate diagnosis verification: %v", err)
 			span.SetStatus(trace.Status{Code: trace.StatusCodeInvalidArgument, Message: message})
-			return response{status: http.StatusUnauthorized, message: message, metric: "publish-bad-verification", count: 1}
+			return response{
+				status:      http.StatusUnauthorized,
+				pubResponse: &verifyapi.PublishResponse{Error: message},
+				metric:      "publish-bad-verification", count: 1}
+		}
+	}
+
+	// Examine the revision token.
+	var token *pb.RevisionTokenData
+	if len(data.RevisionToken) != 0 {
+		encryptedToken, err := base64util.DecodeString(data.RevisionToken)
+		if err != nil {
+			logger.Errorf("unable to decode revision token, proceeding without: %v", err)
+		} else {
+			token, err = h.tokenManager.UnmarshalRevisionToken(ctx, encryptedToken, h.tokenAAD)
+			if err != nil {
+				logger.Errorf("unable to unmarshall / descrypt revision token: %v", err)
+				token = nil // just in case.
+			}
 		}
 	}
 
@@ -166,39 +217,73 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 		message := fmt.Sprintf("unable to read request data: %v", err)
 		logger.Errorf(message)
 		span.SetStatus(trace.Status{Code: trace.StatusCodeInvalidArgument, Message: message})
-		return response{status: http.StatusBadRequest, message: message, metric: "publish-transform-fail", count: 1}
+		return response{
+			status:      http.StatusBadRequest,
+			pubResponse: &verifyapi.PublishResponse{Error: message},
+			metric:      "publish-transform-fail", count: 1}
 	}
 
-	n, err := h.database.InsertAndReviseExposures(ctx, exposures)
+	n, err := h.database.InsertAndReviseExposures(ctx, exposures, token, appConfig.BypassRevisionToken)
 	if err != nil {
 		message := fmt.Sprintf("error writing exposure record: %v", err)
 		logger.Errorf(message)
 		span.SetStatus(trace.Status{Code: trace.StatusCodeInternal, Message: message})
-		return response{status: http.StatusInternalServerError, message: http.StatusText(http.StatusInternalServerError), metric: "publish-db-write-error", count: 1}
+		return response{
+			status:      http.StatusInternalServerError,
+			pubResponse: &verifyapi.PublishResponse{Error: http.StatusText(http.StatusInternalServerError)},
+			metric:      "publish-db-write-error", count: 1}
+	}
+
+	// Build the new revision token. Union of existing token take + new exposures.
+	var keep pb.RevisionTokenData
+	if token != nil {
+		// put existing tokens that aren't too old back in the token.
+		retainInterval := model.IntervalNumber(batchTime.Add(-1 * h.config.MaxIntervalAge))
+		for _, rk := range token.RevisableKeys {
+			if rk.IntervalNumber+rk.IntervalCount >= retainInterval {
+				keep.RevisableKeys = append(keep.RevisableKeys, rk)
+			}
+		}
+	}
+	newToken, err := h.tokenManager.MakeRevisionToken(ctx, &keep, exposures, h.tokenAAD)
+	if err != nil {
+		logger.Errorf("unable to make new revision token: %v", err)
 	}
 
 	message := fmt.Sprintf("Inserted %d exposures.", n)
 	span.AddAttributes(trace.Int64Attribute("inserted_exposures", int64(n)))
 	logger.Info(message)
 	return response{
-		status:  http.StatusOK,
-		message: message,
-		metric:  "publish-exposures-written",
-		count:   len(exposures),
+		status: http.StatusOK,
+		pubResponse: &verifyapi.PublishResponse{
+			RevisionToken:     base64.StdEncoding.EncodeToString(newToken),
+			InsertedExposures: n,
+		},
+		metric: "publish-exposures-written",
+		count:  n,
 	}
 }
 
-// There is a target normalized latency for this function. This is to help prevent
-// clients from being able to distinguish from successful or errored requests.
+// ServeHTTP handles the publish event. It tracks requests and can handle chaff
+// requests when provided a request with the X-Chaff header.
 func (h *publishHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	response := h.handleRequest(w, r)
+	h.tracker.HandleTrack(chaff.HeaderDetector("X-Chaff"),
+		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			response := h.handleRequest(w, r)
 
-	if response.metric != "" {
-		ctx := r.Context()
-		metrics := h.serverenv.MetricsExporter(ctx)
-		metrics.WriteInt(response.metric, true, response.count)
-	}
+			if response.metric != "" {
+				ctx := r.Context()
+				metrics := h.serverenv.MetricsExporter(ctx)
+				metrics.WriteInt(response.metric, true, response.count)
+			}
 
-	w.WriteHeader(response.status)
-	fmt.Fprint(w, response.message)
+			data, err := json.Marshal(response.pubResponse)
+			if err != nil {
+				w.WriteHeader(http.StatusInternalServerError)
+				fmt.Fprintf(w, "{\"error\": \"%v\"}", err.Error())
+				return
+			}
+			w.WriteHeader(response.status)
+			fmt.Fprintf(w, "%s", data)
+		})).ServeHTTP(w, r)
 }
diff --git a/internal/revision/database/revision.go b/internal/revision/database/revision.go
index 994c0c7..5f2f0e4 100644
--- a/internal/revision/database/revision.go
+++ b/internal/revision/database/revision.go
@@ -60,22 +60,9 @@ func (r *RevisionKey) KeyIDString() string {
 // given KeyManager for wrapping/unwrapping keys.
 type KMSConfig struct {
 	WrapperKeyID string
-	WrapperAAD   []byte
 	KeyManager   keys.KeyManager
 }
 
-// Copy creates a deep copy of the KMSConfig
-// This is done to ensure that we have a clean copy of the ADD slice that can't be changed externally.
-func (k *KMSConfig) Copy() *KMSConfig {
-	c := KMSConfig{
-		WrapperKeyID: k.WrapperKeyID,
-		WrapperAAD:   make([]byte, 0, len(k.WrapperAAD)),
-		KeyManager:   k.KeyManager,
-	}
-	copy(c.WrapperAAD, k.WrapperAAD)
-	return &c
-}
-
 // RevisionDB wraps a database connection and provides functions for interacting with revision keys.
 type RevisionDB struct {
 	db     *database.DB
@@ -87,15 +74,12 @@ func New(db *database.DB, c *KMSConfig) (*RevisionDB, error) {
 	if c.WrapperKeyID == "" {
 		return nil, fmt.Errorf("no KMS key ID passed in to revision.New")
 	}
-	if len(c.WrapperAAD) == 0 {
-		return nil, fmt.Errorf("no AAD provided")
-	}
 	if c.KeyManager == nil {
 		return nil, fmt.Errorf("no KeyManager provided")
 	}
 	return &RevisionDB{
 		db:     db,
-		config: c.Copy(),
+		config: c,
 	}, nil
 }
 
@@ -220,7 +204,7 @@ func (rdb *RevisionDB) GetAllowedRevisionKeys(ctx context.Context) (int64, []*Re
 	unwrappedKeys := make([]*RevisionKey, 0, len(keys))
 	// Attempt to unwrap all of the keys
 	for _, wk := range keys {
-		unwrapped, err := rdb.decrypt(ctx, wk.WrappedCipher)
+		unwrapped, err := rdb.decrypt(ctx, wk.WrappedCipher, wk.AAD)
 		if err != nil {
 			logger.Errorf("still allowed revision key that can't be unwrapped: kid: %v error: %v", wk.KeyID, err)
 			return 0, nil, fmt.Errorf("unable to unwrap revision key: %w", err)
@@ -271,7 +255,7 @@ func (rdb *RevisionDB) GetEffectiveRevisionKey(ctx context.Context) (*RevisionKe
 	}
 
 	// Unwrap the DEK w/ the KeyManager.
-	unwrapped, err := rdb.decrypt(ctx, revKey.WrappedCipher)
+	unwrapped, err := rdb.decrypt(ctx, revKey.WrappedCipher, revKey.AAD)
 	if err != nil {
 		return nil, fmt.Errorf("unable to unwrap key: %w", err)
 	}
@@ -280,8 +264,8 @@ func (rdb *RevisionDB) GetEffectiveRevisionKey(ctx context.Context) (*RevisionKe
 	return revKey, nil
 }
 
-func (rdb *RevisionDB) decrypt(ctx context.Context, ciphertext []byte) ([]byte, error) {
-	return rdb.config.KeyManager.Decrypt(ctx, rdb.config.WrapperKeyID, ciphertext, rdb.config.WrapperAAD)
+func (rdb *RevisionDB) decrypt(ctx context.Context, ciphertext []byte, aad []byte) ([]byte, error) {
+	return rdb.config.KeyManager.Decrypt(ctx, rdb.config.WrapperKeyID, ciphertext, aad)
 }
 
 // CreateRevisionKey generates a new AES key and wraps it
@@ -296,7 +280,7 @@ func (rdb *RevisionDB) CreateRevisionKey(ctx context.Context) (*RevisionKey, err
 	}
 
 	// Wrap the key using the configured KMS.
-	wrapped, err := rdb.config.KeyManager.Encrypt(ctx, rdb.config.WrapperKeyID, key, rdb.config.WrapperAAD)
+	wrapped, err := rdb.config.KeyManager.Encrypt(ctx, rdb.config.WrapperKeyID, key, aad)
 	if err != nil {
 		return nil, fmt.Errorf("failed to wrap key: %w", err)
 	}
diff --git a/internal/revision/revision.go b/internal/revision/revision.go
index ab20467..9b16df5 100644
--- a/internal/revision/revision.go
+++ b/internal/revision/revision.go
@@ -21,9 +21,9 @@ import (
 	"crypto/aes"
 	"crypto/cipher"
 	"crypto/rand"
+	"encoding/base64"
 	"fmt"
 	"io"
-	"sort"
 	"strconv"
 	"sync"
 	"time"
@@ -35,6 +35,15 @@ import (
 	"google.golang.org/protobuf/proto"
 )
 
+var (
+	// Used for padding only.
+	zeroTEK = pb.RevisableKey{
+		TemporaryExposureKey: make([]byte, 16),
+		IntervalCount:        0,
+		IntervalNumber:       0,
+	}
+)
+
 // TokenManager is responsible for creating and unlocking revision tokens.
 type TokenManager struct {
 	db *database.RevisionDB
@@ -48,6 +57,10 @@ type TokenManager struct {
 	// A poitners to the currently active key for encryption purposes.
 	effective *database.RevisionKey
 
+	// Pads tokens so that the size of the token can't be used to determine how many keys
+	// are held within.
+	minTokenSize int
+
 	// The allowed/effective keys are cached to avoid excessive decrypt calls to the KMS system to unwrap
 	// the individual revision keys.
 	// A cache refresh is initially a shallow refresh, if the IDs of allowed/effective keys haven't changed,
@@ -59,7 +72,7 @@ type TokenManager struct {
 
 // New creates a new TokenManager that uses a database handle to manage a cache
 // of allowed revision keys.
-func New(ctx context.Context, db *database.RevisionDB, cacheDuration time.Duration) (*TokenManager, error) {
+func New(ctx context.Context, db *database.RevisionDB, cacheDuration time.Duration, minTokenSize uint) (*TokenManager, error) {
 	if cacheDuration > 60*time.Minute {
 		return nil, fmt.Errorf("cache duration must be <= 60 minutes, got: %v", cacheDuration)
 	}
@@ -67,6 +80,7 @@ func New(ctx context.Context, db *database.RevisionDB, cacheDuration time.Durati
 	tm := &TokenManager{
 		db:                db,
 		allowed:           make(map[int64]*database.RevisionKey),
+		minTokenSize:      int(minTokenSize),
 		cacheDuration:     cacheDuration,
 		cacheRefreshAfter: now.Add(-2 * cacheDuration),
 	}
@@ -113,8 +127,16 @@ func (tm *TokenManager) maybeRefreshCache(ctx context.Context) error {
 		return fmt.Errorf("reading revision key cache: %w", err)
 	}
 
+	// To aid in system upgrade, assuming env vars are setup correctly,
+	// we autocreate the first wrapped revision key.
 	if len(allowed) == 0 {
-		return fmt.Errorf("no revision keys exist")
+		logger.Errorf("no revision keys exist - creating one.")
+		if rk, err := tm.db.CreateRevisionKey(ctx); err != nil {
+			return fmt.Errorf("unable to bootstrap reivion keys: %w", err)
+		} else {
+			allowed = append(allowed, rk)
+			effectiveID = rk.KeyID
+		}
 	}
 
 	for _, rk := range allowed {
@@ -158,16 +180,14 @@ func (tm *TokenManager) isReloadNeeded(ctx context.Context) (bool, error) {
 	return false, nil
 }
 
-func buildTokenBufer(eKeys []*model.Exposure) *pb.RevisionTokenData {
-	// sort the keys.
-	sort.Slice(eKeys, func(i, j int) bool {
-		return eKeys[i].ExposureKeyBase64() < eKeys[j].ExposureKeyBase64()
-	})
+func buildTokenBufer(previous *pb.RevisionTokenData, eKeys []*model.Exposure) *pb.RevisionTokenData {
 	// Build the protocol buffer version of the revision token data.
 	tokenData := pb.RevisionTokenData{
 		RevisableKeys: make([]*pb.RevisableKey, 0, len(eKeys)),
 	}
+	got := make(map[string]struct{})
 	for _, k := range eKeys {
+		got[k.ExposureKeyBase64()] = struct{}{}
 		pbKey := pb.RevisableKey{
 			TemporaryExposureKey: make([]byte, len(k.ExposureKey)),
 			IntervalNumber:       k.IntervalNumber,
@@ -176,13 +196,21 @@ func buildTokenBufer(eKeys []*model.Exposure) *pb.RevisionTokenData {
 		copy(pbKey.TemporaryExposureKey, k.ExposureKey)
 		tokenData.RevisableKeys = append(tokenData.RevisableKeys, &pbKey)
 	}
+	// Add in previous keys that weren't also in the new exposures
+	if previous != nil {
+		for _, rk := range previous.RevisableKeys {
+			if _, ok := got[base64.StdEncoding.EncodeToString(rk.TemporaryExposureKey)]; !ok {
+				tokenData.RevisableKeys = append(tokenData.RevisableKeys, rk)
+			}
+		}
+	}
 	return &tokenData
 }
 
 // MakeRevisionToken turns the TEK data from a given publish request
 // into an encrypted protocol buffer revision token.
 // This is using envelope encryption, based on the currently active revision key.
-func (tm *TokenManager) MakeRevisionToken(ctx context.Context, eKeys []*model.Exposure, aad []byte) ([]byte, error) {
+func (tm *TokenManager) MakeRevisionToken(ctx context.Context, previous *pb.RevisionTokenData, eKeys []*model.Exposure, aad []byte) ([]byte, error) {
 	if len(eKeys) == 0 {
 		return nil, fmt.Errorf("no keys to build token for")
 	}
@@ -200,7 +228,12 @@ func (tm *TokenManager) MakeRevisionToken(ctx context.Context, eKeys []*model.Ex
 		tm.mu.RUnlock()
 	}
 
-	tokenData := buildTokenBufer(eKeys)
+	tokenData := buildTokenBufer(previous, eKeys)
+	// Padd the revisable keys out w/ the zero key.
+	for len(tokenData.RevisableKeys) < tm.minTokenSize {
+		tokenData.RevisableKeys = append(tokenData.RevisableKeys, &zeroTEK)
+	}
+
 	plaintext, err := proto.Marshal(tokenData)
 	if err != nil {
 		return nil, fmt.Errorf("unable to masrhal token data: %w", err)
@@ -288,10 +321,18 @@ func (tm *TokenManager) UnmarshalRevisionToken(ctx context.Context, tokenBytes [
 	}
 
 	// The plaintext is a pb.RevisionTokenData
-	var tokenData pb.RevisionTokenData
-	if err := proto.Unmarshal(plaintext, &tokenData); err != nil {
+	var paddedTokenData pb.RevisionTokenData
+	if err := proto.Unmarshal(plaintext, &paddedTokenData); err != nil {
 		return nil, fmt.Errorf("faield to unmarshal token data: %w", err)
 	}
 
+	var tokenData pb.RevisionTokenData
+	for _, rk := range paddedTokenData.RevisableKeys {
+		if rk.IntervalNumber == 0 && rk.IntervalCount == 0 {
+			continue
+		}
+		tokenData.RevisableKeys = append(tokenData.RevisableKeys, rk)
+	}
+
 	return &tokenData, nil
 }
diff --git a/internal/serverenv/env.go b/internal/serverenv/env.go
index 3928b9b..18a2372 100644
--- a/internal/serverenv/env.go
+++ b/internal/serverenv/env.go
@@ -142,6 +142,10 @@ func (s *ServerEnv) ObservabilityExporter() observability.Exporter {
 	return s.observabilityExporter
 }
 
+func (s *ServerEnv) GetKeyManager() keys.KeyManager {
+	return s.keyManager
+}
+
 // GetSignerForKey returns the crypto.Singer implementation to use based on the installed KeyManager.
 // If there is no KeyManager installed, this returns an error.
 func (s *ServerEnv) GetSignerForKey(ctx context.Context, keyName string) (crypto.Signer, error) {
diff --git a/pkg/api/v1alpha1/exposure_types.go b/pkg/api/v1alpha1/exposure_types.go
index 18be584..8b492f7 100644
--- a/pkg/api/v1alpha1/exposure_types.go
+++ b/pkg/api/v1alpha1/exposure_types.go
@@ -85,9 +85,14 @@ type Publish struct {
 // passed back if the same devices wishes to publish TEKs again.
 //
 // On error, the error field will contain the error details.
+//
+// The Padding field may be populated with random data on both success and
+// error responses.
 type PublishResponse struct {
-	RevisionToken string `json:"revisionToken"`
-	Error         string `json:"error"`
+	RevisionToken     string `json:"revisionToken"`
+	InsertedExposures int    `json:"insertedExposures"`
+	Error             string `json:"error"`
+	Padding           string `json:"padding"`
 }
 
 // ExposureKey is the 16 byte key, the start time of the key and the
diff --git a/pkg/keys/config.go b/pkg/keys/config.go
index bd79bd6..788ca00 100644
--- a/pkg/keys/config.go
+++ b/pkg/keys/config.go
@@ -22,7 +22,7 @@ const (
 	KeyManagerTypeAzureKeyVault  KeyManagerType = "AZURE_KEY_VAULT"
 	KeyManagerTypeGoogleCloudKMS KeyManagerType = "GOOGLE_CLOUD_KMS"
 	KeyManagerTypeHashiCorpVault KeyManagerType = "HASHICORP_VAULT"
-	KeyManagerTypeNoop           KeyManagerType = "NOOP"
+	KeyManagerTypeInMemory       KeyManagerType = "IN_MEMORY"
 )
 
 // Config defines configuration.
diff --git a/pkg/keys/keys.go b/pkg/keys/keys.go
index 8487ebd..1ae1e93 100644
--- a/pkg/keys/keys.go
+++ b/pkg/keys/keys.go
@@ -59,8 +59,8 @@ func KeyManagerFor(ctx context.Context, typ KeyManagerType) (KeyManager, error)
 		return NewGoogleCloudKMS(ctx)
 	case KeyManagerTypeHashiCorpVault:
 		return NewHashiCorpVault(ctx)
-	case KeyManagerTypeNoop:
-		return NewNoop(ctx)
+	case KeyManagerTypeInMemory:
+		return NewInMemory(ctx)
 	}
 
 	return nil, fmt.Errorf("unknown key manager type: %v", typ)
diff --git a/pkg/keys/noop.go b/pkg/keys/noop.go
deleted file mode 100644
index 7dfd02a..0000000
--- a/pkg/keys/noop.go
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright 2020 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package keys
-
-import (
-	"context"
-	"crypto"
-	"fmt"
-)
-
-// Compile-time check to verify implements interface.
-var _ KeyManager = (*Noop)(nil)
-
-// Noop is a key manager that does nothing and always returns an error.
-type Noop struct{}
-
-func NewNoop(ctx context.Context) (KeyManager, error) {
-	return &Noop{}, nil
-}
-
-func (n *Noop) NewSigner(ctx context.Context, keyID string) (crypto.Signer, error) {
-	return nil, fmt.Errorf("noop cannot sign")
-}
-
-func (n *Noop) Encrypt(ctx context.Context, keyID string, plaintext []byte, aad []byte) ([]byte, error) {
-	return plaintext, nil
-}
-
-func (n *Noop) Decrypt(ctx context.Context, keyID string, ciphertext []byte, aad []byte) ([]byte, error) {
-	return ciphertext, nil
-}
diff --git a/scripts/performance.sh b/scripts/performance.sh
index a732e76..9e6f15e 100755
--- a/scripts/performance.sh
+++ b/scripts/performance.sh
@@ -29,6 +29,9 @@ if [[ -z "${GOOGLE_APPLICATION_CREDENTIALS:-}" ]]; then
 fi
 
 echo "🔨 Start mako microservice"
+gcloud auth activate-service-account --key-file=${GOOGLE_APPLICATION_CREDENTIALS}
+gcloud auth configure-docker
+
 docker \
   run \
   --rm \
diff --git a/terraform/key_management.tf b/terraform/key_management.tf
index 0932760..63c1655 100644
--- a/terraform/key_management.tf
+++ b/terraform/key_management.tf
@@ -31,3 +31,27 @@ resource "google_kms_crypto_key" "export-signer" {
     protection_level = "HSM"
   }
 }
+
+resource "google_kms_key_ring" "revision-tokens" {
+  project  = data.google_project.project.project_id
+  name     = "revision-tokens"
+  location = var.kms_location
+
+  depends_on = [
+    google_project_service.services["cloudkms.googleapis.com"],
+  ]
+}
+
+resource "google_kms_crypto_key" "token-key" {
+  key_ring = google_kms_key_ring.revision-tokens.self_link
+  name     = "token-key"
+  purpose  = "ENCRYPT_DECRYPT"
+  version_template {
+    algorithm        = "GOOGLE_SYMMETRIC_ENCRYPTION"
+    protection_level = "HSM"
+  }
+}
+
+data "google_kms_crypto_key_version" "token_key_version" {
+  crypto_key = google_kms_crypto_key.token-key.self_link
+}
\ No newline at end of file
diff --git a/terraform/secrets.tf b/terraform/secrets.tf
new file mode 100644
index 0000000..468c4e6
--- /dev/null
+++ b/terraform/secrets.tf
@@ -0,0 +1,40 @@
+# 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 necessary generated secretes
+#
+
+resource "random_id" "revision_token_aad" {
+  byte_length = 32
+}
+
+resource "google_secret_manager_secret" "revision_token_aad" {
+  provider = google-beta
+
+  secret_id = "revision-token-aad"
+  replication {
+    automatic = true
+  }
+  depends_on = [
+    google_project_service.services["secretmanager.googleapis.com"],
+  ]
+}
+
+resource "google_secret_manager_secret_version" "revision_token_aad_secret_version" {
+  provider = google-beta
+
+  secret      = google_secret_manager_secret.revision_token_aad.id
+  secret_data = random_id.revision_token_aad.b64_std
+}
diff --git a/terraform/service_exposure.tf b/terraform/service_exposure.tf
index c8ed821..0be0323 100644
--- a/terraform/service_exposure.tf
+++ b/terraform/service_exposure.tf
@@ -47,6 +47,20 @@ resource "google_secret_manager_secret_iam_member" "exposure-db" {
   member    = "serviceAccount:${google_service_account.exposure.email}"
 }
 
+resource "google_secret_manager_secret_iam_member" "revision-token-aad" {
+  provider = google-beta
+
+  secret_id = google_secret_manager_secret.revision_token_aad.id
+  role      = "roles/secretmanager.secretAccessor"
+  member    = "serviceAccount:${google_service_account.exposure.email}"
+}
+
+resource "google_kms_key_ring_iam_member" "revision-tokens-encrypt-decrypt" {
+  key_ring_id = google_kms_key_ring.revision-tokens.self_link
+  role        = "roles/cloudkms.cryptoKeyEncrypterDecrypter"
+  member      = "serviceAccount:${google_service_account.exposure.email}"
+}
+
 resource "google_project_iam_member" "exposure-observability" {
   for_each = toset([
     "roles/cloudtrace.agent",
@@ -93,6 +107,15 @@ resource "google_cloud_run_service" "exposure" {
             value = env.value
           }
         }
+
+        env {
+          name = "REVISION_TOKEN_KEY_ID"
+          value = google_kms_crypto_key.token-key.self_link
+        }
+        env {
+          name = "REVISION_TOKEN_AAD"
+          value = "secret://${google_secret_manager_secret_version.revision_token_aad_secret_version.id}"
+        }
       }
     }
 


Test patch:
diff --git a/docker/presubmit-test.dockerfile b/docker/presubmit-test.dockerfile
index 90b24d5..e1799ea 100644
--- a/docker/presubmit-test.dockerfile
+++ b/docker/presubmit-test.dockerfile
@@ -19,6 +19,21 @@ FROM golang:1.14
 # Install sudo
 RUN apt-get update -yqq && apt-get install -yqq sudo
 
+# Install gcloud
+ENV PATH=/google-cloud-sdk/bin:/workspace:${PATH} \
+    CLOUDSDK_CORE_DISABLE_PROMPTS=1
+
+RUN wget -q https://dl.google.com/dl/cloudsdk/channels/rapid/google-cloud-sdk.tar.gz && \
+    tar xzf google-cloud-sdk.tar.gz -C / && \
+    rm google-cloud-sdk.tar.gz && \
+    /google-cloud-sdk/install.sh \
+        --disable-installation-options \
+        --bash-completion=false \
+        --path-update=false \
+        --usage-reporting=false && \
+    gcloud components install alpha beta kubectl && \
+    gcloud info | tee /workspace/gcloud-info.txt
+
 #
 # BEGIN: DOCKER IN DOCKER SETUP
 #
diff --git a/internal/export/database/export_test.go b/internal/export/database/export_test.go
index 21d25de..0b2ba7d 100644
--- a/internal/export/database/export_test.go
+++ b/internal/export/database/export_test.go
@@ -482,7 +482,7 @@ func TestKeysInBatch(t *testing.T) {
 	}
 
 	// Add the keys to the database.
-	if _, err := publishdb.New(testDB).InsertAndReviseExposures(ctx, []*publishmodel.Exposure{sek, eek}); err != nil {
+	if _, err := publishdb.New(testDB).InsertAndReviseExposures(ctx, []*publishmodel.Exposure{sek, eek}, nil, true); err != nil {
 		t.Fatal(err)
 	}
 
diff --git a/internal/federationin/federationin_test.go b/internal/federationin/federationin_test.go
index 699688f..0829bf4 100644
--- a/internal/federationin/federationin_test.go
+++ b/internal/federationin/federationin_test.go
@@ -70,7 +70,7 @@ type publishDB struct {
 	exposures []*publishmodel.Exposure
 }
 
-func (idb *publishDB) insertExposures(ctx context.Context, exposures []*publishmodel.Exposure) (int, error) {
+func (idb *publishDB) insertExposures(ctx context.Context, exposures []*publishmodel.Exposure, rt *pb.RevisionTokenData, enforce bool) (int, error) {
 	idb.exposures = append(idb.exposures, exposures...)
 	return len(exposures), nil
 }
diff --git a/internal/publish/database/exposure_test.go b/internal/publish/database/exposure_test.go
index f68ae51..9963668 100644
--- a/internal/publish/database/exposure_test.go
+++ b/internal/publish/database/exposure_test.go
@@ -23,6 +23,7 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/pb"
 	"github.com/google/exposure-notifications-server/internal/publish/model"
 	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
 	pgx "github.com/jackc/pgx/v4"
@@ -63,7 +64,7 @@ func TestReadExposures(t *testing.T) {
 			LocalProvenance: true,
 		},
 	}
-	if _, err := testPublishDB.InsertAndReviseExposures(ctx, exposures); err != nil {
+	if _, err := testPublishDB.InsertAndReviseExposures(ctx, exposures, nil, true); err != nil {
 		t.Fatal(err)
 	}
 
@@ -142,7 +143,7 @@ func TestExposures(t *testing.T) {
 			LocalProvenance: false,
 		},
 	}
-	if _, err := testPublishDB.InsertAndReviseExposures(ctx, exposures); err != nil {
+	if _, err := testPublishDB.InsertAndReviseExposures(ctx, exposures, nil, true); err != nil {
 		t.Fatal(err)
 	}
 
@@ -236,8 +237,10 @@ func TestReviseExposures(t *testing.T) {
 	createdAt := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Hour)
 	revisedAt := time.Now().UTC().Truncate(time.Hour)
 
+	existingTEK := []byte{1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4}
+
 	existingExp := &model.Exposure{
-		ExposureKey:      []byte{1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4},
+		ExposureKey:      existingTEK,
 		TransmissionRisk: verifyapi.TransmissionRiskClinical,
 		AppPackageName:   "foo.bar",
 		Regions:          []string{"US", "CA", "MX"},
@@ -248,7 +251,7 @@ func TestReviseExposures(t *testing.T) {
 		ReportType:       verifyapi.ReportTypeClinical,
 	}
 	existingExp.SetDaysSinceSymptomOnset(0)
-	if n, err := pubDB.InsertAndReviseExposures(ctx, []*model.Exposure{existingExp}); err != nil {
+	if n, err := pubDB.InsertAndReviseExposures(ctx, []*model.Exposure{existingExp}, nil, true); err != nil {
 		t.Fatalf("error inserting exposures: %v", err)
 	} else if n != 1 {
 		t.Fatalf("wrong number of changed exposures, want: 1, got: %v", n)
@@ -256,7 +259,7 @@ func TestReviseExposures(t *testing.T) {
 
 	// Modify the existing on in place.
 	revisedExp := &model.Exposure{
-		ExposureKey:      []byte{1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4},
+		ExposureKey:      existingTEK,
 		TransmissionRisk: verifyapi.TransmissionRiskConfirmedStandard,
 		AppPackageName:   "foo.bar",
 		Regions:          []string{"US", "CA", "MX"},
@@ -267,8 +270,9 @@ func TestReviseExposures(t *testing.T) {
 		ReportType:       verifyapi.ReportTypeConfirmed,
 	}
 	// Add a new TEK to insert.
+	newTEK := []byte{1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 5}
 	newExp := &model.Exposure{
-		ExposureKey:      []byte{1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 5},
+		ExposureKey:      newTEK,
 		TransmissionRisk: verifyapi.TransmissionRiskClinical,
 		AppPackageName:   "foo.bar",
 		Regions:          []string{"US", "CA", "MX"},
@@ -281,7 +285,23 @@ func TestReviseExposures(t *testing.T) {
 	newExp.SetDaysSinceSymptomOnset(1)
 
 	revisions := []*model.Exposure{revisedExp, newExp}
-	if n, err := pubDB.InsertAndReviseExposures(ctx, revisions); err != nil {
+	// In the first pass - try to revise the key without the necessary revision token.
+	wantError := "attempt to revise key not in revision token"
+	if _, err := pubDB.InsertAndReviseExposures(ctx, revisions, nil, true); err == nil {
+		t.Fatalf("expected error revising without token data")
+	} else if !strings.Contains(err.Error(), wantError) {
+		t.Fatalf("wrong error, want: '%v' got: %v", wantError, err)
+	}
+
+	// Revision token that allows revision of the revised key.
+	var token pb.RevisionTokenData
+	token.RevisableKeys = append(token.RevisableKeys, &pb.RevisableKey{
+		TemporaryExposureKey: existingTEK,
+		IntervalNumber:       100,
+		IntervalCount:        144,
+	})
+
+	if n, err := pubDB.InsertAndReviseExposures(ctx, revisions, &token, true); err != nil {
 		t.Fatalf("error revising exposures: %v", err)
 	} else if n != 2 {
 		t.Fatalf("wrong number of changed exposures, want: 2, got: %v", n)
@@ -315,8 +335,14 @@ func TestReviseExposures(t *testing.T) {
 		}
 	}
 
+	token.RevisableKeys = append(token.RevisableKeys, &pb.RevisableKey{
+		TemporaryExposureKey: newTEK,
+		IntervalNumber:       244,
+		IntervalCount:        144,
+	})
+
 	// Attempt to revise the already revised key.
-	if _, err := pubDB.InsertAndReviseExposures(ctx, []*model.Exposure{revisedExp}); err == nil {
+	if _, err := pubDB.InsertAndReviseExposures(ctx, []*model.Exposure{revisedExp}, &token, true); err == nil {
 		t.Fatalf("expected error on revising already revised key, got nil")
 	} else if !strings.Contains(err.Error(), "key has already been revised ") {
 		t.Fatalf("wrong error, want 'invalid key revision request', got: %v", err)
@@ -348,7 +374,7 @@ func TestIterateExposuresCursor(t *testing.T) {
 			Regions:        []string{"MX", "CA"},
 		},
 	}
-	if _, err := testPublishDB.InsertAndReviseExposures(ctx, exposures); err != nil {
+	if _, err := testPublishDB.InsertAndReviseExposures(ctx, exposures, nil, true); err != nil {
 		t.Fatal(err)
 	}
 	// Iterate over them, canceling the context in the middle.
diff --git a/internal/publish/publish_test.go b/internal/publish/publish_test.go
index d1e4006..07dbd57 100644
--- a/internal/publish/publish_test.go
+++ b/internal/publish/publish_test.go
@@ -25,6 +25,7 @@ import (
 	"encoding/json"
 	"encoding/pem"
 	"io/ioutil"
+	"log"
 	"net/http"
 	"net/http/httptest"
 	"sort"
@@ -36,13 +37,17 @@ import (
 	aadb "github.com/google/exposure-notifications-server/internal/authorizedapp/database"
 	aamodel "github.com/google/exposure-notifications-server/internal/authorizedapp/model"
 	coredb "github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/pb"
 	pubdb "github.com/google/exposure-notifications-server/internal/publish/database"
 	"github.com/google/exposure-notifications-server/internal/publish/model"
+	"github.com/google/exposure-notifications-server/internal/revision"
+	revisiondb "github.com/google/exposure-notifications-server/internal/revision/database"
 	"github.com/google/exposure-notifications-server/internal/serverenv"
 	"github.com/google/exposure-notifications-server/internal/util"
 	verdb "github.com/google/exposure-notifications-server/internal/verification/database"
 	vermodel "github.com/google/exposure-notifications-server/internal/verification/model"
 	"github.com/google/exposure-notifications-server/pkg/base64util"
+	"github.com/google/exposure-notifications-server/pkg/keys"
 
 	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
 	utils "github.com/google/exposure-notifications-server/pkg/verification"
@@ -337,6 +342,27 @@ func TestPublishWithBypass(t *testing.T) {
 			testDB := coredb.NewTestDatabase(t)
 			ctx := context.Background()
 
+			// Make key manager
+			kms, err := keys.NewInMemory(ctx)
+			if err != nil {
+				t.Fatalf("can't make kms: %v", err)
+			}
+			keyID := "rev" + tc.Name
+			kms.AddEncryptionKey(keyID)
+			tokenAAD := make([]byte, 16)
+			if _, err := rand.Read(tokenAAD); err != nil {
+				t.Fatalf("not enough entropy: %v", err)
+			}
+			aad := base64.StdEncoding.EncodeToString(tokenAAD)
+			// Configure revision keys.
+			revDB, err := revisiondb.New(testDB, &revisiondb.KMSConfig{WrapperKeyID: keyID, KeyManager: kms})
+			if err != nil {
+				t.Fatalf("unable to create revision DB handle: %v", err)
+			}
+			if _, err := revDB.CreateRevisionKey(ctx); err != nil {
+				t.Fatalf("unable to create revision key: %v", err)
+			}
+
 			// And set up publish handler up front.
 			config := Config{}
 			config.AuthorizedApp.CacheDuration = time.Nanosecond
@@ -348,9 +374,12 @@ func TestPublishWithBypass(t *testing.T) {
 			if err != nil {
 				t.Fatal(err)
 			}
+			config.RevisionTokenAAD = aad
+			config.RevisionTokenKeyID = keyID
 			env := serverenv.New(ctx,
 				serverenv.WithDatabase(testDB),
-				serverenv.WithAuthorizedAppProvider(aaProvider))
+				serverenv.WithAuthorizedAppProvider(aaProvider),
+				serverenv.WithKeyManager(kms))
 			// Some config overrides for test.
 			handler, err := NewHandler(ctx, &config, env)
 			if err != nil {
@@ -423,13 +452,20 @@ func TestPublishWithBypass(t *testing.T) {
 			}
 
 			// For non success status, check that they body contains the expected message
+			defer resp.Body.Close()
 			respBytes, err := ioutil.ReadAll(resp.Body)
 			if err != nil {
 				t.Fatal(err)
 			}
-			respBody := string(respBytes)
+
+			log.Printf("\n\n%#v\n\n", string(respBytes))
+
+			var response verifyapi.PublishResponse
+			if err := json.Unmarshal(respBytes, &response); err != nil {
+				t.Fatalf("unable to unmarshal response body: %v; data: %v", err, string(respBytes))
+			}
 			if resp.StatusCode != tc.Code {
-				t.Fatalf("http response code want: %v, got %v. Extended: %v", tc.Code, resp.StatusCode, respBody)
+				t.Fatalf("http response code want: %v, got %v.", tc.Code, resp.StatusCode)
 			}
 
 			if resp.StatusCode == http.StatusOK {
@@ -450,6 +486,7 @@ func TestPublishWithBypass(t *testing.T) {
 				}
 
 				want := make([]*model.Exposure, 0, len(tc.Publish.Keys))
+				tokenWant := &pb.RevisionTokenData{}
 				for _, k := range tc.Publish.Keys {
 					if key, err := base64util.DecodeString(k.Key); err != nil {
 						t.Fatal(err)
@@ -469,6 +506,13 @@ func TestPublishWithBypass(t *testing.T) {
 						}
 
 						want = append(want, &next)
+
+						tokenWant.RevisableKeys = append(tokenWant.RevisableKeys,
+							&pb.RevisableKey{
+								TemporaryExposureKey: key,
+								IntervalNumber:       k.IntervalNumber,
+								IntervalCount:        k.IntervalCount,
+							})
 					}
 				}
 
@@ -489,9 +533,33 @@ func TestPublishWithBypass(t *testing.T) {
 				if diff := cmp.Diff(want, got, sorter, ignoreCreatedAt, cmpopts.IgnoreUnexported(model.Exposure{})); diff != "" {
 					t.Errorf("mismatch (-want, +got):\n%s", diff)
 				}
+
+				// Crack the revision token - it should contain the uploaded exposure keys.
+				tm, err := revision.New(ctx, revDB, time.Minute, 1)
+				if err != nil {
+					t.Fatalf("unable to create token manager: %v", err)
+				}
+				revTokenBytes, err := base64util.DecodeString(response.RevisionToken)
+				if err != nil {
+					t.Fatalf("revision token encoded incorrectly: %v", err)
+				}
+				revToken, err := tm.UnmarshalRevisionToken(ctx, revTokenBytes, tokenAAD)
+				if err != nil {
+					t.Fatalf("unable to decrypt revision token: %v", err)
+				}
+				revisableSorter := cmp.Transformer("Sort", func(in []*pb.RevisableKey) []*pb.RevisableKey {
+					out := append([]*pb.RevisableKey(nil), in...)
+					sort.Slice(out, func(i int, j int) bool {
+						return bytes.Compare(out[i].TemporaryExposureKey, out[j].TemporaryExposureKey) <= 0
+					})
+					return out
+				})
+				if diff := cmp.Diff(tokenWant.RevisableKeys, revToken.RevisableKeys, revisableSorter, cmpopts.IgnoreUnexported(pb.RevisableKey{})); diff != "" {
+					t.Errorf("mismatch (-want, +got):\n%s", diff)
+				}
 			} else {
-				if !strings.Contains(respBody, tc.Error) {
-					t.Errorf("missing error text '%v', got '%v'", tc.Error, respBody)
+				if !strings.Contains(response.Error, tc.Error) {
+					t.Errorf("missing error text '%v', got '%+v'", tc.Error, response)
 				}
 			}
 		})
diff --git a/internal/revision/database/revision_test.go b/internal/revision/database/revision_test.go
index e1c01d9..a020e94 100644
--- a/internal/revision/database/revision_test.go
+++ b/internal/revision/database/revision_test.go
@@ -44,7 +44,7 @@ func TestRevisionKey(t *testing.T) {
 		t.Fatalf("unable to generate key: %v", err)
 	}
 
-	cfg := KMSConfig{keyID, []byte("super"), kms}
+	cfg := KMSConfig{keyID, kms}
 	revDB, err := New(testDB, &cfg)
 	if err != nil {
 		t.Fatalf("unable to provision revision DB: %v", err)
@@ -80,7 +80,7 @@ func TestMultipleRevisionKeys(t *testing.T) {
 		t.Fatalf("unable to generate key: %v", err)
 	}
 
-	cfg := KMSConfig{keyID, []byte("super"), kms}
+	cfg := KMSConfig{keyID, kms}
 	revDB, err := New(testDB, &cfg)
 	if err != nil {
 		t.Fatalf("unable to provision revision DB: %v", err)
diff --git a/internal/revision/revision_test.go b/internal/revision/revision_test.go
index 0332c00..379dc66 100644
--- a/internal/revision/revision_test.go
+++ b/internal/revision/revision_test.go
@@ -33,9 +33,10 @@ import (
 
 func TestBuildTokenBuffer(t *testing.T) {
 	cases := []struct {
-		name    string
-		publish []*model.Exposure
-		want    *pb.RevisionTokenData
+		name     string
+		publish  []*model.Exposure
+		previous *pb.RevisionTokenData
+		want     *pb.RevisionTokenData
 	}{
 		{
 			name: "one",
@@ -46,6 +47,7 @@ func TestBuildTokenBuffer(t *testing.T) {
 					IntervalCount:  144,
 				},
 			},
+			previous: nil,
 			want: &pb.RevisionTokenData{
 				RevisableKeys: []*pb.RevisableKey{
 					{
@@ -57,7 +59,7 @@ func TestBuildTokenBuffer(t *testing.T) {
 			},
 		},
 		{
-			name: "wrong_order",
+			name: "merge_previous",
 			publish: []*model.Exposure{
 				{
 					ExposureKey:    []byte{4, 3, 2, 1},
@@ -70,17 +72,36 @@ func TestBuildTokenBuffer(t *testing.T) {
 					IntervalCount:  144,
 				},
 			},
+			previous: &pb.RevisionTokenData{
+				RevisableKeys: []*pb.RevisableKey{
+					{
+						TemporaryExposureKey: []byte{4, 3, 2, 1},
+						IntervalNumber:       200144,
+						IntervalCount:        144,
+					},
+					{
+						TemporaryExposureKey: []byte{8, 9, 10, 11},
+						IntervalNumber:       200144,
+						IntervalCount:        82,
+					},
+				},
+			},
 			want: &pb.RevisionTokenData{
 				RevisableKeys: []*pb.RevisableKey{
+					{
+						TemporaryExposureKey: []byte{4, 3, 2, 1},
+						IntervalNumber:       200144,
+						IntervalCount:        144,
+					},
 					{
 						TemporaryExposureKey: []byte{1, 2, 3, 4},
 						IntervalNumber:       200000,
 						IntervalCount:        144,
 					},
 					{
-						TemporaryExposureKey: []byte{4, 3, 2, 1},
+						TemporaryExposureKey: []byte{8, 9, 10, 11},
 						IntervalNumber:       200144,
-						IntervalCount:        144,
+						IntervalCount:        82,
 					},
 				},
 			},
@@ -89,7 +110,7 @@ func TestBuildTokenBuffer(t *testing.T) {
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			got := buildTokenBufer(tc.publish)
+			got := buildTokenBufer(tc.previous, tc.publish)
 			if diff := cmp.Diff(tc.want, got, cmpopts.IgnoreUnexported(pb.RevisionTokenData{}), cmpopts.IgnoreUnexported(pb.RevisableKey{})); diff != "" {
 				t.Fatalf("mismatch (-want, +got):\n%s", diff)
 			}
@@ -117,7 +138,6 @@ func TestEncryptDecrypt(t *testing.T) {
 
 	cfg := revisiondb.KMSConfig{
 		WrapperKeyID: keyID,
-		WrapperAAD:   []byte("super"),
 		KeyManager:   kms,
 	}
 	revDB, err := revisiondb.New(testDB, &cfg)
@@ -131,7 +151,7 @@ func TestEncryptDecrypt(t *testing.T) {
 		t.Fatalf("unable to create a revision key: %v", err)
 	}
 
-	tm, err := New(ctx, revDB, time.Second)
+	tm, err := New(ctx, revDB, time.Second, 28)
 	if err != nil {
 		t.Fatalf("unable to build token manager: %v", err)
 	}
@@ -166,7 +186,7 @@ func TestEncryptDecrypt(t *testing.T) {
 		},
 	}
 
-	encrypted, err := tm.MakeRevisionToken(ctx, source, aad)
+	encrypted, err := tm.MakeRevisionToken(ctx, nil, source, aad)
 	if err != nil {
 		t.Fatalf("error encrypting and serializing data: %v", err)
 	}
diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go
index 440a306..1c49e38 100644
--- a/internal/setup/setup_test.go
+++ b/internal/setup/setup_test.go
@@ -64,7 +64,7 @@ func (t *testConfig) DatabaseConfig() *database.Config {
 
 func (t *testConfig) KeyManagerConfig() *keys.Config {
 	return &keys.Config{
-		KeyManagerType: keys.KeyManagerType("NOOP"),
+		KeyManagerType: keys.KeyManagerType("IN_MEMORY"),
 	}
 }
 
@@ -197,8 +197,8 @@ func TestSetupWith(t *testing.T) {
 			t.Errorf("expected key manager to exist")
 		}
 
-		if _, ok := km.(*keys.Noop); !ok {
-			t.Errorf("expected %T to be Noop", km)
+		if _, ok := km.(*keys.InMemory); !ok {
+			t.Errorf("expected %T to be InMemory", km)
 		}
 	})