From 8e5078abfb7d9d9e92fa6c4a894a434af18ed489 Mon Sep 17 00:00:00 2001 From: violog <51th.apprent1ce.f0rce@gmail.com> Date: Fri, 21 Jun 2024 16:14:56 +0300 Subject: [PATCH 1/2] Use anonymous ID for extra security in joining program --- README.md | 13 +-- .../components/schemas/VerifyPassport.yaml | 16 +++- docs/spec/components/schemas/Withdraw.yaml | 2 +- docs/spec/openapi.yaml | 2 +- ...lic@balances@{nullifier}@join_program.yaml | 9 +- ...c@balances@{nullifier}@verifypassport.yaml | 10 ++- internal/service/handlers/join_program.go | 77 ---------------- internal/service/handlers/verify_passport.go | 90 ++++++++++++------- internal/service/handlers/withdraw.go | 4 +- internal/service/requests/join_program.go | 32 ------- internal/service/requests/verify_passport.go | 68 +++++++++++--- internal/service/router.go | 2 +- .../service/workers/nooneisforgotten/main.go | 10 ++- resources/model_verify_passport_attributes.go | 8 +- resources/model_withdraw_attributes.go | 2 +- 15 files changed, 172 insertions(+), 173 deletions(-) delete mode 100644 internal/service/handlers/join_program.go delete mode 100644 internal/service/requests/join_program.go diff --git a/README.md b/README.md index 99e2070..8bc7d7a 100644 --- a/README.md +++ b/README.md @@ -115,12 +115,15 @@ func AuthMiddleware(auth *auth.Client, log *logan.Entry) func(http.Handler) http ``` and in handlers/verify_passport: ```go - // never panics because of request validation // proof.PubSignals[zk.Nullifier] = mustHexToInt(nullifier) - // err = Verifier(r).VerifyProof(*proof) - // if err != nil { - // return nil, problems.BadRequest(err) - // } + // err = Verifier(r).VerifyProof(*proof) + // if err != nil { + // if errors.Is(err, identity.ErrContractCall) { + // Log(r).WithError(err).Error("Failed to verify proof") + // return nil, append(errs, problems.InternalError()) + // } + // return nil, problems.BadRequest(err) + // } ``` and in handlers/withdraw(lines 49-58): ```go diff --git a/docs/spec/components/schemas/VerifyPassport.yaml b/docs/spec/components/schemas/VerifyPassport.yaml index c46b7e9..c0db2d7 100644 --- a/docs/spec/components/schemas/VerifyPassport.yaml +++ b/docs/spec/components/schemas/VerifyPassport.yaml @@ -7,10 +7,22 @@ allOf: properties: attributes: required: - - proof + - anonymous_id + - country type: object properties: + anonymous_id: + type: string + description: Unique identifier of the passport. + example: "2bd3a2532096fee10a45a40e444a11b4d00a707f3459376087747de05996fbf5" + country: + type: string + description: | + ISO 3166-1 alpha-3 country code, must match the one provided in `proof`. + example: "UKR" proof: type: object format: types.ZKProof - description: Iden3 ZK passport verification proof. \ No newline at end of file + description: | + Query ZK passport verification proof. + Required for endpoint `/v2/balances/{nullifier}/verifypassport`. diff --git a/docs/spec/components/schemas/Withdraw.yaml b/docs/spec/components/schemas/Withdraw.yaml index 0967736..40dcef4 100644 --- a/docs/spec/components/schemas/Withdraw.yaml +++ b/docs/spec/components/schemas/Withdraw.yaml @@ -24,4 +24,4 @@ allOf: proof: type: object format: types.ZKProof - description: Iden3 ZK passport verification proof. + description: Query ZK passport verification proof. diff --git a/docs/spec/openapi.yaml b/docs/spec/openapi.yaml index c37d081..d58e840 100644 --- a/docs/spec/openapi.yaml +++ b/docs/spec/openapi.yaml @@ -1,6 +1,6 @@ openapi: 3.0.0 info: - version: 1.0.0 + version: 1.2.0 title: rarime-points-svc description: '' servers: diff --git a/docs/spec/paths/integrations@rarime-points-svc@v1@public@balances@{nullifier}@join_program.yaml b/docs/spec/paths/integrations@rarime-points-svc@v1@public@balances@{nullifier}@join_program.yaml index 302bad7..4b1040f 100644 --- a/docs/spec/paths/integrations@rarime-points-svc@v1@public@balances@{nullifier}@join_program.yaml +++ b/docs/spec/paths/integrations@rarime-points-svc@v1@public@balances@{nullifier}@join_program.yaml @@ -6,6 +6,13 @@ post: operationId: joinRewardsProgram parameters: - $ref: '#/components/parameters/pathNullifier' + - in: header + name: Signature + description: Signature of the request + required: true + schema: + type: string + pattern: '^[a-f0-9]{64}$' requestBody: required: true content: @@ -16,7 +23,7 @@ post: - data properties: data: - $ref: '#/components/schemas/JoinProgram' + $ref: '#/components/schemas/VerifyPassport' responses: 200: description: Success diff --git a/docs/spec/paths/integrations@rarime-points-svc@v1@public@balances@{nullifier}@verifypassport.yaml b/docs/spec/paths/integrations@rarime-points-svc@v1@public@balances@{nullifier}@verifypassport.yaml index ab94a46..62955d5 100644 --- a/docs/spec/paths/integrations@rarime-points-svc@v1@public@balances@{nullifier}@verifypassport.yaml +++ b/docs/spec/paths/integrations@rarime-points-svc@v1@public@balances@{nullifier}@verifypassport.yaml @@ -3,10 +3,18 @@ post: - Points balance summary: Verify passport description: | - Fulfill verify passport event if it is open + Verify passport with ZKP, fulfilling the event. + One passport can't be verified twice. operationId: verifyPassport parameters: - $ref: '#/components/parameters/pathNullifier' + - in: header + name: Signature + description: Signature of the request + required: true + schema: + type: string + pattern: '^[a-f0-9]{64}$' requestBody: required: true content: diff --git a/internal/service/handlers/join_program.go b/internal/service/handlers/join_program.go deleted file mode 100644 index 1ad5047..0000000 --- a/internal/service/handlers/join_program.go +++ /dev/null @@ -1,77 +0,0 @@ -package handlers - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "fmt" - "net/http" - - "github.com/rarimo/rarime-points-svc/internal/data" - "github.com/rarimo/rarime-points-svc/internal/data/evtypes" - "github.com/rarimo/rarime-points-svc/internal/service/requests" - "gitlab.com/distributed_lab/ape" - "gitlab.com/distributed_lab/ape/problems" -) - -func JoinProgram(w http.ResponseWriter, r *http.Request) { - req, err := requests.NewJoinProgram(r) - if err != nil { - Log(r).WithError(err).Debug("Bad request") - ape.RenderErr(w, problems.BadRequest(err)...) - return - } - - gotSig := r.Header.Get("Signature") - wantSig := calculateCountrySignature(CountriesConfig(r).VerificationKey, req.Data.ID, req.Data.Attributes.Country) - if gotSig != wantSig { - Log(r).Warnf("Unauthorized access: HMAC signature mismatch: got %s, want %s", gotSig, wantSig) - ape.RenderErr(w, problems.Forbidden()) - return - } - - balance, errs := getAndVerifyBalanceEligibility(r, req.Data.ID, nil) - if len(errs) > 0 { - ape.RenderErr(w, errs...) - return - } - - if balance.Country != nil { - Log(r).Debugf("Balance %s already joined rewards program", balance.Nullifier) - ape.RenderErr(w, problems.TooManyRequests()) - return - } - - err = EventsQ(r).Transaction(func() error { - return doPassportScanUpdates(r, *balance, req.Data.Attributes.Country, false) - }) - if err != nil { - Log(r).WithError(err).Error("Failed to execute transaction") - ape.RenderErr(w, problems.InternalError()) - return - } - - event, err := EventsQ(r).FilterByNullifier(balance.Nullifier). - FilterByType(evtypes.TypePassportScan). - FilterByStatus(data.EventClaimed).Get() - if err != nil { - Log(r).WithError(err).Error("Failed to get claimed event") - ape.RenderErr(w, problems.InternalError()) - return - } - - ape.Render(w, newPassportEventStateResponse(req.Data.ID, event)) -} - -func calculateCountrySignature(key []byte, nullifier, country string) string { - bNull, err := hex.DecodeString(nullifier[2:]) - if err != nil { - panic(fmt.Errorf("nullifier was not properly validated as hex: %w", err)) - } - - h := hmac.New(sha256.New, key) - msg := append(bNull, []byte(country)...) - h.Write(msg) - - return hex.EncodeToString(h.Sum(nil)) -} diff --git a/internal/service/handlers/verify_passport.go b/internal/service/handlers/verify_passport.go index 7a11ca2..166f5b1 100644 --- a/internal/service/handlers/verify_passport.go +++ b/internal/service/handlers/verify_passport.go @@ -1,14 +1,18 @@ package handlers import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "fmt" "math" "math/big" "net/http" + "errors" + "github.com/ethereum/go-ethereum/common/hexutil" validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/go-ozzo/ozzo-validation/v4/is" "github.com/google/jsonapi" zkptypes "github.com/iden3/go-rapidsnark/types" "github.com/rarimo/decentralized-auth-svc/pkg/auth" @@ -17,9 +21,9 @@ import ( "github.com/rarimo/rarime-points-svc/internal/service/requests" "github.com/rarimo/rarime-points-svc/resources" zk "github.com/rarimo/zkverifier-kit" + "github.com/rarimo/zkverifier-kit/identity" "gitlab.com/distributed_lab/ape" "gitlab.com/distributed_lab/ape/problems" - "gitlab.com/distributed_lab/logan/v3/errors" ) func VerifyPassport(w http.ResponseWriter, r *http.Request) { @@ -29,30 +33,45 @@ func VerifyPassport(w http.ResponseWriter, r *http.Request) { ape.RenderErr(w, problems.BadRequest(err)...) return } + log := Log(r).WithFields(map[string]any{ + "balance.nullifier": req.Data.ID, + "balance.anonymous_id": req.Data.Attributes.AnonymousId, + "balance.country": req.Data.Attributes.Country, + }) - balance, errs := getAndVerifyBalanceEligibility(r, req.Data.ID, &req.Data.Attributes.Proof) - if len(errs) > 0 { - ape.RenderErr(w, errs...) + gotSig := r.Header.Get("Signature") + wantSig := calculatePassportVerificationSignature( + CountriesConfig(r).VerificationKey, + req.Data.ID, + req.Data.Attributes.Country, + req.Data.Attributes.AnonymousId, + ) + + if gotSig != wantSig { + log.Warnf("Unauthorized access: HMAC signature mismatch: got %s, want %s", gotSig, wantSig) + ape.RenderErr(w, problems.Forbidden()) return } + if req.Data.Attributes.Proof == nil { + log.Debug("Proof is not provided: performing logic of joining program instead of full verification") + } - countryCode, err := extractCountry(req.Data.Attributes.Proof) - if err != nil { - Log(r).WithError(err).Error("Critical: invalid country code provided, while the proof was valid") - ape.RenderErr(w, problems.InternalError()) + balance, errs := getAndVerifyBalanceEligibility(r, req.Data.ID, req.Data.Attributes.Proof) + if len(errs) > 0 { + ape.RenderErr(w, errs...) return } if balance.Country != nil { if balance.IsPassportProven { - Log(r).Debugf("Balance %s already verified", balance.Nullifier) + log.Debugf("Balance %s already verified", balance.Nullifier) ape.RenderErr(w, problems.TooManyRequests()) return } - if *balance.Country != countryCode { + if *balance.Country != req.Data.Attributes.Country { ape.RenderErr(w, problems.BadRequest(validation.Errors{ - "country": fmt.Errorf("country mismatch: got %s, joined program with %s", countryCode, *balance.Country), + "country": fmt.Errorf("country mismatch: got %s, joined program with %s", req.Data.Attributes.Country, *balance.Country), })...) return } @@ -61,7 +80,7 @@ func VerifyPassport(w http.ResponseWriter, r *http.Request) { data.ColIsPassport: true, }) if err != nil { - Log(r).WithError(err).Error("Failed to update balance") + log.WithError(err).Error("Failed to update balance") ape.RenderErr(w, problems.InternalError()) return } @@ -71,10 +90,10 @@ func VerifyPassport(w http.ResponseWriter, r *http.Request) { } err = EventsQ(r).Transaction(func() error { - return doPassportScanUpdates(r, *balance, countryCode, true) + return doPassportScanUpdates(r, *balance, req.Data.Attributes.Country, true) }) if err != nil { - Log(r).WithError(err).Error("Failed to execute transaction") + log.WithError(err).Error("Failed to execute transaction") ape.RenderErr(w, problems.InternalError()) return } @@ -83,7 +102,7 @@ func VerifyPassport(w http.ResponseWriter, r *http.Request) { FilterByType(evtypes.TypePassportScan). FilterByStatus(data.EventClaimed).Get() if err != nil { - Log(r).WithError(err).Error("Failed to get claimed event") + log.WithError(err).Error("Failed to get claimed event") ape.RenderErr(w, problems.InternalError()) return } @@ -99,6 +118,24 @@ func newPassportEventStateResponse(id string, event *data.Event) resources.Passp return res } +func calculatePassportVerificationSignature(key []byte, nullifier, country, anonymousID string) string { + bNull, err := hex.DecodeString(nullifier[2:]) + if err != nil { + panic(fmt.Errorf("nullifier was not properly validated as hex: %w", err)) + } + bAID, err := hex.DecodeString(anonymousID) + if err != nil { + panic(fmt.Errorf("anonymousID was not properly validated as hex: %w", err)) + } + + h := hmac.New(sha256.New, key) + msg := append(bNull, []byte(country)...) + msg = append(msg, bAID...) + h.Write(msg) + + return hex.EncodeToString(h.Sum(nil)) +} + // getAndVerifyBalanceEligibility provides shared logic to verify that the user // is eligible to verify passport or withdraw. Some extra checks still exist in // the flows. You may provide nil proof to handle its verification outside. @@ -130,6 +167,10 @@ func getAndVerifyBalanceEligibility( proof.PubSignals[zk.Nullifier] = mustHexToInt(nullifier) err = Verifier(r).VerifyProof(*proof) if err != nil { + if errors.Is(err, identity.ErrContractCall) { + Log(r).WithError(err).Error("Failed to verify proof") + return nil, append(errs, problems.InternalError()) + } return nil, problems.BadRequest(err) } @@ -479,23 +520,6 @@ func getOrCreateCountry(q data.CountriesQ, code string) (*data.Country, error) { return c, nil } -// extractCountry extracts 3-letter country code from the proof. -func extractCountry(proof zkptypes.ZKProof) (string, error) { - b, ok := new(big.Int).SetString(proof.PubSignals[zk.Citizenship], 10) - if !ok { - b = new(big.Int) - } - - code := string(b.Bytes()) - - return code, validation.Errors{ - "code": validation.Validate( - code, - validation.Required, - validation.When(code != data.DefaultCountryCode, is.CountryCode3), - )}.Filter() -} - func mustHexToInt(s string) string { return new(big.Int).SetBytes(hexutil.MustDecode(s)).String() } diff --git a/internal/service/handlers/withdraw.go b/internal/service/handlers/withdraw.go index 6883812..ae550ab 100644 --- a/internal/service/handlers/withdraw.go +++ b/internal/service/handlers/withdraw.go @@ -57,7 +57,7 @@ func Withdraw(w http.ResponseWriter, r *http.Request) { return } - countryCode, err := extractCountry(proof) + countryCode, err := requests.ExtractCountry(proof) if err != nil { log.WithError(err).Error("Critical: invalid country code provided, while the proof was valid") ape.RenderErr(w, problems.InternalError()) @@ -158,6 +158,8 @@ func isEligibleToWithdraw( } switch { + case !balance.IsPassportProven: + return mapValidationErr("data/attributes/proof", "passport must be proven beforehand") case balance.Amount < amount: return mapValidationErr("data/attributes/amount", "insufficient balance: %d", balance.Amount) case !country.WithdrawalAllowed: diff --git a/internal/service/requests/join_program.go b/internal/service/requests/join_program.go deleted file mode 100644 index a79c7ca..0000000 --- a/internal/service/requests/join_program.go +++ /dev/null @@ -1,32 +0,0 @@ -package requests - -import ( - "encoding/json" - "net/http" - "strings" - - "github.com/go-chi/chi" - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/go-ozzo/ozzo-validation/v4/is" - "github.com/rarimo/rarime-points-svc/resources" -) - -func NewJoinProgram(r *http.Request) (req resources.JoinProgramRequest, err error) { - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - err = newDecodeError("body", err) - return - } - - req.Data.ID = strings.ToLower(req.Data.ID) - - return req, validation.Errors{ - "data/id": validation.Validate(req.Data.ID, - validation.Required, - validation.In(strings.ToLower(chi.URLParam(r, "nullifier"))), - validation.Match(nullifierRegexp)), - "data/type": validation.Validate(req.Data.Type, - validation.Required, - validation.In(resources.JOIN_PROGRAM)), - "data/attributes/country": validation.Validate(req.Data.Attributes.Country, validation.Required, is.CountryCode3), - }.Filter() -} diff --git a/internal/service/requests/verify_passport.go b/internal/service/requests/verify_passport.go index 4a4fea4..789762a 100644 --- a/internal/service/requests/verify_passport.go +++ b/internal/service/requests/verify_passport.go @@ -2,33 +2,75 @@ package requests import ( "encoding/json" + "math/big" "net/http" "regexp" "strings" "github.com/go-chi/chi" - validation "github.com/go-ozzo/ozzo-validation/v4" + val "github.com/go-ozzo/ozzo-validation/v4" + "github.com/go-ozzo/ozzo-validation/v4/is" + zkptypes "github.com/iden3/go-rapidsnark/types" "github.com/rarimo/rarime-points-svc/resources" + zk "github.com/rarimo/zkverifier-kit" ) -var nullifierRegexp = regexp.MustCompile("^0x[0-9a-fA-F]{64}$") +var ( + nullifierRegexp = regexp.MustCompile("^0x[0-9a-fA-F]{64}$") + hex32bRegexp = regexp.MustCompile("^[0-9a-f]{64}$") + // endpoint is hardcoded to reuse handlers.VerifyPassport + verifyPassportPathRegexp = regexp.MustCompile("^/v1/public/balances/0x[0-9a-fA-F]{64}/verifypassport$") +) func NewVerifyPassport(r *http.Request) (req resources.VerifyPassportRequest, err error) { if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - err = newDecodeError("body", err) - return + return req, newDecodeError("body", err) } req.Data.ID = strings.ToLower(req.Data.ID) + var ( + attr = req.Data.Attributes + provingCountry = attr.Country // validate only when proof is provided + proof zkptypes.ZKProof // safe dereference + ) + + if attr.Proof != nil { + proof = *attr.Proof + provingCountry, err = ExtractCountry(proof) + if err != nil { + return req, err + } + } - return req, validation.Errors{ - "data/id": validation.Validate(req.Data.ID, - validation.Required, - validation.In(strings.ToLower(chi.URLParam(r, "nullifier"))), - validation.Match(nullifierRegexp)), - "data/type": validation.Validate(req.Data.Type, - validation.Required, - validation.In(resources.VERIFY_PASSPORT)), - "data/attributes/proof": validation.Validate(req.Data.Attributes.Proof, validation.Required), + return req, val.Errors{ + "data/id": val.Validate(req.Data.ID, + val.Required, + val.In(strings.ToLower(chi.URLParam(r, "nullifier"))), + val.Match(nullifierRegexp)), + "data/type": val.Validate(req.Data.Type, + val.Required, + val.In(resources.VERIFY_PASSPORT)), + "data/attributes/anonymous_id": val.Validate(attr.AnonymousId, val.Required, val.Match(hex32bRegexp)), + "data/attributes/country": val.Validate(attr.Country, val.Required, val.In(provingCountry)), + "data/attributes/proof": val.Validate(attr.Proof, val.When(verifyPassportPathRegexp.MatchString(r.URL.Path), val.Required)), + "data/attributes/proof/proof": val.Validate(proof.Proof, val.When(attr.Proof != nil, val.Required)), + "data/attributes/proof/pub_signals": val.Validate(proof.PubSignals, val.When(attr.Proof != nil, val.Required, val.Length(22, 22))), }.Filter() } + +// ExtractCountry extracts country code from the proof, converting decimal UTF-8 +// code to ISO 3166-1 alpha-3 code. +func ExtractCountry(proof zkptypes.ZKProof) (string, error) { + if len(proof.PubSignals) <= int(zk.Citizenship) { + return "", val.Errors{"country_code": val.ErrLengthTooShort}.Filter() + } + + b, ok := new(big.Int).SetString(proof.PubSignals[zk.Citizenship], 10) + if !ok { + b = new(big.Int) + } + + code := string(b.Bytes()) + + return code, val.Errors{"country_code": val.Validate(code, val.Required, is.CountryCode3)}.Filter() +} diff --git a/internal/service/router.go b/internal/service/router.go index a245643..6b48600 100644 --- a/internal/service/router.go +++ b/internal/service/router.go @@ -36,7 +36,7 @@ func Run(ctx context.Context, cfg config.Config) { r.Route("/{nullifier}", func(r chi.Router) { r.Get("/", handlers.GetBalance) r.Post("/verifypassport", handlers.VerifyPassport) - r.Post("/join_program", handlers.JoinProgram) + r.Post("/join_program", handlers.VerifyPassport) r.Get("/withdrawals", handlers.ListWithdrawals) r.Post("/withdrawals", handlers.Withdraw) }) diff --git a/internal/service/workers/nooneisforgotten/main.go b/internal/service/workers/nooneisforgotten/main.go index 6185bad..1dd4db1 100644 --- a/internal/service/workers/nooneisforgotten/main.go +++ b/internal/service/workers/nooneisforgotten/main.go @@ -231,7 +231,10 @@ func claimReferralSpecificEvents(db *pgdb.DB, types evtypes.Types, levels config return nil } - events, err := pg.NewEvents(db).FilterByType(evtypes.TypeReferralSpecific).FilterByStatus(data.EventFulfilled).Select() + events, err := pg.NewEvents(db). + FilterByType(evtypes.TypeReferralSpecific). + FilterByStatus(data.EventFulfilled). + Select() if err != nil { return fmt.Errorf("failed to select passport scan events: %w", err) } @@ -251,7 +254,10 @@ func claimReferralSpecificEvents(db *pgdb.DB, types evtypes.Types, levels config return nil } - balances, err := pg.NewBalances(db).FilterByNullifier(nullifiers...).Select() + balances, err := pg.NewBalances(db). + FilterByNullifier(nullifiers...). + FilterDisabled(). + Select() if err != nil { return fmt.Errorf("failed to select balances for claim passport scan event: %w", err) } diff --git a/resources/model_verify_passport_attributes.go b/resources/model_verify_passport_attributes.go index cb28054..622f961 100644 --- a/resources/model_verify_passport_attributes.go +++ b/resources/model_verify_passport_attributes.go @@ -7,6 +7,10 @@ package resources import "github.com/iden3/go-rapidsnark/types" type VerifyPassportAttributes struct { - // Iden3 ZK passport verification proof. - Proof types.ZKProof `json:"proof"` + // Unique identifier of the passport. + AnonymousId string `json:"anonymous_id"` + // ISO 3166-1 alpha-3 country code, must match the one provided in `proof`. + Country string `json:"country"` + // Query ZK passport verification proof. Required for endpoint `/v2/balances/{nullifier}/verifypassport`. + Proof *types.ZKProof `json:"proof,omitempty"` } diff --git a/resources/model_withdraw_attributes.go b/resources/model_withdraw_attributes.go index c07e3a8..eb92d74 100644 --- a/resources/model_withdraw_attributes.go +++ b/resources/model_withdraw_attributes.go @@ -11,6 +11,6 @@ type WithdrawAttributes struct { Address string `json:"address"` // Amount of points to withdraw Amount int64 `json:"amount"` - // Iden3 ZK passport verification proof. + // Query ZK passport verification proof. Proof types.ZKProof `json:"proof"` } From 20c943281ffc52c70ec780373833256c89851c85 Mon Sep 17 00:00:00 2001 From: violog <51th.apprent1ce.f0rce@gmail.com> Date: Fri, 21 Jun 2024 17:16:46 +0300 Subject: [PATCH 2/2] Add DB handling of anonymous ID, fix bugs with the flow --- config-testing.yaml | 1 + .../assets/migrations/004_anonymous_id.sql | 5 ++ internal/data/balances.go | 11 +-- internal/data/pg/balances.go | 4 ++ internal/service/handlers/verify_passport.go | 70 ++++++++++++++----- internal/service/requests/verify_passport.go | 11 +-- .../service/workers/nooneisforgotten/main.go | 5 +- 7 files changed, 77 insertions(+), 30 deletions(-) create mode 100644 internal/assets/migrations/004_anonymous_id.sql diff --git a/config-testing.yaml b/config-testing.yaml index 8fabbc6..76dcfee 100644 --- a/config-testing.yaml +++ b/config-testing.yaml @@ -76,6 +76,7 @@ levels: withdrawal_allowed: true countries: + verification_key: "37bc75afc97f8bdcd21cda85ae7b2885b5f1205ae3d79942e56457230f1636a037cc7ebfe42998d66a3dd3446b9d29366271b4f2bd8e0d307db1d320b38fc02f" countries: - code: "UKR" reserve_limit: 100000 diff --git a/internal/assets/migrations/004_anonymous_id.sql b/internal/assets/migrations/004_anonymous_id.sql new file mode 100644 index 0000000..305aa32 --- /dev/null +++ b/internal/assets/migrations/004_anonymous_id.sql @@ -0,0 +1,5 @@ +-- +migrate Up +ALTER TABLE balances ADD COLUMN anonymous_id text UNIQUE; + +-- +migrate Down +ALTER TABLE balances DROP COLUMN anonymous_id; diff --git a/internal/data/balances.go b/internal/data/balances.go index b2ac805..84ef5a6 100644 --- a/internal/data/balances.go +++ b/internal/data/balances.go @@ -7,10 +7,11 @@ import ( ) const ( - ColAmount = "amount" - ColLevel = "level" - ColCountry = "country" - ColIsPassport = "is_passport_proven" + ColAmount = "amount" + ColLevel = "level" + ColCountry = "country" + ColIsPassport = "is_passport_proven" + ColAnonymousID = "anonymous_id" ) type Balance struct { @@ -23,6 +24,7 @@ type Balance struct { Level int `db:"level"` Country *string `db:"country"` IsPassportProven bool `db:"is_passport_proven"` + AnonymousID *string `db:"anonymous_id"` } type BalancesQ interface { @@ -45,6 +47,7 @@ type BalancesQ interface { FilterByNullifier(...string) BalancesQ FilterDisabled() BalancesQ + FilterByAnonymousID(id string) BalancesQ } type WithoutPassportEventBalance struct { diff --git a/internal/data/pg/balances.go b/internal/data/pg/balances.go index 74ba933..7819a47 100644 --- a/internal/data/pg/balances.go +++ b/internal/data/pg/balances.go @@ -196,6 +196,10 @@ func (q *balances) FilterDisabled() data.BalancesQ { return q.applyCondition(squirrel.NotEq{"referred_by": nil}) } +func (q *balances) FilterByAnonymousID(id string) data.BalancesQ { + return q.applyCondition(squirrel.Eq{"anonymous_id": id}) +} + func (q *balances) applyCondition(cond squirrel.Sqlizer) data.BalancesQ { q.selector = q.selector.Where(cond) q.updater = q.updater.Where(cond) diff --git a/internal/service/handlers/verify_passport.go b/internal/service/handlers/verify_passport.go index 166f5b1..a103fe4 100644 --- a/internal/service/handlers/verify_passport.go +++ b/internal/service/handlers/verify_passport.go @@ -33,18 +33,25 @@ func VerifyPassport(w http.ResponseWriter, r *http.Request) { ape.RenderErr(w, problems.BadRequest(err)...) return } + log := Log(r).WithFields(map[string]any{ "balance.nullifier": req.Data.ID, "balance.anonymous_id": req.Data.Attributes.AnonymousId, "balance.country": req.Data.Attributes.Country, }) - gotSig := r.Header.Get("Signature") - wantSig := calculatePassportVerificationSignature( - CountriesConfig(r).VerificationKey, - req.Data.ID, - req.Data.Attributes.Country, - req.Data.Attributes.AnonymousId, + var ( + country = req.Data.Attributes.Country + anonymousID = req.Data.Attributes.AnonymousId + proof = req.Data.Attributes.Proof + + gotSig = r.Header.Get("Signature") + wantSig = calculatePassportVerificationSignature( + CountriesConfig(r).VerificationKey, + req.Data.ID, + country, + anonymousID, + ) ) if gotSig != wantSig { @@ -52,27 +59,51 @@ func VerifyPassport(w http.ResponseWriter, r *http.Request) { ape.RenderErr(w, problems.Forbidden()) return } - if req.Data.Attributes.Proof == nil { + if proof == nil { log.Debug("Proof is not provided: performing logic of joining program instead of full verification") } - balance, errs := getAndVerifyBalanceEligibility(r, req.Data.ID, req.Data.Attributes.Proof) + balance, errs := getAndVerifyBalanceEligibility(r, req.Data.ID, proof) if len(errs) > 0 { ape.RenderErr(w, errs...) return } + byAnonymousID, err := BalancesQ(r).FilterByAnonymousID(anonymousID).Get() + if err != nil { + log.WithError(err).Error("Failed to get balance by anonymous ID") + ape.RenderErr(w, problems.InternalError()) + return + } + if byAnonymousID != nil && byAnonymousID.Nullifier != balance.Nullifier { + log.Warn("Balance with the same anonymous ID already exists") + ape.RenderErr(w, problems.Conflict()) + return + } + if balance.Country != nil { if balance.IsPassportProven { - log.Debugf("Balance %s already verified", balance.Nullifier) + log.Warnf("Balance %s already verified", balance.Nullifier) + ape.RenderErr(w, problems.TooManyRequests()) + return + } + if proof == nil { + log.Warnf("Balance %s tried to re-join program", balance.Nullifier) ape.RenderErr(w, problems.TooManyRequests()) return } - if *balance.Country != req.Data.Attributes.Country { - ape.RenderErr(w, problems.BadRequest(validation.Errors{ - "country": fmt.Errorf("country mismatch: got %s, joined program with %s", req.Data.Attributes.Country, *balance.Country), - })...) + var balAID string + if balance.AnonymousID != nil { + balAID = *balance.AnonymousID + } + + err = validation.Errors{ + "data/attributes/country": validation.Validate(*balance.Country, validation.Required, validation.In(country)), + "data/attributes/anonymous_id": validation.Validate(anonymousID, validation.Required, validation.In(balAID)), + }.Filter() + if err != nil { + ape.RenderErr(w, problems.BadRequest(err)...) return } @@ -90,7 +121,7 @@ func VerifyPassport(w http.ResponseWriter, r *http.Request) { } err = EventsQ(r).Transaction(func() error { - return doPassportScanUpdates(r, *balance, req.Data.Attributes.Country, true) + return doPassportScanUpdates(r, *balance, country, anonymousID, proof != nil) }) if err != nil { log.WithError(err).Error("Failed to execute transaction") @@ -195,8 +226,8 @@ func checkVerificationEligibility(r *http.Request, balance *data.Balance) (errs // doPassportScanUpdates performs all the necessary updates when the passport // scan proof is provided. This logic is shared between verification and // withdrawal handlers. -func doPassportScanUpdates(r *http.Request, balance data.Balance, countryCode string, proven bool) error { - country, err := updateBalanceCountry(r, balance, countryCode, proven) +func doPassportScanUpdates(r *http.Request, balance data.Balance, countryCode, anonymousID string, proven bool) error { + country, err := updateBalanceCountry(r, balance, countryCode, anonymousID, proven) if err != nil { return fmt.Errorf("update balance country: %w", err) } @@ -247,7 +278,7 @@ func doPassportScanUpdates(r *http.Request, balance data.Balance, countryCode st return nil } -func updateBalanceCountry(r *http.Request, balance data.Balance, code string, proven bool) (*data.Country, error) { +func updateBalanceCountry(r *http.Request, balance data.Balance, code, anonymousID string, proven bool) (*data.Country, error) { country, err := getOrCreateCountry(CountriesQ(r), code) if err != nil { return nil, fmt.Errorf("get or create country: %w", err) @@ -261,7 +292,10 @@ func updateBalanceCountry(r *http.Request, balance data.Balance, code string, pr return nil, errors.New("countries mismatch") } - toUpd := map[string]any{data.ColCountry: country.Code} + toUpd := map[string]any{ + data.ColCountry: country.Code, + data.ColAnonymousID: anonymousID, + } if proven { toUpd[data.ColIsPassport] = true } diff --git a/internal/service/requests/verify_passport.go b/internal/service/requests/verify_passport.go index 789762a..79bb437 100644 --- a/internal/service/requests/verify_passport.go +++ b/internal/service/requests/verify_passport.go @@ -19,7 +19,8 @@ var ( nullifierRegexp = regexp.MustCompile("^0x[0-9a-fA-F]{64}$") hex32bRegexp = regexp.MustCompile("^[0-9a-f]{64}$") // endpoint is hardcoded to reuse handlers.VerifyPassport - verifyPassportPathRegexp = regexp.MustCompile("^/v1/public/balances/0x[0-9a-fA-F]{64}/verifypassport$") + verifyPassportPathRegexp = regexp.MustCompile("^/integrations/rarime-points-svc/v1/public/balances/0x[0-9a-fA-F]{64}/verifypassport$") + joinProgramPathRegexp = regexp.MustCompile("^/integrations/rarime-points-svc/v1/public/balances/0x[0-9a-fA-F]{64}/join_program$") ) func NewVerifyPassport(r *http.Request) (req resources.VerifyPassportRequest, err error) { @@ -50,9 +51,11 @@ func NewVerifyPassport(r *http.Request) (req resources.VerifyPassportRequest, er "data/type": val.Validate(req.Data.Type, val.Required, val.In(resources.VERIFY_PASSPORT)), - "data/attributes/anonymous_id": val.Validate(attr.AnonymousId, val.Required, val.Match(hex32bRegexp)), - "data/attributes/country": val.Validate(attr.Country, val.Required, val.In(provingCountry)), - "data/attributes/proof": val.Validate(attr.Proof, val.When(verifyPassportPathRegexp.MatchString(r.URL.Path), val.Required)), + "data/attributes/anonymous_id": val.Validate(attr.AnonymousId, val.Required, val.Match(hex32bRegexp)), + "data/attributes/country": val.Validate(attr.Country, val.Required, val.In(provingCountry), is.CountryCode3), + "data/attributes/proof": val.Validate(attr.Proof, + val.When(verifyPassportPathRegexp.MatchString(r.URL.Path), val.Required), + val.When(joinProgramPathRegexp.MatchString(r.URL.Path), val.Nil)), "data/attributes/proof/proof": val.Validate(proof.Proof, val.When(attr.Proof != nil, val.Required)), "data/attributes/proof/pub_signals": val.Validate(proof.PubSignals, val.When(attr.Proof != nil, val.Required, val.Length(22, 22))), }.Filter() diff --git a/internal/service/workers/nooneisforgotten/main.go b/internal/service/workers/nooneisforgotten/main.go index 1dd4db1..741d5ae 100644 --- a/internal/service/workers/nooneisforgotten/main.go +++ b/internal/service/workers/nooneisforgotten/main.go @@ -254,10 +254,7 @@ func claimReferralSpecificEvents(db *pgdb.DB, types evtypes.Types, levels config return nil } - balances, err := pg.NewBalances(db). - FilterByNullifier(nullifiers...). - FilterDisabled(). - Select() + balances, err := pg.NewBalances(db).FilterByNullifier(nullifiers...).Select() if err != nil { return fmt.Errorf("failed to select balances for claim passport scan event: %w", err) }