diff --git a/functions/cmd/main.go b/functions/cmd/main.go index e9c689d..2a87ba6 100644 --- a/functions/cmd/main.go +++ b/functions/cmd/main.go @@ -12,6 +12,8 @@ import ( func main() { funcframework.RegisterHTTPFunction("/challenge", functions.ChallengeHandler) + funcframework.RegisterHTTPFunction("/report", functions.ReportHandler) + funcframework.RegisterHTTPFunction("/validate", functions.ValidateHandler) // Use PORT environment variable, or default to 8080. port := "8080" if envPort := os.Getenv("PORT"); envPort != "" { diff --git a/functions/internal/pow/pow.go b/functions/internal/pow/pow.go index eaaebc5..fdb443b 100644 --- a/functions/internal/pow/pow.go +++ b/functions/internal/pow/pow.go @@ -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 @@ -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 } diff --git a/functions/internal/report/report.go b/functions/internal/report/report.go new file mode 100644 index 0000000..4f36d92 --- /dev/null +++ b/functions/internal/report/report.go @@ -0,0 +1,208 @@ +package report + +import ( + "context" + "encoding/binary" + "encoding/json" + "errors" + "time" + + "cloud.google.com/go/firestore" + + "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 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 + }) +} diff --git a/functions/internal/report/report_test.go b/functions/internal/report/report_test.go new file mode 100644 index 0000000..18d0e91 --- /dev/null +++ b/functions/internal/report/report_test.go @@ -0,0 +1,88 @@ +package report + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "functions/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) + } +} diff --git a/functions/internal/util/util.go b/functions/internal/util/util.go index 4cee3a5..499539d 100644 --- a/functions/internal/util/util.go +++ b/functions/internal/util/util.go @@ -9,6 +9,7 @@ import ( "io" "log" "net/http" + "time" "cloud.google.com/go/firestore" "google.golang.org/api/option" @@ -41,6 +42,32 @@ func getClientOptions(ctx context.Context) []option.ClientOption { } } +// fakeClockKey is a key used to identify the fake clock. Since it is not +// exported, there is no way for code outside of this package to overwrite or +// access values stored with this key. +type fakeClockKey struct{} + +// WithFakeClock stores a fake time in a context. If Now is called on the +// returned context, the given fake time will be returned instead of the real +// time. +func WithFakeClock(parent context.Context, time time.Duration) context.Context { + return context.WithValue(parent, fakeClockKey{}, &time) +} + +// Now attempts to extract a fake clock from ctx. If a fake clock was attached +// using WithFakeClock, then it is returned. If no fake clock is found, then the +// result of time.Now() is returned. +func Now(ctx context.Context) time.Time { + switch t := ctx.Value(fakeClockKey{}).(type) { + case nil: + return time.Now() + case *time.Duration: + return time.Unix(0, int64(*t)) + default: + panic("unreachable") + } +} + // Context is a context.Context that provides extra utilities for common // operations. type Context struct { @@ -119,6 +146,36 @@ func (c *Context) ValidateRequestMethod(method, err string) StatusError { return nil } +// RunTransaction wraps firestore.Client.RunTransaction. If the transaction +// fails for reasons other than f failing, the resulting error will be wrapped +// with NewInternalStatusError. +func (c *Context) RunTransaction(f func(ctx context.Context, txn *firestore.Transaction) StatusError) StatusError { + return RunTransaction(c, c.FirestoreClient(), f) +} + +// RunTransaction wraps firestore.Client.RunTransaction. If the transaction +// fails for reasons other than f failing, the resulting error will be wrapped +// with NewInternalStatusError. +func RunTransaction(ctx context.Context, c *firestore.Client, f func(ctx context.Context, txn *firestore.Transaction) StatusError) StatusError { + err := c.RunTransaction( + ctx, + func(ctx context.Context, txn *firestore.Transaction) error { + return f(ctx, txn) + }, + ) + switch err := err.(type) { + case nil: + return nil + case StatusError: + return err + default: + // If err doesn't implement StatusError, then it must not have come from + // f, which means that it was an error with running the transaction not + // related to business logic, so it's an internal server error. + return NewInternalServerError(err) + } +} + // StatusError is implemented by error types which correspond to a particular // HTTP status code. type StatusError interface { @@ -183,6 +240,16 @@ func NewMethodNotAllowedError(method string) StatusError { } } +// NewNotImplementedError returns a StatusError whose HTTPStatusCode method +// returns http.StatusNotImplemented and whose Message method returns "not +// implemented". +func NewNotImplementedError() StatusError { + return statusError{ + code: http.StatusNotImplemented, + error: fmt.Errorf("not implemented"), + } +} + var ( // NotFoundError is an error returned when a resource is not found. NotFoundError = NewBadRequestError(errors.New("not found")) diff --git a/functions/report.go b/functions/report.go new file mode 100644 index 0000000..ca4329d --- /dev/null +++ b/functions/report.go @@ -0,0 +1,130 @@ +package functions + +import ( + "encoding/json" + "errors" + "log" + "net/http" + "os" + + "functions/internal/pow" + "functions/internal/report" + "functions/internal/util" +) + +// If the environment variable ALLOW_EMPTY_CHALLENGE_SOLUTION is set, then if an +// empty challenge solution is given, simply skip verification. This is useful +// in testing. +var allowEmptyChallengeSolution = false + +func init() { + if os.Getenv("ALLOW_EMPTY_CHALLENGE_SOLUTION") != "" { + log.Println("Detected ALLOW_EMPTY_CHALLENGE_SOLUTION") + allowEmptyChallengeSolution = true + } +} + +// reportRequest is the body of a POST request to the /report endpoint. +type reportRequest struct { + // Must have exactly one of Challenge or UploadKey. + Challenge *pow.ChallengeSolution `json:"challenge"` + UploadKey *report.UploadKey `json:"upload_key"` + Report reportObj `json:"report"` +} + +type reportObj struct { + Data []byte `json:"data"` +} + +type reportResponse struct { + UploadToken report.UploadToken `json:"upload_token"` + UploadKey report.UploadKey `json:"upload_key"` +} + +// Validate validates that r has exactly one of Challenge or UploadKey set (not +// both, and not neither). +func (r *reportRequest) Validate() util.StatusError { + switch { + case r.Challenge != nil && r.UploadKey != nil: + return util.NewBadRequestError(errors.New("can only have proof of work challenge solution or upload key, not both")) + case r.Challenge == nil && r.UploadKey == nil: + return util.NewBadRequestError(errors.New("missing proof of work challenge solution or upload key")) + default: + return nil + } +} + +// ReportHandler is a handler for the /report endpoint. +func ReportHandler(w http.ResponseWriter, r *http.Request) { + ctx, err := util.NewContext(w, r) + if err != nil { + return + } + + if err = ctx.ValidateRequestMethod("POST", ""); err != nil { + return + } + + var req reportRequest + if err := json.NewDecoder(ctx.HTTPRequest().Body).Decode(&req); err != nil { + err := util.JSONToStatusError(err) + ctx.WriteStatusError(err) + return + } + if err := req.Validate(); err != nil { + ctx.WriteStatusError(err) + return + } + + if req.Challenge != nil { + var emptyChallgeSolution pow.ChallengeSolution + if !allowEmptyChallengeSolution || *req.Challenge != emptyChallgeSolution { + if err := pow.ValidateSolution(&ctx, req.Challenge); err != nil { + ctx.WriteStatusError(err) + return + } + } + + token, key, err := report.StorePendingReport(&ctx, req.Report.Data) + if err != nil { + ctx.WriteStatusError(err) + return + } + + json.NewEncoder(w).Encode(reportResponse{ + UploadToken: token, + UploadKey: key, + }) + } else { + // TODO(joshlf): Implement this case. + ctx.WriteStatusError(util.NewNotImplementedError()) + } +} + +type validateRequest struct { + UploadToken report.UploadToken `json:"upload_token"` +} + +// ValidateHandler is a handler for the /report endpoint. +func ValidateHandler(w http.ResponseWriter, r *http.Request) { + ctx, err := util.NewContext(w, r) + if err != nil { + return + } + + if err = ctx.ValidateRequestMethod("POST", ""); err != nil { + return + } + + var req validateRequest + if err := json.NewDecoder(ctx.HTTPRequest().Body).Decode(&req); err != nil { + err := util.JSONToStatusError(err) + ctx.WriteStatusError(err) + return + } + + if err := report.ValidatePendingReport(&ctx, req.UploadToken); err != nil { + ctx.WriteStatusError(err) + return + } +}