Skip to content

Commit

Permalink
[report] Initial commit of /report and /validate endpoints
Browse files Browse the repository at this point in the history
- Split the `util.Context` type into `Context` and `RequestContext`.
  `Context` stores a Firestore client and implements a fake clock
  for testing. `RequestContext` wraps `Context`, and adds HTTP
  request-specific utilities. All code should use a `Context` unless
  it needs HTTP request-specific utilities.
- Implement a fake clock for testing in `util.Context`
- Add utilities to `util.Context` to help run Firestore transactions
  • Loading branch information
joshlf committed May 14, 2020
1 parent 380bc1d commit 53f7ed5
Show file tree
Hide file tree
Showing 11 changed files with 701 additions and 89 deletions.
88 changes: 81 additions & 7 deletions API.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,101 @@
# API Endpoints

Note: In addition to listed response codes, all endpoints may return 500 on
internal server error.
All error conditions which can happen even to a correctly-written client are
documented. The following are not documented:
- Any error condition which can only happen if a client is buggy
- All endpoints may return 500 on internal server error

## `/challenge`
# `/challenge`

### Behavior
## Behavior

Generates a new proof of work challenge and stores it in the database.

### Request
## Request

Method: `GET`

Request body: None

### Response
## Response

Code: 200

Code: 200 on success
Response body:

```json
{
"work_factor" : 1024,
"nonce" : "54be07e7445880272d5f36cc56c78b6b"
}
```

# `/report`

## Behavior

- If a challenge solution is provided but not upload key:
- Allocates new upload token, generates new upload key
- Stores pending report in database
- Responds with upload token
- If upload key is provided but not challenge solution:
- TODO

## New report without upload key

### Request

Method: `POST`

Request body:

```json
{
"report" : {
"data" : "9USO+Z30bvZWIKPwZmee0TvkGXBQi7+DqAjtdYZ="
},
"challenge" : {
"solution" : {
"nonce" : "6e38798e1cf0c5a26fedb35da176a589"
},
"challenge" : {
"nonce" : "54be07e7445880272d5f36cc56c78b6b",
"work_factor" : 1024
}
}
}
```

### Response

Code: 200

Response body:

```json
{
"upload_key" : "UufO/rTN6adhqkwnNqRUbQ==",
"upload_token" : "234-226-9"
}
```

## New report with upload key

### Request

Method: `POST`

Request body:

```json
{
"upload_key" : "UufO/rTN6adhqkwnNqRUbQ==",
"report" : {
"data" : "9USO+Z30bvZWIKPwZmee0TvkGXBQi7+DqAjtdYZ="
}
}
```

### Response

TODO
4 changes: 2 additions & 2 deletions functions/challenge.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ import (
var ChallengeHTTPHandler = util.MakeHTTPHandler(ChallengeHandler)

// ChallengeHandler is a handler for the /challenge endpoint.
func ChallengeHandler(ctx *util.Context) util.StatusError {
func ChallengeHandler(ctx *util.RequestContext) util.StatusError {
if err := ctx.ValidateRequestMethod("GET", ""); err != nil {
return err
}

c, err := pow.GenerateChallenge(ctx)
c, err := pow.GenerateChallenge(ctx.Inner())
if err != nil {
return util.NewInternalServerError(err)
}
Expand Down
4 changes: 2 additions & 2 deletions functions/challenge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ func TestChallenge(t *testing.T) {
req, err := http.NewRequestWithContext(context.Background(), "GET", "/challenge", nil)
assert.Nil(t, err)
r := httptest.NewRecorder()
ctx, err := util.NewTestContext(r, req, firestore)
ctx, err := util.NewTestRequestContext(r, req, firestore)
assert.Nil(t, err)

ChallengeHandler(&ctx)
ChallengeHandler(ctx)

// First, unmarshal using pow.Challenge in order to benefit from its
// validation.
Expand Down
4 changes: 3 additions & 1 deletion functions/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import (
)

func main() {
funcframework.RegisterHTTPFunction("/challenge", util.MakeTestHTTPHandler(functions.ChallengeHandler))
funcframework.RegisterHTTPFunction("/challenge", util.MakeDevHTTPHandler(functions.ChallengeHandler))
funcframework.RegisterHTTPFunction("/report", util.MakeDevHTTPHandler(functions.ReportHandler))
funcframework.RegisterHTTPFunction("/validate", util.MakeDevHTTPHandler(functions.ValidateHandler))
// Use PORT environment variable, or default to 8080.
port := "8080"
if envPort := os.Getenv("PORT"); envPort != "" {
Expand Down
5 changes: 2 additions & 3 deletions functions/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: ctx.Now().Add(expirationPeriod)}
_, err := ctx.FirestoreClient().Collection(challengeCollection).Doc(c.docID()).Create(ctx, doc)
if err != nil {
return nil, err
Expand Down Expand Up @@ -194,8 +194,7 @@ func ValidateSolution(ctx *util.Context, cs *ChallengeSolution) util.StatusError
return util.FirestoreToStatusError(err)
}

now := time.Now()
if challengeDoc.Expiration.Before(now) {
if challengeDoc.Expiration.Before(ctx.Now()) {
return challengeExpiredError
}

Expand Down
201 changes: 201 additions & 0 deletions functions/internal/report/report.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package report

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

"cloud.google.com/go/firestore"

"upload-token.functions/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 removed 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) {
validityExp := ctx.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 := ctx.FirestoreClient().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 {
// 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
now := ctx.Now()
client := ctx.FirestoreClient()
return util.RunTransaction(ctx, client, func(ctx context.Context, txn *firestore.Transaction) util.StatusError {
pendingReports := client.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 || now.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
})
}
Loading

0 comments on commit 53f7ed5

Please sign in to comment.