Skip to content

Commit

Permalink
[report] Initial commit of /report and /validate endpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
joshlf committed May 10, 2020
1 parent 582a7c9 commit 747f99a
Show file tree
Hide file tree
Showing 8 changed files with 503 additions and 162 deletions.
7 changes: 5 additions & 2 deletions app/challenge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ import (
"github.com/stretchr/testify/assert"

"app/internal/pow"
"app/internal/util"
)

func TestChallenge(t *testing.T) {
firestore, err := NewTestFirestore()
firestore, err := util.NewTestFirestore()
assert.Nil(t, err)

req, err := http.NewRequestWithContext(firestore.FirestoreClientContext(), "GET", "/challenge", nil)
client, err := firestore.FirestoreClientContext()
assert.Nil(t, err)
req, err := http.NewRequestWithContext(client, "GET", "/challenge", nil)
assert.Nil(t, err)

r := httptest.NewRecorder()
Expand Down
4 changes: 3 additions & 1 deletion app/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import (

func main() {
funcframework.RegisterHTTPFunction("/challenge", app.ChallengeHandler)
// Use PORT environment variable, or default to 8080.
funcframework.RegisterHTTPFunction("/report", app.ReportHandler)
funcframework.RegisterHTTPFunction("/validate", app.ValidateHandler)
// Use PORT environment variable, or default to 8088.
port := "8088"
if envPort := os.Getenv("PORT"); envPort != "" {
port = envPort
Expand Down
4 changes: 2 additions & 2 deletions app/internal/pow/pow.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ type challengeDoc struct {
func GenerateChallenge(ctx util.Context) (*Challenge, error) {
c := generateChallenge(DefaultWorkFactor)

doc := challengeDoc{Expiration: time.Now().Add(expirationPeriod)}
doc := challengeDoc{Expiration: util.Now(ctx).Add(expirationPeriod)}
_, err := ctx.FirestoreClient().Collection(challengeCollection).Doc(c.docID()).Create(ctx, doc)
if err != nil {
return nil, err
Expand Down Expand Up @@ -194,7 +194,7 @@ func ValidateSolution(ctx *util.Context, cs *ChallengeSolution) util.StatusError
return util.FirestoreToStatusError(err)
}

now := time.Now()
now := util.Now(ctx)
if challengeDoc.Expiration.Before(now) {
return challengeExpiredError
}
Expand Down
208 changes: 208 additions & 0 deletions app/internal/report/report.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package report

import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"time"

"cloud.google.com/go/firestore"

"app/internal/util"
)

const (
// UploadKeyLen is the length, in bytes, of an UploadKey
UploadKeyLen = 16

pendingReportsCollection = "pending_reports"

// 3 days - the period of time during which a token is valid and may be
// verified.
validityPeriod = time.Hour * 24 * 3
// 4 days - the period of time after the validity period has expired during
// which the token is still allocated in order to prevent reallocation.
allocationPeriod = time.Hour * 24 * 4
)

// An UploadKey is used to authorize the uploading of future reports.
type UploadKey [UploadKeyLen]byte

func genUploadKey() UploadKey {
var k UploadKey
util.ReadCryptoRandBytes(k[:])
return k
}

// MarshalJSON implements json.Marshaler.
func (k UploadKey) MarshalJSON() ([]byte, error) {
return json.Marshal(k[:])
}

var invalidUploadKeyError = util.NewBadRequestError(errors.New("invalid upload key"))

// UnmarshalJSON implements json.Unmarshaler.
func (k *UploadKey) UnmarshalJSON(b []byte) error {
var bytes []byte
err := json.Unmarshal(b, &bytes)
if err != nil {
return err
}
if len(bytes) != UploadKeyLen {
return invalidUploadKeyError
}
copy(k[:], bytes)
return nil
}

// The layout of a pending report document in the database.
type pendingReportDoc struct {
UploadKey UploadKey
// The 9-bit key from the upload token, used to hedge against human mistakes
// in transmitting or entering the upload token.
TokenKey uint16
// The data of the report. Once this report has been logically removed but
// is still in the database for allocation reasons (see comments on fields
// below), this is zeroed to save space.
ReportData []byte
// Whether this report has already been validated. When a report is
// validated, its token is logically removed from the database. However, in
// order to prevent token reallocation, we leave the document in the
// database with this flag set to true. See the comment on
// ValidityExpiration for an explanation of why we want to prevent
// reallocation.
Validated bool
// The time at which this document's upload token may no longer be
// validated. Note that after the validity has expired, the document is
// still not removed from the database until the allocation expiration has
// been reached. This is in order to prevent the following scenario, which
// would be possible if the two expirations were the same duration:
// 1. User uploads a report and gets an upload token.
// 2. User knows that they have a particular amount of time before their
// token will expire, so they wait until the last minute to call their
// health authority.
// 3. Token expires, and document is remvoed from the database.
// 4. A different user uploads a report, and is allocated the same token.
// 5. Original user calls health authority, and provides token.
// 6. Health authority verifies the original user's diagnosis, and verifies
// their token.
// 7. The new report - associated with a different user - is published.
//
// We expect that a user will not wait /that/ long after they know that
// their token has expired to try verifying their report with the health
// authority, and so adding a period of time after which the token can no
// longer be verified but during which the token cannot be re-allocated for
// a new report minimizes the likelihood of this mistake happening.
ValidityExpiration time.Time
// The time at which this document is removed from the database, and the
// token becomes available for allocation again.
AllocationExpiration time.Time
}

// StorePendingReport stores the given report in the database as pending. It
// allocates a new upload token and upload key, and returns them.
func StorePendingReport(ctx *util.Context, reportData []byte) (UploadToken, UploadKey, util.StatusError) {
return storePendingReport(ctx, ctx.FirestoreClient(), reportData)
}

func storePendingReport(ctx context.Context, client *firestore.Client, reportData []byte) (UploadToken, UploadKey, util.StatusError) {
now := util.Now(ctx)
validityExp := now.Add(validityPeriod)
allocationExp := validityExp.Add(allocationPeriod)
doc := pendingReportDoc{
UploadKey: genUploadKey(),
TokenKey: 0, // We'll explicitly set this below
Validated: false,
ReportData: reportData,
ValidityExpiration: validityExp,
AllocationExpiration: allocationExp,
}

pendingReports := client.Collection(pendingReportsCollection)

// TODO(28): Implement a token allocation algorithm which guarantees that
// the numerically smallest unallocated token is always chosen.
//
// For the time being, we simply generate a random token and hope for the
// best. With 55 bits of entropy, it's very unlikely that this will ever be
// a problem during testing before we implement the final algorithm.
var bytes [8]byte
util.ReadCryptoRandBytes(bytes[:])
t := UploadToken{token: binary.BigEndian.Uint64(bytes[:])}
doc.TokenKey = t.key()
_, err := pendingReports.Doc(t.idString()).Create(ctx, doc)
if err != nil {
return UploadToken{}, UploadKey{}, util.NewInternalServerError(err)
}
return t, doc.UploadKey, nil
}

// ValidatePendingReport validates the pending report with the given token. On
// success, it performs the following operations under a single database
// transaction:
// - Marks the token as validated
// - Adds the upload key to the database of upload keys
// - Adds the report to the database of published reports
//
// ValidatePendingReport returns a "not found" error under the following
// conditions:
// - The token's ID doesn't identify a document in the database
// - The token's key doesn't match the key stored in the document with the
// token's ID
// - The token's validity period has expired
// - The token has already been validated
func ValidatePendingReport(ctx *util.Context, token UploadToken) util.StatusError {
return validatePendingReport(ctx, ctx.FirestoreClient(), token)
}

func validatePendingReport(ctx context.Context, c *firestore.Client, token UploadToken) util.StatusError {
// We perform the following steps under a transaction:
// Reads:
// - Get the document from the database
// - Validate the token key
// - Validate that the token has not already been validated
// - Validate that the token has not expired
// Writes:
// - Mark the token as validated
// - Add the upload key to the database of upload keys
// - Add the report to the database of published reports
return util.RunTransaction(ctx, c, func(ctx context.Context, txn *firestore.Transaction) util.StatusError {
pendingReports := c.Collection(pendingReportsCollection)

docID := token.idString()
snapshot, err := txn.Get(pendingReports.Doc(docID))
if err != nil {
return util.FirestoreToStatusError(err)
}
var doc pendingReportDoc
if err = snapshot.DataTo(&doc); err != nil {
return util.FirestoreToStatusError(err)
}

if token.key() != doc.TokenKey || doc.Validated || util.Now(ctx).After(doc.ValidityExpiration) {
// If the key doesn't match, then the token was entered incorrectly,
// and so we treat this as a failed lookup.
//
// When a report is validated or expires, it is logically removed
// from the database. Its document is only kept in order to prevent
// the same token from being reallocated before the allocation
// period has expired.
return util.NotFoundError
}

// TODO(joshlf):
// - Add the report to the database of published reports
// - Add the upload key to the database of upload keys

// So long as we're overwriting entire document anyway, clear the report
// data to save space.
doc.ReportData = nil
doc.Validated = true
if err = txn.Set(pendingReports.Doc(docID), doc); err != nil {
return util.FirestoreToStatusError(err)
}

return nil
})
}
88 changes: 88 additions & 0 deletions app/internal/report/report_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package report

import (
"context"
"encoding/json"
"testing"

"github.com/stretchr/testify/assert"

"app/internal/util"
)

func TestReport(t *testing.T) {
firestore, err := util.NewTestFirestore()
assert.Nil(t, err)

ctx := util.WithFakeClock(context.Background(), 0)
client, err := firestore.FirestoreClient(ctx)
assert.Nil(t, err)

//
// Store a pending report
//

const clientData = "hello, world"
token, key, err := storePendingReport(ctx, client, []byte(clientData))
assert.Nil(t, err)

getDoc := func() pendingReportDoc {
snapshot, err := client.Collection(pendingReportsCollection).Doc(token.idString()).Get(ctx)
assert.Nil(t, err)
var doc pendingReportDoc
assert.Nil(t, snapshot.DataTo(&doc))
return doc
}

doc := getDoc()
assert.Equal(t, doc.UploadKey, key)
assert.Equal(t, doc.TokenKey, token.key())
assert.Equal(t, doc.ReportData, []byte(clientData))
assert.Equal(t, doc.Validated, false)

//
// Test a numer of validation attempts that should fail
//

// The wrong token ID
err = validatePendingReport(ctx, client, newUploadToken(0, token.key()))
assert.Equal(t, util.NotFoundError, err)
// The wrong key
err = validatePendingReport(ctx, client, newUploadToken(token.id(), 0))
assert.Equal(t, util.NotFoundError, err)
// Expired token
err = validatePendingReport(util.WithFakeClock(ctx, validityPeriod+1), client, token)
assert.Equal(t, util.NotFoundError, err)

//
// Test a validation that should succeed
//

err = validatePendingReport(ctx, client, token)
assert.Nil(t, err)

doc = getDoc()
assert.Equal(t, doc.UploadKey, key)
assert.Equal(t, doc.TokenKey, token.key())
assert.Equal(t, doc.ReportData, []byte{})
assert.Equal(t, doc.Validated, true)

//
// Test that validating an already-validated token should fail
//

err = validatePendingReport(ctx, client, token)
assert.Equal(t, util.NotFoundError, err)
}

func TestKeyJSON(t *testing.T) {
for i := 0; i < 1024; i++ {
k0 := genUploadKey()
bytes, err := json.Marshal(k0)
assert.Nil(t, err)
var k1 UploadKey
err = json.Unmarshal(bytes, &k1)
assert.Nil(t, err)
assert.Equal(t, k0, k1)
}
}
Loading

0 comments on commit 747f99a

Please sign in to comment.