From 4f802df114d79d1191bd96142a15b312d282c66b Mon Sep 17 00:00:00 2001 From: Isabelle Wei Date: Thu, 27 Oct 2022 20:49:05 -0400 Subject: [PATCH] merge from master --- .circleci/config.yml | 25 +- .env | 15 + .env.alfajores | 18 +- .env.baklava | 4 +- .env.rc1 | 20 +- .vscode/extensions.json | 3 +- dependency-graph.json | 1 + .../circleci/circleci-node12/Dockerfile | 2 +- package.json | 2 +- packages/attestation-service/package.json | 4 +- packages/celotool/src/lib/env-utils.ts | 11 + packages/celotool/src/lib/odis.ts | 15 + .../odis/templates/signer-deployment.yaml | 7 + .../phone-number-privacy/combiner/README.md | 2 - .../combiner/jest.config.js | 7 + .../20200330212224_create-accounts-table.ts | 14 - ...0200330212301_create-number-pairs-table.ts | 14 - .../20210421212301_create-indices.ts | 27 - ...erPhoneNumber_columns_to_accounts_table.ts | 19 - .../combiner/package.json | 23 +- .../combiner/scripts/run-migrations.ts | 25 - .../combiner/src/common/action.ts | 8 + .../combiner/src/common/combine.ts | 146 ++ .../combiner/src/common/controller.ts | 25 + .../crypto-clients/bls-crypto-client.ts} | 58 +- .../common/crypto-clients/crypto-client.ts | 54 + .../crypto-clients/domain-crypto-client.ts | 40 + .../combiner/src/common/crypto-session.ts | 16 + .../combiner/src/common/error-utils.ts | 20 - .../combiner/src/common/io.ts | 165 ++ .../combiner/src/common/session.ts | 54 + .../combiner/src/common/sign.ts | 95 + .../combiner/src/config.ts | 197 +- .../combiner/src/database/database.ts | 16 - .../combiner/src/database/models/account.ts | 26 - .../src/database/models/numberPair.ts | 21 - .../combiner/src/database/wrappers/account.ts | 110 - .../src/database/wrappers/number-pairs.ts | 56 - .../src/domain/endpoints/disable/action.ts | 39 + .../src/domain/endpoints/disable/io.ts | 86 + .../src/domain/endpoints/quota/action.ts | 39 + .../combiner/src/domain/endpoints/quota/io.ts | 92 + .../src/domain/endpoints/sign/action.ts | 56 + .../combiner/src/domain/endpoints/sign/io.ts | 105 + .../src/domain/services/log-responses.ts | 59 + .../src/domain/services/threshold-state.ts | 78 + .../combiner/src/index.ts | 138 +- .../src/match-making/get-contact-matches.ts | 278 --- .../src/pnp/endpoints/quota/action.ts | 42 + .../combiner/src/pnp/endpoints/quota/io.ts | 103 + .../combiner/src/pnp/endpoints/sign/action.ts | 56 + .../src/pnp/endpoints/sign/io.legacy.ts | 124 ++ .../combiner/src/pnp/endpoints/sign/io.ts | 123 ++ .../src/pnp/services/log-responses.ts | 130 ++ .../src/pnp/services/threshold-state.ts | 49 + .../combiner/src/server.ts | 203 ++ .../src/signing/get-threshold-signature.ts | 390 ---- .../combiner/src/web3/contracts.ts | 10 - .../test/end-to-end/get-blinded-sig.test.ts | 18 +- .../end-to-end/get-contact-matches.test.ts | 145 -- .../combiner/test/end-to-end/resources.ts | 8 +- .../combiner/test/index.test.ts | 567 ------ .../combiner/test/integration/domain.test.ts | 1120 +++++++++++ .../test/integration/legacypnp.test.ts | 806 ++++++++ .../combiner/test/integration/pnp.test.ts | 1190 +++++++++++ .../{signing => unit}/bls-signature.test.ts | 77 +- .../test/unit/domain-response-logger.test.ts | 350 ++++ .../test/unit/domain-threshold-state.test.ts | 175 ++ .../test/unit/pnp-response-logger.test.ts | 713 +++++++ .../test/unit/pnp-threshold-state.test.ts | 225 +++ .../combiner/tsconfig.json | 2 +- .../common/jest.config.js | 7 + .../phone-number-privacy/common/package.json | 8 +- .../common/src/domains/sequential-delay.ts | 90 +- .../phone-number-privacy/common/src/index.ts | 12 +- .../common/src/interfaces/endpoints.ts | 79 + .../common/src/interfaces/error-utils.ts | 34 - .../common/src/interfaces/errors.ts | 58 + .../common/src/interfaces/index.ts | 3 +- .../common/src/interfaces/requests.ts | 203 +- .../common/src/interfaces/responses.ts | 217 +- .../phone-number-privacy/common/src/poprf.ts | 9 +- .../common/src/test/utils.ts | 107 +- .../common/src/test/values.ts | 126 ++ .../common/src/utils/authentication.ts | 74 +- .../{config-utils.ts => config.utils.ts} | 0 .../common/src/utils/constants.ts | 4 +- .../common/src/utils/contracts.ts | 10 + .../common/src/utils/input-validation.ts | 34 +- .../common/src/utils/key-version.ts | 105 + .../common/src/utils/logger.ts | 41 +- .../common/src/utils/responses.utils.ts | 20 + .../{src/domains => test}/domains.test.ts | 6 +- .../common/test/interfaces/requests.test.ts | 45 +- .../common/test/poprf.test.ts | 14 +- .../common/test/utils/authentication.test.ts | 163 +- .../test/utils/input-validation.test.ts | 154 +- .../common/test/utils/key-version.test.ts | 228 +++ .../test/utils/sequential-delay.test.ts | 58 +- .../phone-number-privacy/monitor/package.json | 4 +- .../{runLoadTest.ts => run-load-test.ts} | 0 packages/phone-number-privacy/signer/.env | 20 +- .../phone-number-privacy/signer/README.md | 23 +- .../signer/azure-templates/README.md | 129 -- .../azure-templates/container-parameters.json | 106 - .../azure-templates/container-template.json | 115 -- .../signer/azure-templates/db-parameters.json | 51 - .../signer/azure-templates/db-template.json | 89 - .../azure-templates/frontdoor-template.json | 122 -- .../azure-templates/prometheus-alfajores.yaml | 14 - .../azure-templates/prometheus-mainnet.yaml | 14 - .../prometheus-parameters.json | 130 -- .../prometheus-service-account-key.json | 12 - .../azure-templates/prometheus-staging.yaml | 14 - .../azure-templates/prometheus-template.json | 132 -- .../signer/jest.config.js | 7 + .../phone-number-privacy/signer/package.json | 23 +- .../signer/scripts/poprf-keygen.ts | 21 + .../signer/scripts/run-migrations.ts | 6 +- ...te-bls-keys.ts => threshold-bls-keygen.ts} | 10 +- .../signer/src/common/action.ts | 21 + .../bls/bls-cryptography-client.ts | 6 +- .../signer/src/common/controller.ts | 52 + .../src/{ => common}/database/database.ts | 66 +- .../20200330212224_create-accounts-table.ts | 11 +- .../20200811163913_create-requests-table.ts} | 10 +- .../20210421212301_create-indices.ts | 17 + .../20210921173354_create-domain-state.ts | 19 + .../20220119165335_domain-requests.ts | 26 + .../20220923161710_pnp-requests-onchain.ts | 22 + .../20220923165433_pnp-accounts-onchain.ts | 18 + .../src/common/database/models/account.ts | 24 + .../common/database/models/domain-request.ts | 25 + .../common/database/models/domain-state.ts | 46 + .../src/common/database/models/request.ts | 27 + .../signer/src/common/database/utils.ts | 42 + .../src/common/database/wrappers/account.ts | 105 + .../database/wrappers/domain-request.ts | 68 + .../common/database/wrappers/domain-state.ts | 141 ++ .../src/common/database/wrappers/request.ts | 67 + .../src/common/domain/domainState.mapper.ts | 12 - .../signer/src/common/error-utils.ts | 43 - .../signer/src/common/io.ts | 59 + .../common/key-management/aws-key-provider.ts | 78 + .../key-management/azure-key-provider.ts | 45 + .../key-management/google-key-provider.ts | 58 + .../key-management/key-provider-base.ts | 70 + .../src/common/key-management/key-provider.ts | 52 + .../key-management/mock-key-provider.ts | 51 + .../signer/src/common/metrics.ts | 44 +- .../signer/src/common/quota.ts | 34 + .../signer/src/common/web3/contracts.ts | 203 ++ .../phone-number-privacy/signer/src/config.ts | 107 +- .../signer/src/database/models/account.ts | 17 - .../signer/src/database/models/domainState.ts | 45 - .../signer/src/database/models/request.ts | 19 - .../signer/src/database/wrappers/account.ts | 83 - .../src/database/wrappers/domainState.ts | 166 -- .../signer/src/database/wrappers/request.ts | 53 - .../src/domain/auth/domainAuth.interface.ts | 3 - .../src/domain/auth/domainAuth.service.ts | 8 - .../signer/src/domain/domain.interface.ts | 21 - .../signer/src/domain/domain.service.ts | 270 --- .../src/domain/endpoints/disable/action.ts | 63 + .../signer/src/domain/endpoints/disable/io.ts | 78 + .../src/domain/endpoints/quota/action.ts | 35 + .../signer/src/domain/endpoints/quota/io.ts | 83 + .../src/domain/endpoints/sign/action.ts | 175 ++ .../signer/src/domain/endpoints/sign/io.ts | 96 + .../src/domain/quota/domainQuota.interface.ts | 13 - .../src/domain/quota/domainQuota.service.ts | 61 - .../signer/src/domain/services/quota.ts | 63 + .../signer/src/domain/session.ts | 14 + .../phone-number-privacy/signer/src/index.ts | 46 +- .../src/key-management/aws-key-provider.ts | 62 - .../src/key-management/azure-key-provider.ts | 20 - .../src/key-management/google-key-provider.ts | 30 - .../src/key-management/key-provider-base.ts | 28 - .../signer/src/key-management/key-provider.ts | 42 - .../src/key-management/mock-key-provider.ts | 8 - .../20210421212301_create-indices.ts | 17 - .../20210921173354_create-domain-state.ts | 20 - .../signer/src/pnp/endpoints/quota/action.ts | 43 + .../src/pnp/endpoints/quota/io.legacy.ts | 103 + .../signer/src/pnp/endpoints/quota/io.ts | 101 + .../src/pnp/endpoints/sign/action.legacy.ts | 21 + .../src/pnp/endpoints/sign/action.onchain.ts | 21 + .../signer/src/pnp/endpoints/sign/action.ts | 159 ++ .../src/pnp/endpoints/sign/io.legacy.ts | 125 ++ .../signer/src/pnp/endpoints/sign/io.ts | 121 ++ .../signer/src/pnp/services/quota.legacy.ts | 282 +++ .../signer/src/pnp/services/quota.onchain.ts | 38 + .../signer/src/pnp/services/quota.ts | 156 ++ .../signer/src/pnp/session.ts | 19 + .../phone-number-privacy/signer/src/server.ts | 195 +- .../src/signing/get-partial-signature.ts | 209 -- .../signer/src/signing/query-quota.ts | 335 ---- .../signer/src/web3/contracts.ts | 38 - .../signer/test/domain/domain.test.ts | 174 -- .../end-to-end/domain/domain.service.test.ts | 86 + .../test/end-to-end/get-blinded-sig.test.ts | 35 +- .../signer/test/index.test.ts | 245 --- .../signer/test/integration/domain.test.ts | 1044 ++++++++++ .../signer/test/integration/legacypnp.test.ts | 1766 +++++++++++++++++ .../signer/test/integration/pnp.test.ts | 1343 +++++++++++++ .../key-management/aws-key-provider.test.ts | 45 +- .../key-management/azure-key-provider.test.ts | 41 +- .../google-key-provider.test.ts | 42 +- .../signer/test/signing/bls-signature.test.ts | 24 +- .../signer/test/signing/query-quota.test.ts | 208 -- .../protocol/scripts/bash/backupmigrations.sh | 1 + .../sdk/contractkit/src/contract-cache.ts | 6 - .../contractkit/src/web3-contract-cache.ts | 3 + .../contractkit/src/wrappers/OdisPayments.ts | 11 +- packages/sdk/encrypted-backup/package.json | 6 +- .../sdk/encrypted-backup/src/backup.test.ts | 14 +- .../sdk/encrypted-backup/src/odis.mock.ts | 37 +- packages/sdk/encrypted-backup/src/odis.ts | 28 +- packages/sdk/identity/package.json | 2 +- packages/sdk/identity/src/odis/identifier.ts | 43 +- packages/sdk/identity/src/odis/index.ts | 2 - .../sdk/identity/src/odis/matchmaking.test.ts | 82 - packages/sdk/identity/src/odis/matchmaking.ts | 120 -- .../src/odis/phone-number-identifier.test.ts | 13 +- .../src/odis/phone-number-identifier.ts | 2 - packages/sdk/identity/src/odis/query.ts | 182 +- packages/sdk/identity/src/odis/quota.test.ts | 54 + packages/sdk/identity/src/odis/quota.ts | 54 + yarn.lock | 827 ++++++-- 229 files changed, 17228 insertions(+), 6498 deletions(-) delete mode 100644 packages/phone-number-privacy/combiner/migrations/20200330212224_create-accounts-table.ts delete mode 100644 packages/phone-number-privacy/combiner/migrations/20200330212301_create-number-pairs-table.ts delete mode 100644 packages/phone-number-privacy/combiner/migrations/20210421212301_create-indices.ts delete mode 100644 packages/phone-number-privacy/combiner/migrations/20210813105139_add_dekSigner_signedUserPhoneNumber_columns_to_accounts_table.ts delete mode 100644 packages/phone-number-privacy/combiner/scripts/run-migrations.ts create mode 100644 packages/phone-number-privacy/combiner/src/common/action.ts create mode 100644 packages/phone-number-privacy/combiner/src/common/combine.ts create mode 100644 packages/phone-number-privacy/combiner/src/common/controller.ts rename packages/phone-number-privacy/combiner/src/{bls/bls-cryptography-client.ts => common/crypto-clients/bls-crypto-client.ts} (65%) create mode 100644 packages/phone-number-privacy/combiner/src/common/crypto-clients/crypto-client.ts create mode 100644 packages/phone-number-privacy/combiner/src/common/crypto-clients/domain-crypto-client.ts create mode 100644 packages/phone-number-privacy/combiner/src/common/crypto-session.ts delete mode 100644 packages/phone-number-privacy/combiner/src/common/error-utils.ts create mode 100644 packages/phone-number-privacy/combiner/src/common/io.ts create mode 100644 packages/phone-number-privacy/combiner/src/common/session.ts create mode 100644 packages/phone-number-privacy/combiner/src/common/sign.ts delete mode 100644 packages/phone-number-privacy/combiner/src/database/database.ts delete mode 100644 packages/phone-number-privacy/combiner/src/database/models/account.ts delete mode 100644 packages/phone-number-privacy/combiner/src/database/models/numberPair.ts delete mode 100644 packages/phone-number-privacy/combiner/src/database/wrappers/account.ts delete mode 100644 packages/phone-number-privacy/combiner/src/database/wrappers/number-pairs.ts create mode 100644 packages/phone-number-privacy/combiner/src/domain/endpoints/disable/action.ts create mode 100644 packages/phone-number-privacy/combiner/src/domain/endpoints/disable/io.ts create mode 100644 packages/phone-number-privacy/combiner/src/domain/endpoints/quota/action.ts create mode 100644 packages/phone-number-privacy/combiner/src/domain/endpoints/quota/io.ts create mode 100644 packages/phone-number-privacy/combiner/src/domain/endpoints/sign/action.ts create mode 100644 packages/phone-number-privacy/combiner/src/domain/endpoints/sign/io.ts create mode 100644 packages/phone-number-privacy/combiner/src/domain/services/log-responses.ts create mode 100644 packages/phone-number-privacy/combiner/src/domain/services/threshold-state.ts delete mode 100644 packages/phone-number-privacy/combiner/src/match-making/get-contact-matches.ts create mode 100644 packages/phone-number-privacy/combiner/src/pnp/endpoints/quota/action.ts create mode 100644 packages/phone-number-privacy/combiner/src/pnp/endpoints/quota/io.ts create mode 100644 packages/phone-number-privacy/combiner/src/pnp/endpoints/sign/action.ts create mode 100644 packages/phone-number-privacy/combiner/src/pnp/endpoints/sign/io.legacy.ts create mode 100644 packages/phone-number-privacy/combiner/src/pnp/endpoints/sign/io.ts create mode 100644 packages/phone-number-privacy/combiner/src/pnp/services/log-responses.ts create mode 100644 packages/phone-number-privacy/combiner/src/pnp/services/threshold-state.ts create mode 100644 packages/phone-number-privacy/combiner/src/server.ts delete mode 100644 packages/phone-number-privacy/combiner/src/signing/get-threshold-signature.ts delete mode 100644 packages/phone-number-privacy/combiner/src/web3/contracts.ts delete mode 100644 packages/phone-number-privacy/combiner/test/end-to-end/get-contact-matches.test.ts delete mode 100644 packages/phone-number-privacy/combiner/test/index.test.ts create mode 100644 packages/phone-number-privacy/combiner/test/integration/domain.test.ts create mode 100644 packages/phone-number-privacy/combiner/test/integration/legacypnp.test.ts create mode 100644 packages/phone-number-privacy/combiner/test/integration/pnp.test.ts rename packages/phone-number-privacy/combiner/test/{signing => unit}/bls-signature.test.ts (79%) create mode 100644 packages/phone-number-privacy/combiner/test/unit/domain-response-logger.test.ts create mode 100644 packages/phone-number-privacy/combiner/test/unit/domain-threshold-state.test.ts create mode 100644 packages/phone-number-privacy/combiner/test/unit/pnp-response-logger.test.ts create mode 100644 packages/phone-number-privacy/combiner/test/unit/pnp-threshold-state.test.ts create mode 100644 packages/phone-number-privacy/common/src/interfaces/endpoints.ts delete mode 100644 packages/phone-number-privacy/common/src/interfaces/error-utils.ts create mode 100644 packages/phone-number-privacy/common/src/interfaces/errors.ts rename packages/phone-number-privacy/common/src/utils/{config-utils.ts => config.utils.ts} (100%) create mode 100644 packages/phone-number-privacy/common/src/utils/contracts.ts create mode 100644 packages/phone-number-privacy/common/src/utils/key-version.ts create mode 100644 packages/phone-number-privacy/common/src/utils/responses.utils.ts rename packages/phone-number-privacy/common/{src/domains => test}/domains.test.ts (88%) create mode 100644 packages/phone-number-privacy/common/test/utils/key-version.test.ts rename packages/phone-number-privacy/monitor/src/scripts/{runLoadTest.ts => run-load-test.ts} (100%) delete mode 100644 packages/phone-number-privacy/signer/azure-templates/README.md delete mode 100644 packages/phone-number-privacy/signer/azure-templates/container-parameters.json delete mode 100644 packages/phone-number-privacy/signer/azure-templates/container-template.json delete mode 100644 packages/phone-number-privacy/signer/azure-templates/db-parameters.json delete mode 100644 packages/phone-number-privacy/signer/azure-templates/db-template.json delete mode 100644 packages/phone-number-privacy/signer/azure-templates/frontdoor-template.json delete mode 100644 packages/phone-number-privacy/signer/azure-templates/prometheus-alfajores.yaml delete mode 100644 packages/phone-number-privacy/signer/azure-templates/prometheus-mainnet.yaml delete mode 100644 packages/phone-number-privacy/signer/azure-templates/prometheus-parameters.json delete mode 100644 packages/phone-number-privacy/signer/azure-templates/prometheus-service-account-key.json delete mode 100644 packages/phone-number-privacy/signer/azure-templates/prometheus-staging.yaml delete mode 100644 packages/phone-number-privacy/signer/azure-templates/prometheus-template.json create mode 100644 packages/phone-number-privacy/signer/scripts/poprf-keygen.ts rename packages/phone-number-privacy/signer/scripts/{create-bls-keys.ts => threshold-bls-keygen.ts} (70%) create mode 100644 packages/phone-number-privacy/signer/src/common/action.ts rename packages/phone-number-privacy/signer/src/{ => common}/bls/bls-cryptography-client.ts (83%) create mode 100644 packages/phone-number-privacy/signer/src/common/controller.ts rename packages/phone-number-privacy/signer/src/{ => common}/database/database.ts (50%) rename packages/phone-number-privacy/signer/src/{ => common/database}/migrations/20200330212224_create-accounts-table.ts (55%) rename packages/phone-number-privacy/signer/src/{migrations/20200811163913_create_requests_table.ts => common/database/migrations/20200811163913_create-requests-table.ts} (65%) create mode 100644 packages/phone-number-privacy/signer/src/common/database/migrations/20210421212301_create-indices.ts create mode 100644 packages/phone-number-privacy/signer/src/common/database/migrations/20210921173354_create-domain-state.ts create mode 100644 packages/phone-number-privacy/signer/src/common/database/migrations/20220119165335_domain-requests.ts create mode 100644 packages/phone-number-privacy/signer/src/common/database/migrations/20220923161710_pnp-requests-onchain.ts create mode 100644 packages/phone-number-privacy/signer/src/common/database/migrations/20220923165433_pnp-accounts-onchain.ts create mode 100644 packages/phone-number-privacy/signer/src/common/database/models/account.ts create mode 100644 packages/phone-number-privacy/signer/src/common/database/models/domain-request.ts create mode 100644 packages/phone-number-privacy/signer/src/common/database/models/domain-state.ts create mode 100644 packages/phone-number-privacy/signer/src/common/database/models/request.ts create mode 100644 packages/phone-number-privacy/signer/src/common/database/utils.ts create mode 100644 packages/phone-number-privacy/signer/src/common/database/wrappers/account.ts create mode 100644 packages/phone-number-privacy/signer/src/common/database/wrappers/domain-request.ts create mode 100644 packages/phone-number-privacy/signer/src/common/database/wrappers/domain-state.ts create mode 100644 packages/phone-number-privacy/signer/src/common/database/wrappers/request.ts delete mode 100644 packages/phone-number-privacy/signer/src/common/domain/domainState.mapper.ts delete mode 100644 packages/phone-number-privacy/signer/src/common/error-utils.ts create mode 100644 packages/phone-number-privacy/signer/src/common/io.ts create mode 100644 packages/phone-number-privacy/signer/src/common/key-management/aws-key-provider.ts create mode 100644 packages/phone-number-privacy/signer/src/common/key-management/azure-key-provider.ts create mode 100644 packages/phone-number-privacy/signer/src/common/key-management/google-key-provider.ts create mode 100644 packages/phone-number-privacy/signer/src/common/key-management/key-provider-base.ts create mode 100644 packages/phone-number-privacy/signer/src/common/key-management/key-provider.ts create mode 100644 packages/phone-number-privacy/signer/src/common/key-management/mock-key-provider.ts create mode 100644 packages/phone-number-privacy/signer/src/common/quota.ts create mode 100644 packages/phone-number-privacy/signer/src/common/web3/contracts.ts delete mode 100644 packages/phone-number-privacy/signer/src/database/models/account.ts delete mode 100644 packages/phone-number-privacy/signer/src/database/models/domainState.ts delete mode 100644 packages/phone-number-privacy/signer/src/database/models/request.ts delete mode 100644 packages/phone-number-privacy/signer/src/database/wrappers/account.ts delete mode 100644 packages/phone-number-privacy/signer/src/database/wrappers/domainState.ts delete mode 100644 packages/phone-number-privacy/signer/src/database/wrappers/request.ts delete mode 100644 packages/phone-number-privacy/signer/src/domain/auth/domainAuth.interface.ts delete mode 100644 packages/phone-number-privacy/signer/src/domain/auth/domainAuth.service.ts delete mode 100644 packages/phone-number-privacy/signer/src/domain/domain.interface.ts delete mode 100644 packages/phone-number-privacy/signer/src/domain/domain.service.ts create mode 100644 packages/phone-number-privacy/signer/src/domain/endpoints/disable/action.ts create mode 100644 packages/phone-number-privacy/signer/src/domain/endpoints/disable/io.ts create mode 100644 packages/phone-number-privacy/signer/src/domain/endpoints/quota/action.ts create mode 100644 packages/phone-number-privacy/signer/src/domain/endpoints/quota/io.ts create mode 100644 packages/phone-number-privacy/signer/src/domain/endpoints/sign/action.ts create mode 100644 packages/phone-number-privacy/signer/src/domain/endpoints/sign/io.ts delete mode 100644 packages/phone-number-privacy/signer/src/domain/quota/domainQuota.interface.ts delete mode 100644 packages/phone-number-privacy/signer/src/domain/quota/domainQuota.service.ts create mode 100644 packages/phone-number-privacy/signer/src/domain/services/quota.ts create mode 100644 packages/phone-number-privacy/signer/src/domain/session.ts delete mode 100644 packages/phone-number-privacy/signer/src/key-management/aws-key-provider.ts delete mode 100644 packages/phone-number-privacy/signer/src/key-management/azure-key-provider.ts delete mode 100644 packages/phone-number-privacy/signer/src/key-management/google-key-provider.ts delete mode 100644 packages/phone-number-privacy/signer/src/key-management/key-provider-base.ts delete mode 100644 packages/phone-number-privacy/signer/src/key-management/key-provider.ts delete mode 100644 packages/phone-number-privacy/signer/src/key-management/mock-key-provider.ts delete mode 100644 packages/phone-number-privacy/signer/src/migrations/20210421212301_create-indices.ts delete mode 100644 packages/phone-number-privacy/signer/src/migrations/20210921173354_create-domain-state.ts create mode 100644 packages/phone-number-privacy/signer/src/pnp/endpoints/quota/action.ts create mode 100644 packages/phone-number-privacy/signer/src/pnp/endpoints/quota/io.legacy.ts create mode 100644 packages/phone-number-privacy/signer/src/pnp/endpoints/quota/io.ts create mode 100644 packages/phone-number-privacy/signer/src/pnp/endpoints/sign/action.legacy.ts create mode 100644 packages/phone-number-privacy/signer/src/pnp/endpoints/sign/action.onchain.ts create mode 100644 packages/phone-number-privacy/signer/src/pnp/endpoints/sign/action.ts create mode 100644 packages/phone-number-privacy/signer/src/pnp/endpoints/sign/io.legacy.ts create mode 100644 packages/phone-number-privacy/signer/src/pnp/endpoints/sign/io.ts create mode 100644 packages/phone-number-privacy/signer/src/pnp/services/quota.legacy.ts create mode 100644 packages/phone-number-privacy/signer/src/pnp/services/quota.onchain.ts create mode 100644 packages/phone-number-privacy/signer/src/pnp/services/quota.ts create mode 100644 packages/phone-number-privacy/signer/src/pnp/session.ts delete mode 100644 packages/phone-number-privacy/signer/src/signing/get-partial-signature.ts delete mode 100644 packages/phone-number-privacy/signer/src/signing/query-quota.ts delete mode 100644 packages/phone-number-privacy/signer/src/web3/contracts.ts delete mode 100644 packages/phone-number-privacy/signer/test/domain/domain.test.ts create mode 100644 packages/phone-number-privacy/signer/test/end-to-end/domain/domain.service.test.ts delete mode 100644 packages/phone-number-privacy/signer/test/index.test.ts create mode 100644 packages/phone-number-privacy/signer/test/integration/domain.test.ts create mode 100644 packages/phone-number-privacy/signer/test/integration/legacypnp.test.ts create mode 100644 packages/phone-number-privacy/signer/test/integration/pnp.test.ts delete mode 100644 packages/phone-number-privacy/signer/test/signing/query-quota.test.ts delete mode 100644 packages/sdk/identity/src/odis/matchmaking.test.ts delete mode 100644 packages/sdk/identity/src/odis/matchmaking.ts create mode 100644 packages/sdk/identity/src/odis/quota.test.ts create mode 100644 packages/sdk/identity/src/odis/quota.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index 051a28fa2b0..ad19a7e38fd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -26,7 +26,7 @@ reference: defaults: &defaults working_directory: ~/app docker: - - image: us.gcr.io/celo-testnet/circleci-node12:1.0.0 + - image: us.gcr.io/celo-testnet/circleci-node12:1.1.0 environment: # To avoid ENOMEM problem when running node NODE_OPTIONS: '--max-old-space-size=4096' @@ -727,7 +727,7 @@ jobs: test-typescript-npm-package-install: working_directory: ~/app docker: - - image: us.gcr.io/celo-testnet/circleci-node12:1.0.0 + - image: us.gcr.io/celo-testnet/circleci-node12:1.1.0 steps: - run: name: Check if the test should run @@ -740,7 +740,7 @@ jobs: test-utils-npm-package-install: working_directory: ~/app docker: - - image: us.gcr.io/celo-testnet/circleci-node12:1.0.0 + - image: us.gcr.io/celo-testnet/circleci-node12:1.1.0 steps: - run: name: Check if the test should run @@ -753,7 +753,7 @@ jobs: test-contractkit-npm-package-install: working_directory: ~/app docker: - - image: us.gcr.io/celo-testnet/circleci-node12:1.0.0 + - image: us.gcr.io/celo-testnet/circleci-node12:1.1.0 steps: - run: name: Check if the test should run @@ -769,7 +769,7 @@ jobs: test-celocli-npm-package-install: working_directory: ~/app docker: - - image: us.gcr.io/celo-testnet/circleci-node12:1.0.0 + - image: us.gcr.io/celo-testnet/circleci-node12:1.1.0 steps: - run: name: Check if the test should run @@ -782,7 +782,7 @@ jobs: name: Minor test of celocli command: ./node_modules/.bin/celocli account:new # Small test - phone-number-privacy-test: + odis-test: <<: *defaults steps: - attach_workspace: @@ -790,13 +790,16 @@ jobs: - run: name: Check if the test should run command: | - ./scripts/ci_check_if_test_should_run_v2.sh @celo/phone-number-privacy-signer,@celo/phone-number-privacy-combiner + ./scripts/ci_check_if_test_should_run_v2.sh @celo/phone-number-privacy-signer,@celo/phone-number-privacy-combiner,@celo/phone-number-privacy-common + - run: + name: Run Tests for common package + command: yarn --cwd=packages/phone-number-privacy/common test:coverage - run: name: Run Tests for combiner - command: yarn --cwd=packages/phone-number-privacy/combiner test + command: yarn --cwd=packages/phone-number-privacy/combiner test:coverage - run: name: Run Tests for signer - command: yarn --cwd=packages/phone-number-privacy/signer test + command: yarn --cwd=packages/phone-number-privacy/signer test:coverage certora-test: working_directory: ~/app @@ -979,7 +982,7 @@ workflows: requires: - lint-checks - contractkit-test - - phone-number-privacy-test: + - odis-test: requires: - lint-checks - flakey-test-summary: @@ -999,7 +1002,7 @@ workflows: - end-to-end-geth-sync-test - end-to-end-geth-validator-order-test - end-to-end-cip35-eth-compatibility-test - - phone-number-privacy-test + - odis-test npm-install-testing-cron-workflow: triggers: - schedule: diff --git a/.env b/.env index 4a2e209c7a8..73dd42eabc6 100644 --- a/.env +++ b/.env @@ -207,6 +207,9 @@ CONTEXTS=azure-odis0-centralus,azure-odis1-centralus,azure-odis2-centralus ODIS_SIGNER_DOCKER_IMAGE_REPOSITORY=us.gcr.io/celo-testnet/celo-monorepo ODIS_SIGNER_DOCKER_IMAGE_TAG=oblivious-decentralized-identifier-service-1.1.10 ODIS_SIGNER_BLOCKCHAIN_PROVIDER=https://alfajores-forno.celo-testnet.org +ODIS_SIGNER_DOMAINS_API_ENABLED=true +ODIS_SIGNER_PNP_API_ENABLED=true +ODIS_SIGNER_LEGACY_PNP_API_ENABLED=true # ODIS signer 0 Azure info AZURE_ODIS0_CENTRALUS_AZURE_SUBSCRIPTION_ID=97e2b592-255b-4f92-bce0-127257163c36 @@ -225,6 +228,10 @@ AZURE_ODIS0_CENTRALUS_ODIS_SIGNER_DB_USERNAME=pgpnp@staging-pgpnp-centralus # ODIS signer 0 Key Vault AZURE_ODIS0_CENTRALUS_ODIS_SIGNER_AZURE_KEYVAULT_NAME=staging-pgpnp-cus AZURE_ODIS0_CENTRALUS_ODIS_SIGNER_AZURE_KEYVAULT_SECRET_NAME=bls-share +AZURE_ODIS0_CENTRALUS_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_NAME_BASE=phoneNumberPrivacy0 +AZURE_ODIS0_CENTRALUS_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_LATEST_VERSION=1 +AZURE_ODIS0_CENTRALUS_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_NAME_BASE=domains0 +AZURE_ODIS0_CENTRALUS_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_LATEST_VERSION=1 # ODIS signer 0 Network AZURE_ODIS0_CENTRALUS_ODIS_NETWORK=staging @@ -256,6 +263,10 @@ AZURE_ODIS1_CENTRALUS_ODIS_SIGNER_DB_USERNAME=pgpnp@staging-pgpnp1-centralus # ODIS Signer 1 Key Vault AZURE_ODIS1_CENTRALUS_ODIS_SIGNER_AZURE_KEYVAULT_NAME=staging-pgpnp-cus AZURE_ODIS1_CENTRALUS_ODIS_SIGNER_AZURE_KEYVAULT_SECRET_NAME=bls-share1 +AZURE_ODIS1_CENTRALUS_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_NAME_BASE=phoneNumberPrivacy1 +AZURE_ODIS1_CENTRALUS_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_LATEST_VERSION=1 +AZURE_ODIS1_CENTRALUS_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_NAME_BASE=domains1 +AZURE_ODIS1_CENTRALUS_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_LATEST_VERSION=1 # ODIS Signer 1 Prometheus config AZURE_ODIS1_CENTRALUS_PROM_SCRAPE_JOB_NAME=scrape-odis @@ -283,6 +294,10 @@ AZURE_ODIS2_CENTRALUS_ODIS_SIGNER_DB_USERNAME=pgpnp@staging-pgpnp2-centralus # ODIS Signer 2 Key Vault AZURE_ODIS2_CENTRALUS_ODIS_SIGNER_AZURE_KEYVAULT_NAME=staging-pgpnp-cus AZURE_ODIS2_CENTRALUS_ODIS_SIGNER_AZURE_KEYVAULT_SECRET_NAME=bls-share2 +AZURE_ODIS2_CENTRALUS_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_NAME_BASE=phoneNumberPrivacy2 +AZURE_ODIS2_CENTRALUS_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_LATEST_VERSION=1 +AZURE_ODIS2_CENTRALUS_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_NAME_BASE=domains2 +AZURE_ODIS2_CENTRALUS_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_LATEST_VERSION=1 # ODIS Signer 2 Prometheus config AZURE_ODIS2_CENTRALUS_PROM_SCRAPE_JOB_NAME=scrape-odis diff --git a/.env.alfajores b/.env.alfajores index a0b25998f59..a4e7bdf3108 100644 --- a/.env.alfajores +++ b/.env.alfajores @@ -54,10 +54,10 @@ AZURE_ORACLE_CENTRALUS_FULL_NODES_WS_PORT="8546" # Temporarily point to celo-org repository to consume patched image. GETH_NODE_DOCKER_IMAGE_REPOSITORY="us.gcr.io/celo-org/geth" -GETH_NODE_DOCKER_IMAGE_TAG="1.6.0" +GETH_NODE_DOCKER_IMAGE_TAG="1.7.0" GETH_BOOTNODE_DOCKER_IMAGE_REPOSITORY="us.gcr.io/celo-org/geth-all" -GETH_BOOTNODE_DOCKER_IMAGE_TAG="1.6.0" +GETH_BOOTNODE_DOCKER_IMAGE_TAG="1.7.0" # Enable pprof and prometheus scrape labels GETH_ENABLE_METRICS=true @@ -124,7 +124,7 @@ FAUCET_CUSD_WEI=60000000000000000000000 VALIDATORS=10 VALIDATOR_PROXY_COUNTS=10:0 -TX_NODES=10 +TX_NODES=4 # Nodes whose RPC ports are only internally exposed PRIVATE_TX_NODES=2 STATIC_IPS_FOR_GETH_NODES=true @@ -180,6 +180,10 @@ AZURE_ODIS_EASTUS_1_ODIS_SIGNER_DB_USERNAME=cLabs@pgpnp-alfajores-db1v2 # ODIS signer 1 Key Vault AZURE_ODIS_EASTUS_1_ODIS_SIGNER_AZURE_KEYVAULT_NAME=pgpnp-alfajores-kv1 AZURE_ODIS_EASTUS_1_ODIS_SIGNER_AZURE_KEYVAULT_SECRET_NAME=bls-share +AZURE_ODIS_EASTUS_1_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_NAME_BASE=phoneNumberPrivacy +AZURE_ODIS_EASTUS_1_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_LATEST_VERSION=1 +AZURE_ODIS_EASTUS_1_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_NAME_BASE=domains +AZURE_ODIS_EASTUS_1_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_LATEST_VERSION=1 # ODIS signer 1 Network AZURE_ODIS_EASTUS_1_ODIS_NETWORK=alfajores @@ -210,6 +214,10 @@ AZURE_ODIS_EASTUS_2_ODIS_SIGNER_DB_USERNAME=clabs@pgpnp-alfajores-db2v2 # ODIS signer 2 Key Vault AZURE_ODIS_EASTUS_2_ODIS_SIGNER_AZURE_KEYVAULT_NAME=pgpnp-alfajores-kv2 AZURE_ODIS_EASTUS_2_ODIS_SIGNER_AZURE_KEYVAULT_SECRET_NAME=bls-share +AZURE_ODIS_EASTUS_2_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_NAME_BASE=phoneNumberPrivacy +AZURE_ODIS_EASTUS_2_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_LATEST_VERSION=1 +AZURE_ODIS_EASTUS_2_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_NAME_BASE=domains +AZURE_ODIS_EASTUS_2_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_LATEST_VERSION=1 # ODIS signer 2 Network AZURE_ODIS_EASTUS_2_ODIS_NETWORK=alfajores @@ -240,6 +248,10 @@ AZURE_ODIS_EASTUS_3_ODIS_SIGNER_DB_USERNAME=cLabs@pgpnp-alfajores-db3v2 # ODIS signer 3 Key Vault AZURE_ODIS_EASTUS_3_ODIS_SIGNER_AZURE_KEYVAULT_NAME=pgpnp-alfajores-kv3 AZURE_ODIS_EASTUS_3_ODIS_SIGNER_AZURE_KEYVAULT_SECRET_NAME=bls-share +AZURE_ODIS_EASTUS_3_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_NAME_BASE=phoneNumberPrivacy +AZURE_ODIS_EASTUS_3_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_LATEST_VERSION=1 +AZURE_ODIS_EASTUS_3_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_SECRET_NAME=domains +AZURE_ODIS_EASTUS_3_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_LATEST_VERSION=1 # ODIS signer 3 Network AZURE_ODIS_EASTUS_3_ODIS_NETWORK=alfajores diff --git a/.env.baklava b/.env.baklava index 35727ca0ef5..c8111da8250 100644 --- a/.env.baklava +++ b/.env.baklava @@ -25,10 +25,10 @@ CELOSTATS_BANNED_ADDRESSES="" CELOSTATS_RESERVED_ADDRESSES="" GETH_NODE_DOCKER_IMAGE_REPOSITORY="us.gcr.io/celo-org/geth" -GETH_NODE_DOCKER_IMAGE_TAG="1.6.0" +GETH_NODE_DOCKER_IMAGE_TAG="1.7.0" GETH_BOOTNODE_DOCKER_IMAGE_REPOSITORY="us.gcr.io/celo-org/geth-all" -GETH_BOOTNODE_DOCKER_IMAGE_TAG="1.6.0" +GETH_BOOTNODE_DOCKER_IMAGE_TAG="1.7.0" CELOTOOL_DOCKER_IMAGE_REPOSITORY="gcr.io/celo-testnet/celo-monorepo" CELOTOOL_DOCKER_IMAGE_TAG="celotool-4257fe61f91e935681f3a91bb4dcb44c8dd6df47" diff --git a/.env.rc1 b/.env.rc1 index fe0eb58d5cb..e0d4f7ad0a1 100644 --- a/.env.rc1 +++ b/.env.rc1 @@ -34,12 +34,12 @@ CELOSTATS_RESERVED_ADDRESSES="" GETH_NODE_DOCKER_IMAGE_REPOSITORY="us.gcr.io/celo-org/geth" # When upgrading change this to latest commit hash from the master of the geth repo # `geth $ git show | head -n 1` -GETH_NODE_DOCKER_IMAGE_TAG="1.5.6" +GETH_NODE_DOCKER_IMAGE_TAG="1.7.0" GETH_BOOTNODE_DOCKER_IMAGE_REPOSITORY="us.gcr.io/celo-org/geth-all" # When upgrading change this to latest commit hash from the master of the geth repo # `geth $ git show | head -n 1` -GETH_BOOTNODE_DOCKER_IMAGE_TAG="1.5.5" +GETH_BOOTNODE_DOCKER_IMAGE_TAG="1.7.0" CELOTOOL_DOCKER_IMAGE_REPOSITORY="gcr.io/celo-testnet/celo-monorepo" CELOTOOL_DOCKER_IMAGE_TAG="celotool-4257fe61f91e935681f3a91bb4dcb44c8dd6df47" @@ -357,6 +357,10 @@ AZURE_ODIS_WESTUS2_A_ODIS_SIGNER_DB_USERNAME=clabs@mainnet-pgpnp-db-westus2 # ODIS signer WESTUS2 A Key Vault AZURE_ODIS_WESTUS2_A_ODIS_SIGNER_AZURE_KEYVAULT_NAME=mainnet-pgpnp-westus2 AZURE_ODIS_WESTUS2_A_ODIS_SIGNER_AZURE_KEYVAULT_SECRET_NAME=bls-share +AZURE_ODIS_WESTUS2_A_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_NAME_BASE=phoneNumberPrivacy +AZURE_ODIS_WESTUS2_A_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_LATEST_VERSION=1 +AZURE_ODIS_WESTUS2_A_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_NAME_BASE=domains +AZURE_ODIS_WESTUS2_A_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_LATEST_VERSION=1 # ODIS signer WESTUS2 A Network AZURE_ODIS_WESTUS2_A_ODIS_NETWORK=mainnet @@ -387,6 +391,10 @@ AZURE_ODIS_WESTEUROPE_A_ODIS_SIGNER_DB_USERNAME=cLabs@mainnet-pgpnp-westeurope # ODIS signer WESTEUROPE A Key Vault AZURE_ODIS_WESTEUROPE_A_ODIS_SIGNER_AZURE_KEYVAULT_NAME=mainnet-pgpnp-westeurope AZURE_ODIS_WESTEUROPE_A_ODIS_SIGNER_AZURE_KEYVAULT_SECRET_NAME=bls-share +AZURE_ODIS_WESTEUROPE_A_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_NAME_BASE=phoneNumberPrivacy +AZURE_ODIS_WESTEUROPE_A_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_LATEST_VERSION=1 +AZURE_ODIS_WESTEUROPE_A_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_NAME_BASE=domains +AZURE_ODIS_WESTEUROPE_A_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_LATEST_VERSION=1 # ODIS signer WESTEUROPE A Network AZURE_ODIS_WESTEUROPE_A_ODIS_NETWORK=mainnet @@ -417,6 +425,10 @@ AZURE_ODIS_EASTASIA_A_ODIS_SIGNER_DB_USERNAME=clabs@mainnet-pgpnp-db-eastasia # ODIS signer EASTASIA A Key Vault AZURE_ODIS_EASTASIA_A_ODIS_SIGNER_AZURE_KEYVAULT_NAME=mainnet-pgpnp-eastasia AZURE_ODIS_EASTASIA_A_ODIS_SIGNER_AZURE_KEYVAULT_SECRET_NAME=bls-share +AZURE_ODIS_EASTASIA_A_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_NAME_BASE=phoneNumberPrivacy +AZURE_ODIS_EASTASIA_A_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_LATEST_VERSION=1 +AZURE_ODIS_EASTASIA_A_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_NAME_BASE=domains +AZURE_ODIS_EASTASIA_A_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_LATEST_VERSION=1 # ODIS signer EASTASIA A Network AZURE_ODIS_EASTASIA_A_ODIS_NETWORK=mainnet @@ -447,6 +459,10 @@ AZURE_ODIS_BRAZILSOUTH_A_ODIS_SIGNER_DB_USERNAME=clabs@mainnet-pgpnp-db-brazilso # ODIS signer BRAZILSOUTH A Key Vault AZURE_ODIS_BRAZILSOUTH_A_ODIS_SIGNER_AZURE_KEYVAULT_NAME=mainnet-pgpnp-brazil AZURE_ODIS_BRAZILSOUTH_A_ODIS_SIGNER_AZURE_KEYVAULT_SECRET_NAME=bls-share +AZURE_ODIS_BRAZILSOUTH_A_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_NAME_BASE=phoneNumberPrivacy +AZURE_ODIS_BRAZILSOUTH_A_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_LATEST_VERSION=1 +AZURE_ODIS_BRAZILSOUTH_A_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_NAME_BASE=domains +AZURE_ODIS_BRAZILSOUTH_A_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_LATEST_VERSION=1 # ODIS signer BRAZILSOUTH A Network AZURE_ODIS_BRAZILSOUTH_A_ODIS_NETWORK=mainnet diff --git a/.vscode/extensions.json b/.vscode/extensions.json index e6c92b9177d..f24f6150e8a 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -13,7 +13,8 @@ "pkief.material-icon-theme", "davidanson.vscode-markdownlint", "mikestead.dotenv", - "coenraads.bracket-pair-colorizer-2" + "coenraads.bracket-pair-colorizer-2", + "markis.code-coverage" ], // List of extensions recommended by VS Code that should not be recommended for users of this workspace. "unwantedRecommendations": [] diff --git a/dependency-graph.json b/dependency-graph.json index 1d3dc824e5f..1a3092ab3c4 100644 --- a/dependency-graph.json +++ b/dependency-graph.json @@ -102,6 +102,7 @@ "@celo/flake-tracker", "@celo/identity", "@celo/phone-number-privacy-common", + "@celo/phone-number-privacy-signer", "@celo/utils" ] }, diff --git a/dockerfiles/circleci/circleci-node12/Dockerfile b/dockerfiles/circleci/circleci-node12/Dockerfile index 49b25486082..02415aa8e12 100644 --- a/dockerfiles/circleci/circleci-node12/Dockerfile +++ b/dockerfiles/circleci/circleci-node12/Dockerfile @@ -1,4 +1,4 @@ -FROM circleci/node:12 +FROM circleci/node:12.22 RUN sudo apt-get update -y RUN sudo apt-get install lsb-release libudev-dev libusb-dev libusb-1.0-0 rsync -y diff --git a/package.json b/package.json index b62fa4837c9..e3408fcefa7 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,7 @@ "react-native-flipper": "^0.70.0", "react-native-ntp-client": "^1.0.0", "set-value": "^3.0.2", - "sha3": "1.2.3", + "sha3": "1.2.6", "tar": "4.4.15", "ua-parser-js": "0.7.28", "underscore": "^1.12.1", diff --git a/packages/attestation-service/package.json b/packages/attestation-service/package.json index b7b492d56ea..ac9315f071d 100644 --- a/packages/attestation-service/package.json +++ b/packages/attestation-service/package.json @@ -56,7 +56,6 @@ "pg-hstore": "2.3.3", "prom-client": "11.2.0", "sequelize": "5.21.5", - "sqlite3": "4.0.9", "ts-mockito": "^2.6.1", "twilio": "^3.57.0", "web3": "1.3.6", @@ -73,6 +72,7 @@ "@types/node-fetch": "2.5.12", "nodemon": "1.19.1", "sequelize-cli": "^5.5.1", + "sqlite3": "4.0.9", "ts-node": "8.3.0", "webpack": "4.39.1", "webpack-cli": "3.3.6" @@ -80,4 +80,4 @@ "engines": { "node": ">=8.13.0" } -} \ No newline at end of file +} diff --git a/packages/celotool/src/lib/env-utils.ts b/packages/celotool/src/lib/env-utils.ts index e6ed705cdc2..ccb68093e75 100644 --- a/packages/celotool/src/lib/env-utils.ts +++ b/packages/celotool/src/lib/env-utils.ts @@ -119,6 +119,9 @@ export enum envVar { ODIS_SIGNER_DOCKER_IMAGE_REPOSITORY = 'ODIS_SIGNER_DOCKER_IMAGE_REPOSITORY', ODIS_SIGNER_DOCKER_IMAGE_TAG = 'ODIS_SIGNER_DOCKER_IMAGE_TAG', ODIS_SIGNER_BLOCKCHAIN_PROVIDER = 'ODIS_SIGNER_BLOCKCHAIN_PROVIDER', + ODIS_SIGNER_DOMAINS_API_ENABLED = 'ODIS_SIGNER_DOMAINS_API_ENABLED', + ODIS_SIGNER_PNP_API_ENABLED = 'ODIS_SIGNER_PNP_API_ENABLED', + ODIS_SIGNER_LEGACY_PNP_API_ENABLED = 'ODIS_SIGNER_LEGACY_PNP_API_ENABLED', ORACLE_DOCKER_IMAGE_REPOSITORY = 'ORACLE_DOCKER_IMAGE_REPOSITORY', ORACLE_DOCKER_IMAGE_TAG = 'ORACLE_DOCKER_IMAGE_TAG', ORACLE_UNUSED_ORACLE_ADDRESSES = 'ORACLE_UNUSED_ORACLE_ADDRESSES', @@ -176,6 +179,7 @@ export enum envVar { * Dynamic env vars are env var names that can be dynamically constructed * using templates. */ + export enum DynamicEnvVar { AWS_CLUSTER_REGION = '{{ context }}_AWS_KUBERNETES_CLUSTER_REGION', AWS_RESOURCE_GROUP_TAG = '{{ context }}_AWS_KUBERNETES_RESOURCE_GROUP', @@ -217,6 +221,13 @@ export enum DynamicEnvVar { ODIS_SIGNER_BLOCKCHAIN_API_KEY = '{{ context }}_ODIS_SIGNER_BLOCKCHAIN_API_KEY', ODIS_SIGNER_AZURE_KEYVAULT_NAME = '{{ context }}_ODIS_SIGNER_AZURE_KEYVAULT_NAME', ODIS_SIGNER_AZURE_KEYVAULT_SECRET_NAME = '{{ context }}_ODIS_SIGNER_AZURE_KEYVAULT_SECRET_NAME', + ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_NAME_BASE = '{{ context }}_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_NAME_BASE', + ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_LATEST_VERSION = '{{ context }}_ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_LATEST_VERSION', + ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_NAME_BASE = '{{ context }}_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_NAME_BASE', + ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_LATEST_VERSION = '{{ context }}_ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_LATEST_VERSION', + ODIS_SIGNER_DOMAINS_API_ENABLED = '{{ context }}_ODIS_SIGNER_DOMAINS_API_ENABLED', + ODIS_SIGNER_PHONE_NUMBER_PRIVACY_API_ENABLED = '{{ context }}_ODIS_SIGNER_PNP_API_ENABLED', + ODIS_SIGNER_LEGACY_PHONE_NUMBER_PRIVACY_API_ENABLED = '{{ context }}_ODIS_SIGNER_LEGACY_PNP_API_ENABLED', ODIS_SIGNER_DB_HOST = '{{ context }}_ODIS_SIGNER_DB_HOST', ODIS_SIGNER_DB_PORT = '{{ context }}_ODIS_SIGNER_DB_PORT', ODIS_SIGNER_DB_USERNAME = '{{ context }}_ODIS_SIGNER_DB_USERNAME', diff --git a/packages/celotool/src/lib/odis.ts b/packages/celotool/src/lib/odis.ts index 8438ad0d952..b309fa1898e 100644 --- a/packages/celotool/src/lib/odis.ts +++ b/packages/celotool/src/lib/odis.ts @@ -19,6 +19,10 @@ const helmChartPath = '../helm-charts/odis' interface ODISSignerKeyVaultConfig { vaultName: string secretName: string + pnpKeyNameBase: string + pnpKeyLatestVersion: string + domainsKeyNameBase: string + domainsKeyLatestVersion: string } /** @@ -59,6 +63,10 @@ const contextODISSignerKeyVaultConfigDynamicEnvVars: { } = { vaultName: DynamicEnvVar.ODIS_SIGNER_AZURE_KEYVAULT_NAME, secretName: DynamicEnvVar.ODIS_SIGNER_AZURE_KEYVAULT_SECRET_NAME, + pnpKeyNameBase: DynamicEnvVar.ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_NAME_BASE, + pnpKeyLatestVersion: DynamicEnvVar.ODIS_SIGNER_AZURE_KEYVAULT_PNP_KEY_LATEST_VERSION, + domainsKeyNameBase: DynamicEnvVar.ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_NAME_BASE, + domainsKeyLatestVersion: DynamicEnvVar.ODIS_SIGNER_AZURE_KEYVAULT_DOMAINS_KEY_LATEST_VERSION, } /** @@ -159,6 +167,13 @@ async function helmParameters(celoEnv: string, context: string) { `--set db.password='${databaseConfig.password}'`, `--set keystore.vaultName=${keyVaultConfig.vaultName}`, `--set keystore.secretName=${keyVaultConfig.secretName}`, + `--set keystore.pnpKeyNameBase=${keyVaultConfig.pnpKeyNameBase}`, + `--set keystore.domainsKeyNameBase=${keyVaultConfig.domainsKeyNameBase}`, + `--set keystore.pnpKeyLatestVersion=${keyVaultConfig.pnpKeyLatestVersion}`, + `--set keystore.domainsKeyLatestVersion=${keyVaultConfig.domainsKeyLatestVersion}`, + `--set api.pnpAPIEnabled=${fetchEnv(envVar.ODIS_SIGNER_PNP_API_ENABLED)}`, + `--set api.legacyPnpAPIEnabled=${fetchEnv(envVar.ODIS_SIGNER_LEGACY_PNP_API_ENABLED)}`, + `--set api.domainsAPIEnabled=${fetchEnv(envVar.ODIS_SIGNER_DOMAINS_API_ENABLED)}`, `--set blockchainProvider=${fetchEnv(envVar.ODIS_SIGNER_BLOCKCHAIN_PROVIDER)}`, `--set blockchainApiKey=${blockchainConfig.blockchainApiKey}`, `--set log.level=${loggingConfig.level}`, diff --git a/packages/helm-charts/odis/templates/signer-deployment.yaml b/packages/helm-charts/odis/templates/signer-deployment.yaml index b17fc9bf650..ec10c4181ea 100644 --- a/packages/helm-charts/odis/templates/signer-deployment.yaml +++ b/packages/helm-charts/odis/templates/signer-deployment.yaml @@ -47,6 +47,13 @@ spec: {{ include "common.env-var" (dict "name" "DB_USERNAME" "dict" .Values.db "value_name" "username") | indent 12 }} {{ include "common.env-var" (dict "name" "KEYSTORE_AZURE_VAULT_NAME" "dict" .Values.keystore "value_name" "vaultName") | indent 12 }} {{ include "common.env-var" (dict "name" "KEYSTORE_AZURE_SECRET_NAME" "dict" .Values.keystore "value_name" "secretName") | indent 12 }} +{{ include "common.env-var" (dict "name" "PHONE_NUMBER_PRIVACY_KEY_NAME_BASE" "dict" .Values.keystore "value_name" "pnpKeyNameBase") | indent 12 }} +{{ include "common.env-var" (dict "name" "DOMAINS_KEY_NAME_BASE" "dict" .Values.keystore "value_name" "domainsKeyNameBase") | indent 12 }} +{{ include "common.env-var" (dict "name" "PHONE_NUMBER_PRIVACY_LATEST_KEY_VERSION" "dict" .Values.keystore "value_name" "pnpKeyLatestVersion") | indent 12 }} +{{ include "common.env-var" (dict "name" "DOMAINS_LATEST_KEY_VERSION" "dict" .Values.keystore "value_name" "domainsKeyLatestVersion") | indent 12 }} +{{ include "common.env-var" (dict "name" "DOMAINS_API_ENABLED" "dict" .Values.api "value_name" "domainsAPIEnabled") | indent 12 }} +{{ include "common.env-var" (dict "name" "PHONE_NUMBER_PRIVACY_API_ENABLED" "dict" .Values.api "value_name" "pnpAPIEnabled") | indent 12 }} +{{ include "common.env-var" (dict "name" "LEGACY_PHONE_NUMBER_PRIVACY_API_ENABLED" "dict" .Values.api "value_name" "legacyPnpAPIEnabled") | indent 12 }} - name: DB_PASSWORD valueFrom: secretKeyRef: diff --git a/packages/phone-number-privacy/combiner/README.md b/packages/phone-number-privacy/combiner/README.md index 9ef3e53d213..5c9909368f4 100644 --- a/packages/phone-number-privacy/combiner/README.md +++ b/packages/phone-number-privacy/combiner/README.md @@ -25,5 +25,3 @@ Note: When you fill in the `host` field you may need to use the database's publi Always run migrations in staging first and ensure all e2e tests pass before migrating in alfajores and mainnet. Run `yarn db:migrate:` - -TODO: Figure out how to make migrations run automatically on deployment diff --git a/packages/phone-number-privacy/combiner/jest.config.js b/packages/phone-number-privacy/combiner/jest.config.js index 6036c030963..60af40428ce 100644 --- a/packages/phone-number-privacy/combiner/jest.config.js +++ b/packages/phone-number-privacy/combiner/jest.config.js @@ -4,4 +4,11 @@ module.exports = { preset: 'ts-jest', ...nodeFlakeTracking, setupFilesAfterEnv: ['/jest_setup.ts', ...nodeFlakeTracking.setupFilesAfterEnv], + coverageReporters: [['lcov', { projectRoot: '../../../' }], 'text'], + collectCoverageFrom: ['./src/**'], + coverageThreshold: { + global: { + lines: 80, + }, + }, } diff --git a/packages/phone-number-privacy/combiner/migrations/20200330212224_create-accounts-table.ts b/packages/phone-number-privacy/combiner/migrations/20200330212224_create-accounts-table.ts deleted file mode 100644 index 7d7e3b44523..00000000000 --- a/packages/phone-number-privacy/combiner/migrations/20200330212224_create-accounts-table.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as Knex from 'knex' -import { ACCOUNTS_COLUMNS, ACCOUNTS_TABLE } from '../src/database/models/account' - -export async function up(knex: Knex): Promise { - return knex.schema.createTable(ACCOUNTS_TABLE, (t) => { - t.string(ACCOUNTS_COLUMNS.address).notNullable().primary() - t.dateTime(ACCOUNTS_COLUMNS.createdAt).notNullable() - t.dateTime(ACCOUNTS_COLUMNS.didMatchmaking) - }) -} - -export async function down(knex: Knex): Promise { - return knex.schema.dropTable(ACCOUNTS_TABLE) -} diff --git a/packages/phone-number-privacy/combiner/migrations/20200330212301_create-number-pairs-table.ts b/packages/phone-number-privacy/combiner/migrations/20200330212301_create-number-pairs-table.ts deleted file mode 100644 index 8dfe8918d33..00000000000 --- a/packages/phone-number-privacy/combiner/migrations/20200330212301_create-number-pairs-table.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as Knex from 'knex' -import { NUMBER_PAIRS_COLUMN, NUMBER_PAIRS_TABLE } from '../src/database/models/numberPair' - -export async function up(knex: Knex): Promise { - return knex.schema.createTable(NUMBER_PAIRS_TABLE, (t) => { - t.string(NUMBER_PAIRS_COLUMN.userPhoneHash).notNullable() - t.string(NUMBER_PAIRS_COLUMN.contactPhoneHash).notNullable() - t.unique([NUMBER_PAIRS_COLUMN.userPhoneHash, NUMBER_PAIRS_COLUMN.contactPhoneHash]) - }) -} - -export async function down(knex: Knex): Promise { - return knex.schema.dropTable(NUMBER_PAIRS_TABLE) -} diff --git a/packages/phone-number-privacy/combiner/migrations/20210421212301_create-indices.ts b/packages/phone-number-privacy/combiner/migrations/20210421212301_create-indices.ts deleted file mode 100644 index 4c2a191e28e..00000000000 --- a/packages/phone-number-privacy/combiner/migrations/20210421212301_create-indices.ts +++ /dev/null @@ -1,27 +0,0 @@ -import * as Knex from 'knex' -import { ACCOUNTS_COLUMNS, ACCOUNTS_TABLE } from '../src/database/models/account' -import { NUMBER_PAIRS_COLUMN, NUMBER_PAIRS_TABLE } from '../src/database/models/numberPair' - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(NUMBER_PAIRS_TABLE))) { - throw new Error('Unexpected error: Could not find NUMBER_PAIRS_TABLE') - } - if (!(await knex.schema.hasTable(ACCOUNTS_TABLE))) { - throw new Error('Unexpected error: Could not find ACCOUNTS_TABLE') - } - await knex.schema.alterTable(NUMBER_PAIRS_TABLE, (t) => { - t.index(NUMBER_PAIRS_COLUMN.contactPhoneHash) - }) - return knex.schema.alterTable(ACCOUNTS_TABLE, (t) => { - t.index(ACCOUNTS_COLUMNS.address) - }) -} - -export async function down(knex: Knex): Promise { - await knex.schema.alterTable(NUMBER_PAIRS_TABLE, (t) => { - t.dropIndex(NUMBER_PAIRS_COLUMN.contactPhoneHash) - }) - return knex.schema.alterTable(ACCOUNTS_TABLE, (t) => { - t.dropIndex(ACCOUNTS_COLUMNS.address) - }) -} diff --git a/packages/phone-number-privacy/combiner/migrations/20210813105139_add_dekSigner_signedUserPhoneNumber_columns_to_accounts_table.ts b/packages/phone-number-privacy/combiner/migrations/20210813105139_add_dekSigner_signedUserPhoneNumber_columns_to_accounts_table.ts deleted file mode 100644 index d8a28af69d2..00000000000 --- a/packages/phone-number-privacy/combiner/migrations/20210813105139_add_dekSigner_signedUserPhoneNumber_columns_to_accounts_table.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as Knex from 'knex' -import { ACCOUNTS_COLUMNS, ACCOUNTS_TABLE } from '../src/database/models/account' - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(ACCOUNTS_TABLE))) { - throw new Error('Unexpected error: Could not find ACCOUNTS_TABLE') - } - return knex.schema.alterTable(ACCOUNTS_TABLE, (t) => { - t.string(ACCOUNTS_COLUMNS.signedUserPhoneNumber) - t.string(ACCOUNTS_COLUMNS.dekSigner) - }) -} - -export async function down(knex: Knex): Promise { - return knex.schema.alterTable(ACCOUNTS_TABLE, (t) => { - t.dropColumn(ACCOUNTS_COLUMNS.signedUserPhoneNumber) - t.dropColumn(ACCOUNTS_COLUMNS.dekSigner) - }) -} diff --git a/packages/phone-number-privacy/combiner/package.json b/packages/phone-number-privacy/combiner/package.json index 56481e84300..58d2475a4cd 100644 --- a/packages/phone-number-privacy/combiner/package.json +++ b/packages/phone-number-privacy/combiner/package.json @@ -1,6 +1,6 @@ { "name": "@celo/phone-number-privacy-combiner", - "version": "1.1.6", + "version": "2.0.0-dev", "description": "Orchestrates and combines threshold signatures for use in ODIS", "author": "Celo", "license": "Apache-2.0", @@ -20,30 +20,31 @@ "build": "tsc -b .", "lint": "tslint --project .", "test": "jest --runInBand --testPathIgnorePatterns test/end-to-end", + "test:coverage": "yarn test --coverage", + "test:integration": "jest --runInBand test/integration", "test:e2e": "jest test/end-to-end --verbose", "test:e2e:staging": "ODIS_COMBINER_SERVICE_URL=https://us-central1-celo-phone-number-privacy-stg.cloudfunctions.net yarn test:e2e", - "test:e2e:alfajores": "ODIS_COMBINER_SERVICE_URL=https://us-central1-celo-phone-number-privacy.cloudfunctions.net yarn test:e2e", - "db:migrate": "NODE_ENV=dev FIREBASE_CONFIG=./firebase.json ts-node ./scripts/run-migrations.ts", - "db:migrate:staging": "GCLOUD_PROJECT=celo-phone-number-privacy-stg yarn db:migrate", - "db:migrate:alfajores": "GCLOUD_PROJECT=celo-phone-number-privacy yarn db:migrate", - "db:migrate:mainnet": "GCLOUD_PROJECT=celo-pgpnp-mainnet yarn db:migrate", - "db:migrate:make": "knex --migrations-directory ./migrations migrate:make -x ts" + "test:e2e:alfajores": "ODIS_COMBINER_SERVICE_URL=https://us-central1-celo-phone-number-privacy.cloudfunctions.net yarn test:e2e" }, "dependencies": { "@celo/contractkit": "2.3.1-dev", - "@celo/identity": "2.3.1-dev", - "@celo/phone-number-privacy-common": "1.0.39", + "@celo/phone-number-privacy-common": "1.0.42-dev", "@celo/utils": "2.3.1-dev", "blind-threshold-bls": "https://github.com/celo-org/blind-threshold-bls-wasm#e1e2f8a", + "express": "^4.17.1", "firebase-admin": "^9.12.0", "firebase-functions": "^3.15.7", - "knex": "^0.21.1", + "knex": "^2.1.0", "node-fetch": "^2.6.1", "pg": "^8.2.1", "uuid": "^7.0.3" }, "devDependencies": { + "@celo/identity": "2.3.1-dev", + "@celo/phone-number-privacy-signer": "2.0.0-dev", "@types/btoa": "^1.2.3", + "@types/express": "^4.17.6", + "@types/supertest": "^2.0.12", "@types/uuid": "^7.0.3", "dotenv": "^8.2.0", "firebase-functions-test": "^0.3.3", @@ -53,6 +54,6 @@ "@celo/flake-tracker": "0.0.1-dev" }, "engines": { - "node": "12" + "node": ">=12" } } \ No newline at end of file diff --git a/packages/phone-number-privacy/combiner/scripts/run-migrations.ts b/packages/phone-number-privacy/combiner/scripts/run-migrations.ts deleted file mode 100644 index 90c34969525..00000000000 --- a/packages/phone-number-privacy/combiner/scripts/run-migrations.ts +++ /dev/null @@ -1,25 +0,0 @@ -// tslint:disable: no-console -// TODO de-dupe with signer script -import knex from 'knex' -import config from '../src/config' - -async function start() { - console.info('Running migrations') - await knex({ - client: 'pg', - connection: config.db, - }).migrate.latest({ - directory: './migrations', - extension: 'ts', - }) -} - -start() - .then(() => { - console.info('Migrations complete') - process.exit(0) - }) - .catch((e) => { - console.error('Migration failed', e) - process.exit(1) - }) diff --git a/packages/phone-number-privacy/combiner/src/common/action.ts b/packages/phone-number-privacy/combiner/src/common/action.ts new file mode 100644 index 00000000000..2bf519cb861 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/common/action.ts @@ -0,0 +1,8 @@ +import { OdisRequest } from '@celo/phone-number-privacy-common' +import { IO } from './io' +import { Session } from './session' + +export interface Action { + readonly io: IO + perform(session: Session): Promise +} diff --git a/packages/phone-number-privacy/combiner/src/common/combine.ts b/packages/phone-number-privacy/combiner/src/common/combine.ts new file mode 100644 index 00000000000..e99bc6bceff --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/common/combine.ts @@ -0,0 +1,146 @@ +import { + ErrorMessage, + OdisRequest, + OdisResponse, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { Response as FetchResponse } from 'node-fetch' +import { performance, PerformanceObserver } from 'perf_hooks' +import { OdisConfig } from '../config' +import { Action } from './action' +import { IO } from './io' +import { Session } from './session' + +export interface Signer { + url: string + fallbackUrl?: string +} + +export abstract class CombineAction implements Action { + readonly signers: Signer[] + public constructor(readonly config: OdisConfig, readonly io: IO) { + this.signers = JSON.parse(config.odisServices.signers) + } + + abstract combine(session: Session): void + + async perform(session: Session) { + await this.distribute(session) + this.combine(session) + } + + async distribute(session: Session): Promise { + const obs = new PerformanceObserver((list) => { + const entry = list.getEntries()[0] + session.logger.info( + { latency: entry, signer: entry!.name }, + 'Signer response latency measured' + ) + }) + obs.observe({ entryTypes: ['measure'], buffered: true }) + + const timeout = setTimeout(() => { + session.timedOut = true + session.abort.abort() + }, this.config.odisServices.timeoutMilliSeconds) + + // Forward request to signers + await Promise.all(this.signers.map((signer) => this.forwardToSigner(signer, session))) + // TODO Resolve race condition where a session can both receive a successful + // response in time and be aborted + + clearTimeout(timeout) + + performance.clearMarks() + obs.disconnect() + } + + protected async forwardToSigner(signer: Signer, session: Session): Promise { + let signerFetchResult: FetchResponse | undefined + try { + signerFetchResult = await this.io.fetchSignerResponseWithFallback(signer, session) + } catch (err) { + session.logger.debug({ err }, 'signer request failure') + if (err instanceof Error && err.name === 'AbortError' && session.abort.signal.aborted) { + if (session.timedOut) { + session.logger.error({ signer }, ErrorMessage.TIMEOUT_FROM_SIGNER) + } else { + session.logger.info({ signer }, WarningMessage.CANCELLED_REQUEST_TO_SIGNER) + } + } else { + // Logging the err & message simultaneously fails to log the message in some cases + session.logger.error({ signer }, ErrorMessage.SIGNER_REQUEST_ERROR) + session.logger.error({ signer, err }) + } + } + return this.handleFetchResult(signer, session, signerFetchResult) + } + + protected async handleFetchResult( + signer: Signer, + session: Session, + signerFetchResult?: FetchResponse + ): Promise { + if (signerFetchResult?.ok) { + try { + // Throws if response is not actually successful + await this.receiveSuccess(signerFetchResult, signer.url, session) + return + } catch (err) { + session.logger.error(err) + } + } + if (signerFetchResult) { + session.logger.debug({ + message: 'Received signerFetchResult on unsuccessful signer response', + res: await signerFetchResult.json(), + }) + } + return this.addFailureToSession(signer, signerFetchResult?.status ?? 502, session) + } + + protected async receiveSuccess( + signerFetchResult: FetchResponse, + url: string, + session: Session + ): Promise> { + if (!signerFetchResult.ok) { + throw new Error(`Implementation Error: receiveSuccess should only receive 'OK' responses`) + } + const { status } = signerFetchResult + const data: string = await signerFetchResult.text() + session.logger.info({ signer: url, res: data, status }, `received 'OK' response from signer`) + const signerResponse: OdisResponse = this.io.validateSignerResponse( + data, + url, + session.logger + ) + if (!signerResponse.success) { + session.logger.error( + { err: signerResponse.error, signer: url, status }, + `Signer request to ${url + this.io.signerEndpoint} failed with 'OK' status` + ) + throw new Error(ErrorMessage.SIGNER_RESPONSE_FAILED_WITH_OK_STATUS) + } + session.logger.info({ signer: url }, `Signer request successful`) + session.responses.push({ url, res: signerResponse, status }) + return signerResponse + } + + private addFailureToSession(signer: Signer, errorCode: number | undefined, session: Session) { + session.logger.warn( + `Received failure from ${session.failedSigners.size}/${this.signers.length} signers` + ) + // Tracking failed request count via signer url prevents + // double counting the same failed request by mistake + session.failedSigners.add(signer.url) + if (errorCode) { + session.incrementErrorCodeCount(errorCode) + } + const { threshold } = session.keyVersionInfo + if (this.signers.length - session.failedSigners.size < threshold) { + session.logger.warn('Not possible to reach a threshold of signer responses. Failing fast') + session.abort.abort() + } + } +} diff --git a/packages/phone-number-privacy/combiner/src/common/controller.ts b/packages/phone-number-privacy/combiner/src/common/controller.ts new file mode 100644 index 00000000000..7726bebad2a --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/common/controller.ts @@ -0,0 +1,25 @@ +import { ErrorMessage, OdisRequest, OdisResponse } from '@celo/phone-number-privacy-common' +import { Request, Response } from 'express' +import { Action } from './action' + +export class Controller { + constructor(readonly action: Action) {} + + public async handle( + request: Request<{}, {}, unknown>, + response: Response> + ): Promise { + try { + const session = await this.action.io.init(request, response) + if (session) { + await this.action.perform(session) + } + } catch (err) { + response.locals.logger.error( + { error: err }, + `Unknown error in handler for ${this.action.io.endpoint}` + ) + this.action.io.sendFailure(ErrorMessage.UNKNOWN_ERROR, 500, response) + } + } +} diff --git a/packages/phone-number-privacy/combiner/src/bls/bls-cryptography-client.ts b/packages/phone-number-privacy/combiner/src/common/crypto-clients/bls-crypto-client.ts similarity index 65% rename from packages/phone-number-privacy/combiner/src/bls/bls-cryptography-client.ts rename to packages/phone-number-privacy/combiner/src/common/crypto-clients/bls-crypto-client.ts index 0b8935b3ea8..40a2d8798aa 100644 --- a/packages/phone-number-privacy/combiner/src/bls/bls-cryptography-client.ts +++ b/packages/phone-number-privacy/combiner/src/common/crypto-clients/bls-crypto-client.ts @@ -1,65 +1,36 @@ -import { ErrorMessage, rootLogger } from '@celo/phone-number-privacy-common' +import { ErrorMessage } from '@celo/phone-number-privacy-common' import threshold_bls from 'blind-threshold-bls' import Logger from 'bunyan' -import config from '../config' - -export interface ServicePartialSignature { - url: string - signature: string -} +import { CryptoClient, ServicePartialSignature } from './crypto-client' function flattenSigsArray(sigs: Uint8Array[]) { return Uint8Array.from(sigs.reduce((a, b) => a.concat(Array.from(b)), [] as any)) } -export class BLSCryptographyClient { - private unverifiedSignatures: ServicePartialSignature[] = [] +export class BLSCryptographyClient extends CryptoClient { + // Signatures can be verified server-side without knowledge of the blinding factor private verifiedSignatures: ServicePartialSignature[] = [] - private get allSignaturesLength(): number { + + protected get allSignaturesLength(): number { return this.unverifiedSignatures.length + this.verifiedSignatures.length } + private get allSignatures(): Uint8Array { const allSigs = this.verifiedSignatures.concat(this.unverifiedSignatures) const sigBuffers = allSigs.map((response) => Buffer.from(response.signature, 'base64')) return flattenSigsArray(sigBuffers) } - public addSignature(serviceResponse: ServicePartialSignature) { - this.unverifiedSignatures.push(serviceResponse) - } - - /** - * Returns true if the number of valid signatures is enough to perform a combination - */ - public hasSufficientSignatures(): boolean { - const threshold = config.thresholdSignature.threshold - return this.allSignaturesLength >= threshold - } - /* * Computes the BLS signature for the blinded phone number. - * Throws an exception if not enough valid signatures - * and drops the invalid signature for future requests using this instance + * On error, logs and throws exception for not enough signatures, + * and drops the invalid signature for future requests using this instance. */ - public async combinePartialBlindedSignatures( - blindedMessage: string, - logger?: Logger - ): Promise { - logger = logger ?? rootLogger() - const threshold = config.thresholdSignature.threshold - if (!this.hasSufficientSignatures()) { - logger.error( - { signatures: this.allSignaturesLength, required: threshold }, - ErrorMessage.NOT_ENOUGH_PARTIAL_SIGNATURES - ) - throw new Error( - `${ErrorMessage.NOT_ENOUGH_PARTIAL_SIGNATURES} ${this.allSignaturesLength}/${threshold}` - ) - } + protected _combineBlindedSignatureShares(blindedMessage: string, logger: Logger): string { // Optimistically attempt to combine unverified signatures // If combination or verification fails, iterate through each signature and remove invalid ones // We do this since partial signature verification incurs higher latencies try { - const result = threshold_bls.combine(threshold, this.allSignatures) + const result = threshold_bls.combine(this.keyVersionInfo.threshold, this.allSignatures) this.verifyCombinedSignature(blindedMessage, result, logger) return Buffer.from(result).toString('base64') } catch (error) { @@ -67,7 +38,7 @@ export class BLSCryptographyClient { // Verify each signature and remove invalid ones // This logging will help us troubleshoot which signers are having issues this.unverifiedSignatures.forEach((unverifiedSignature) => { - this.verifyPartialSignature(blindedMessage, unverifiedSignature, logger!) + this.verifyPartialSignature(blindedMessage, unverifiedSignature, logger) }) this.clearUnverifiedSignatures() throw new Error(ErrorMessage.NOT_ENOUGH_PARTIAL_SIGNATURES) @@ -84,7 +55,7 @@ export class BLSCryptographyClient { // Documentation should not specify that verifyBlindSignature verifies the // signature after it has been unblinded. threshold_bls.verifyBlindSignature( - Buffer.from(config.thresholdSignature.pubKey, 'base64'), + Buffer.from(this.keyVersionInfo.pubKey, 'base64'), Buffer.from(blindedMessage, 'base64'), combinedSignature ) @@ -113,10 +84,9 @@ export class BLSCryptographyClient { } private isValidPartialSignature(signature: Buffer, blindedMessage: string) { - const polynomial = config.thresholdSignature.polynomial try { threshold_bls.partialVerifyBlindSignature( - Buffer.from(polynomial, 'hex'), + Buffer.from(this.keyVersionInfo.polynomial, 'hex'), Buffer.from(blindedMessage, 'base64'), signature ) diff --git a/packages/phone-number-privacy/combiner/src/common/crypto-clients/crypto-client.ts b/packages/phone-number-privacy/combiner/src/common/crypto-clients/crypto-client.ts new file mode 100644 index 00000000000..2fd8580420a --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/common/crypto-clients/crypto-client.ts @@ -0,0 +1,54 @@ +import { ErrorMessage, KeyVersionInfo } from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' + +export interface ServicePartialSignature { + url: string + signature: string +} + +export abstract class CryptoClient { + protected unverifiedSignatures: ServicePartialSignature[] = [] + + constructor(protected readonly keyVersionInfo: KeyVersionInfo) {} + + /** + * Returns true if the number of valid signatures is enough to perform a combination + */ + public hasSufficientSignatures(): boolean { + return this.allSignaturesLength >= this.keyVersionInfo.threshold + } + + public addSignature(serviceResponse: ServicePartialSignature): void { + this.unverifiedSignatures.push(serviceResponse) + } + + /* + * Computes the signature for the blinded phone number using subclass-specific + * logic defined in _combineBlindedSignatureShares. + * Throws an exception if not enough valid signatures or on aggregation failure. + */ + public combineBlindedSignatureShares(blindedMessage: string, logger: Logger): string { + if (!this.hasSufficientSignatures()) { + const { threshold } = this.keyVersionInfo + logger.error( + { signatures: this.allSignaturesLength, required: threshold }, + ErrorMessage.NOT_ENOUGH_PARTIAL_SIGNATURES + ) + throw new Error( + `${ErrorMessage.NOT_ENOUGH_PARTIAL_SIGNATURES} ${this.allSignaturesLength}/${threshold}` + ) + } + return this._combineBlindedSignatureShares(blindedMessage, logger) + } + + /* + * Computes the signature for the blinded phone number. + * Must be implemented by subclass. + */ + protected abstract _combineBlindedSignatureShares(blindedMessage: string, logger: Logger): string + + /** + * Returns total number of signatures received; must be implemented by subclass. + */ + protected abstract get allSignaturesLength(): number +} diff --git a/packages/phone-number-privacy/combiner/src/common/crypto-clients/domain-crypto-client.ts b/packages/phone-number-privacy/combiner/src/common/crypto-clients/domain-crypto-client.ts new file mode 100644 index 00000000000..22a1256d35e --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/common/crypto-clients/domain-crypto-client.ts @@ -0,0 +1,40 @@ +import { ErrorMessage, KeyVersionInfo, PoprfCombiner } from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import { CryptoClient } from './crypto-client' + +export class DomainCryptoClient extends CryptoClient { + private poprfCombiner: PoprfCombiner + + constructor(protected readonly keyVersionInfo: KeyVersionInfo) { + super(keyVersionInfo) + this.poprfCombiner = new PoprfCombiner(keyVersionInfo.threshold) + } + + protected get allSignaturesLength(): number { + // No way of verifying signatures on the server-side + return this.unverifiedSignatures.length + } + + private get allSigsAsArray(): Uint8Array[] { + return this.unverifiedSignatures.map((response) => Buffer.from(response.signature, 'base64')) + } + + /* + * Aggregates blind partial signatures into a blind aggregated POPRF evaluation. + * On error, logs and throws exception for not enough signatures. + * Verification of partial signatures is not possible server-side + * (i.e. without the client's blinding factor). + */ + protected _combineBlindedSignatureShares(_blindedMessage: string, logger: Logger): string { + try { + const result = this.poprfCombiner.blindAggregate(this.allSigsAsArray) + if (result !== undefined) { + return result.toString('base64') + } + } catch (error) { + logger.error(ErrorMessage.SIGNATURE_AGGREGATION_FAILURE) + logger.error(error) + } + throw new Error(ErrorMessage.SIGNATURE_AGGREGATION_FAILURE) + } +} diff --git a/packages/phone-number-privacy/combiner/src/common/crypto-session.ts b/packages/phone-number-privacy/combiner/src/common/crypto-session.ts new file mode 100644 index 00000000000..f1a0d7d6f98 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/common/crypto-session.ts @@ -0,0 +1,16 @@ +import { KeyVersionInfo, OdisResponse } from '@celo/phone-number-privacy-common' +import { Request, Response } from 'express' +import { CryptoClient } from './crypto-clients/crypto-client' +import { Session } from './session' +import { OdisSignatureRequest } from './sign' + +export class CryptoSession extends Session { + public constructor( + readonly request: Request<{}, {}, R>, + readonly response: Response>, + readonly keyVersionInfo: KeyVersionInfo, + readonly crypto: CryptoClient + ) { + super(request, response, keyVersionInfo) + } +} diff --git a/packages/phone-number-privacy/combiner/src/common/error-utils.ts b/packages/phone-number-privacy/combiner/src/common/error-utils.ts deleted file mode 100644 index db97024a3e8..00000000000 --- a/packages/phone-number-privacy/combiner/src/common/error-utils.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ErrorMessage, WarningMessage } from '@celo/phone-number-privacy-common' -import Logger from 'bunyan' -import { Response } from 'firebase-functions' -import { VERSION } from '../config' - -export type ErrorType = ErrorMessage | WarningMessage - -export function respondWithError( - res: Response, - statusCode: number, - error: ErrorType, - logger: Logger -) { - if (error in WarningMessage) { - logger.warn({ error, statusCode }, 'Responding with warning') - } else { - logger.error({ error, statusCode }, 'Responding with error') - } - res.status(statusCode).json({ success: false, error, version: VERSION }) -} diff --git a/packages/phone-number-privacy/combiner/src/common/io.ts b/packages/phone-number-privacy/combiner/src/common/io.ts new file mode 100644 index 00000000000..bfbbf6c91a8 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/common/io.ts @@ -0,0 +1,165 @@ +import { + CombinerEndpoint, + ErrorMessage, + ErrorType, + FailureResponse, + getRequestKeyVersion, + KEY_VERSION_HEADER, + KeyVersionInfo, + OdisRequest, + OdisResponse, + requestHasValidKeyVersion, + SignerEndpoint, + SuccessResponse, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import { Request, Response } from 'express' +import * as t from 'io-ts' +import fetch, { Response as FetchResponse } from 'node-fetch' +import { performance } from 'perf_hooks' +import { OdisConfig } from '../config' +import { Signer } from './combine' +import { Session } from './session' + +// tslint:disable-next-line: interface-over-type-literal +export type SignerResponse = { + url: string + res: OdisResponse + status: number +} + +export abstract class IO { + abstract readonly endpoint: CombinerEndpoint + abstract readonly signerEndpoint: SignerEndpoint + abstract readonly requestSchema: t.Type + abstract readonly responseSchema: t.Type, OdisResponse, unknown> + + constructor(readonly config: OdisConfig) {} + + abstract init( + request: Request<{}, {}, unknown>, + response: Response> + ): Promise | null> + + abstract authenticate(request: Request<{}, {}, R>, logger?: Logger): Promise + + abstract sendFailure( + error: ErrorType, + status: number, + response: Response>, + ...args: unknown[] + ): void + + abstract sendSuccess( + status: number, + response: Response>, + ...args: unknown[] + ): void + + validateClientRequest(request: Request<{}, {}, unknown>): request is Request<{}, {}, R> { + return this.requestSchema.is(request.body) + } + + getKeyVersionInfo(request: Request<{}, {}, OdisRequest>, logger: Logger): KeyVersionInfo { + const requestKeyVersion = getRequestKeyVersion(request, logger) + const defaultKeyVersion = this.config.keys.currentVersion + const keyVersion = requestKeyVersion ?? defaultKeyVersion + const supportedVersions: KeyVersionInfo[] = JSON.parse(this.config.keys.versions) // TODO add io-ts checks for this and signer array + const filteredSupportedVersions: KeyVersionInfo[] = supportedVersions.filter( + (v) => v.keyVersion === keyVersion + ) + if (!filteredSupportedVersions.length) { + throw new Error(`key version ${keyVersion} not supported`) + } + return filteredSupportedVersions[0] + } + + requestHasSupportedKeyVersion(request: Request<{}, {}, OdisRequest>, logger: Logger): boolean { + if (!requestHasValidKeyVersion(request, logger)) { + return false + } + try { + this.getKeyVersionInfo(request, logger) + return true + } catch (err) { + logger.debug('Error caught in requestHasSupportedKeyVersion') + logger.debug(err) + return false + } + } + + validateSignerResponse(data: string, url: string, logger: Logger): OdisResponse { + const res: unknown = JSON.parse(data) + if (!this.responseSchema.is(res)) { + logger.error( + { data, signer: url }, + `Signer request to ${url + this.signerEndpoint} returned malformed response` + ) + throw new Error(ErrorMessage.INVALID_SIGNER_RESPONSE) + } + return res + } + + async fetchSignerResponseWithFallback( + signer: Signer, + session: Session + ): Promise { + const start = `Start ${signer.url + this.signerEndpoint}` + const end = `End ${signer.url + this.signerEndpoint}` + performance.mark(start) + + return this.fetchSignerResponse(signer.url, session) + .catch((err) => { + session.logger.error({ url: signer.url, error: err }, `Signer failed with primary url`) + if (signer.fallbackUrl) { + session.logger.warn({ url: signer.fallbackUrl }, `Using fallback url to call signer`) + return this.fetchSignerResponse(signer.fallbackUrl, session) + } + throw err + }) + .finally(() => { + performance.mark(end) + performance.measure(signer.url, start, end) + }) + } + + protected async fetchSignerResponse( + signerUrl: string, + session: Session + ): Promise { + const { request, logger, abort } = session + const url = signerUrl + this.signerEndpoint + logger.debug({ url }, `Sending signer request`) + // prettier-ignore + return fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + // Pnp requests provide authorization in the request header + ...(request.headers.authorization ? { Authorization: request.headers.authorization } : {}), + // Forward requested keyVersion if provided by client, otherwise use default keyVersion. + // This will be ignored for non-signing requests. + [KEY_VERSION_HEADER]: session.keyVersionInfo.keyVersion.toString() + }, + body: JSON.stringify(request.body), + signal: abort.signal, + }) + } + + protected inputChecks( + request: Request<{}, {}, unknown>, + response: Response> + ): request is Request<{}, {}, R> { + if (!this.config.enabled) { + this.sendFailure(WarningMessage.API_UNAVAILABLE, 503, response) + return false + } + if (!this.validateClientRequest(request)) { + this.sendFailure(WarningMessage.INVALID_INPUT, 400, response) + return false + } + return true + } +} diff --git a/packages/phone-number-privacy/combiner/src/common/session.ts b/packages/phone-number-privacy/combiner/src/common/session.ts new file mode 100644 index 00000000000..bfa3ae24b29 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/common/session.ts @@ -0,0 +1,54 @@ +import { + ErrorMessage, + KeyVersionInfo, + OdisRequest, + OdisResponse, +} from '@celo/phone-number-privacy-common' +import AbortController from 'abort-controller' +import Logger from 'bunyan' +import { Request, Response } from 'express' +import { SignerResponse } from './io' + +export class Session { + public timedOut: boolean = false + readonly logger: Logger + readonly abort: AbortController = new AbortController() + readonly failedSigners: Set = new Set() + readonly errorCodes: Map = new Map() + readonly responses: Array> = new Array>() + readonly warnings: string[] = [] + + public constructor( + readonly request: Request<{}, {}, R>, + readonly response: Response>, + readonly keyVersionInfo: KeyVersionInfo + ) { + this.logger = response.locals.logger + } + + incrementErrorCodeCount(errorCode: number) { + this.errorCodes.set(errorCode, (this.errorCodes.get(errorCode) ?? 0) + 1) + } + + getMajorityErrorCode(): number | null { + const uniqueErrorCount = Array.from(this.errorCodes.keys()).length + if (uniqueErrorCount > 1) { + this.logger.error( + { errorCodes: JSON.stringify([...this.errorCodes]) }, + ErrorMessage.INCONSISTENT_SIGNER_RESPONSES + ) + } + + let maxErrorCode = -1 + let maxCount = -1 + this.errorCodes.forEach((count, errorCode) => { + // This gives priority to the lower status codes in the event of a tie + // because 400s are more helpful than 500s for user feedback + if (count > maxCount || (count === maxCount && errorCode < maxErrorCode)) { + maxCount = count + maxErrorCode = errorCode + } + }) + return maxErrorCode > 0 ? maxErrorCode : null + } +} diff --git a/packages/phone-number-privacy/combiner/src/common/sign.ts b/packages/phone-number-privacy/combiner/src/common/sign.ts new file mode 100644 index 00000000000..49b766bb57a --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/common/sign.ts @@ -0,0 +1,95 @@ +import { + DomainRestrictedSignatureRequest, + ErrorMessage, + ErrorType, + LegacySignMessageRequest, + OdisResponse, + responseHasExpectedKeyVersion, + SignMessageRequest, +} from '@celo/phone-number-privacy-common' +import { Response as FetchResponse } from 'node-fetch' +import { OdisConfig } from '../config' +import { DomainThresholdStateService } from '../domain/services/threshold-state' +import { PnpThresholdStateService } from '../pnp/services/threshold-state' +import { CombineAction } from './combine' +import { CryptoSession } from './crypto-session' +import { IO } from './io' + +// prettier-ignore +export type OdisSignatureRequest = + | SignMessageRequest + | LegacySignMessageRequest + | DomainRestrictedSignatureRequest +export type ThresholdStateService = R extends SignMessageRequest + ? PnpThresholdStateService + : never | R extends DomainRestrictedSignatureRequest + ? DomainThresholdStateService + : never + +// tslint:disable-next-line: max-classes-per-file +export abstract class SignAction extends CombineAction { + constructor( + readonly config: OdisConfig, + readonly thresholdStateService: ThresholdStateService, + readonly io: IO + ) { + super(config, io) + } + + // Throws if response is not actually successful + protected async receiveSuccess( + signerResponse: FetchResponse, + url: string, + session: CryptoSession + ): Promise> { + const { keyVersion } = session.keyVersionInfo + + // TODO(2.0.0, deployment) consider this while doing deployment. Signers should be updated before the combiner is + if (!responseHasExpectedKeyVersion(signerResponse, keyVersion, session.logger)) { + throw new Error(ErrorMessage.INVALID_KEY_VERSION_RESPONSE) + } + + const res = await super.receiveSuccess(signerResponse, url, session) + + if (res.success) { + const signatureAdditionStart = Date.now() + session.crypto.addSignature({ url, signature: res.signature }) + session.logger.info( + { + signer: url, + hasSufficientSignatures: session.crypto.hasSufficientSignatures(), + additionLatency: Date.now() - signatureAdditionStart, + }, + 'Added signature' + ) + // Send response immediately once we cross threshold + // BLS threshold signatures can be combined without all partial signatures + if (session.crypto.hasSufficientSignatures()) { + try { + session.crypto.combineBlindedSignatureShares( + this.parseBlindedMessage(session.request.body), + session.logger + ) + // Close outstanding requests + session.abort.abort() + } catch (err) { + // One or more signatures failed verification and were discarded. + session.logger.info('Error caught in receiveSuccess') + session.logger.info(err) + // Continue to collect signatures. + } + } + } + return res + } + + protected handleMissingSignatures(session: CryptoSession) { + const errorCode = session.getMajorityErrorCode() ?? 500 + const error = this.errorCodeToError(errorCode) + this.io.sendFailure(error, errorCode, session.response) + } + + protected abstract errorCodeToError(errorCode: number): ErrorType + + protected abstract parseBlindedMessage(req: OdisSignatureRequest): string +} diff --git a/packages/phone-number-privacy/combiner/src/config.ts b/packages/phone-number-privacy/combiner/src/config.ts index 46224db790d..752f435c030 100644 --- a/packages/phone-number-privacy/combiner/src/config.ts +++ b/packages/phone-number-privacy/combiner/src/config.ts @@ -1,97 +1,178 @@ -import { OdisUtils } from '@celo/identity' -import { rootLogger as logger, toBool } from '@celo/phone-number-privacy-common' +import { BlockchainConfig, rootLogger, TestUtils, toBool } from '@celo/phone-number-privacy-common' import * as functions from 'firebase-functions' -export const VERSION = process.env.npm_package_version +export const VERSION = process.env.npm_package_version ?? '0.0.0' export const DEV_MODE = process.env.NODE_ENV !== 'production' || process.env.FUNCTIONS_EMULATOR === 'true' -export const DEV_PUBLIC_KEY = - '1f33136ac029a702eb041096bd9ef09dc9c368dde52a972866bdeaff0896f8596b74ab7adfd7318bba38527599768400df44bcab66bcf3843c17a2ce838bcd5a8ba1634c18314ff0565a7c769905b8a8fba27a86bf4c6cb22df89e1badfe2b81' -export const DEV_PRIVATE_KEY = - '00000000dd0005bf4de5f2f052174f5cf58dae1af1d556c7f7f85d6fb3656e1d0f10720f' -export const DEV_POLYNOMIAL = - '01000000000000001f33136ac029a702eb041096bd9ef09dc9c368dde52a972866bdeaff0896f8596b74ab7adfd7318bba38527599768400df44bcab66bcf3843c17a2ce838bcd5a8ba1634c18314ff0565a7c769905b8a8fba27a86bf4c6cb22df89e1badfe2b81' - export const FORNO_ALFAJORES = 'https://alfajores-forno.celo-testnet.org' // combiner always thinks these accounts/phoneNumbersa are verified to enable e2e testing export const E2E_TEST_PHONE_NUMBERS_RAW: string[] = ['+14155550123', '+15555555555', '+14444444444'] -export const E2E_TEST_PHONE_NUMBERS: string[] = E2E_TEST_PHONE_NUMBERS_RAW.map((num) => - OdisUtils.Matchmaking.obfuscateNumberForMatchmaking(num) -) + export const E2E_TEST_ACCOUNTS: string[] = ['0x1be31a94361a391bbafb2a4ccd704f57dc04d4bb'] -interface Config { - blockchain: { - provider: string - apiKey?: string - } - db: { - user: string - password: string - database: string - host: string - ssl: boolean - } +export const MAX_BLOCK_DISCREPANCY_THRESHOLD = 3 +export const MAX_TOTAL_QUOTA_DISCREPANCY_THRESHOLD = 5 +export const MAX_QUERY_COUNT_DISCREPANCY_THRESHOLD = 5 + +export interface OdisConfig { + serviceName: string + enabled: boolean + shouldFailOpen: boolean // TODO (https://github.com/celo-org/celo-monorepo/issues/9862) consider refactoring config, this isn't relevant to domains endpoints odisServices: { signers: string timeoutMilliSeconds: number } - thresholdSignature: { - threshold: number - polynomial: string - pubKey: string + keys: { + currentVersion: number + versions: string // parse as KeyVersionInfo[] } } -let config: Config +export interface CloudFunctionConfig { + minInstances: number +} + +export interface CombinerConfig { + serviceName: string + blockchain: BlockchainConfig + phoneNumberPrivacy: OdisConfig + domains: OdisConfig + cloudFunction: CloudFunctionConfig +} + +let config: CombinerConfig + +const defaultServiceName = 'odis-combiner' if (DEV_MODE) { - logger().debug('Running in dev mode') + rootLogger(defaultServiceName).debug('Running in dev mode') + const devSignersString = JSON.stringify([ + { + url: 'http://localhost:3001', + fallbackUrl: 'http://localhost:3001/fallback', + }, + { + url: 'http://localhost:3002', + fallbackUrl: 'http://localhost:3002/fallback', + }, + { + url: 'http://localhost:3003', + fallbackUrl: 'http://localhost:3003/fallback', + }, + ]) config = { + serviceName: defaultServiceName, blockchain: { provider: FORNO_ALFAJORES, }, - db: { - user: 'postgres', - password: 'fakePass', - database: 'phoneNumberPrivacy', - host: 'fakeHost', - ssl: false, + phoneNumberPrivacy: { + serviceName: defaultServiceName, + enabled: true, + shouldFailOpen: false, + odisServices: { + signers: devSignersString, + timeoutMilliSeconds: 5 * 1000, + }, + keys: { + currentVersion: 1, + versions: JSON.stringify([ + { + keyVersion: 1, + threshold: 2, + polynomial: TestUtils.Values.PNP_THRESHOLD_DEV_POLYNOMIAL_V1, + pubKey: TestUtils.Values.PNP_THRESHOLD_DEV_PUBKEY_V1, + }, + { + keyVersion: 2, + threshold: 2, + polynomial: TestUtils.Values.PNP_THRESHOLD_DEV_POLYNOMIAL_V2, + pubKey: TestUtils.Values.PNP_THRESHOLD_DEV_PUBKEY_V2, + }, + { + keyVersion: 3, + threshold: 2, + polynomial: TestUtils.Values.PNP_THRESHOLD_DEV_POLYNOMIAL_V3, + pubKey: TestUtils.Values.PNP_THRESHOLD_DEV_PUBKEY_V3, + }, + ]), + }, }, - odisServices: { - signers: - '[{"url": "http://localhost:3000", "fallbackUrl": "http://localhost:3000/fallback"}]', - timeoutMilliSeconds: 5 * 1000, + domains: { + serviceName: defaultServiceName, + enabled: true, + shouldFailOpen: false, + odisServices: { + signers: devSignersString, + timeoutMilliSeconds: 5 * 1000, + }, + keys: { + currentVersion: 1, + versions: JSON.stringify([ + { + keyVersion: 1, + threshold: 2, + polynomial: TestUtils.Values.DOMAINS_THRESHOLD_DEV_POLYNOMIAL_V1, + pubKey: TestUtils.Values.DOMAINS_THRESHOLD_DEV_PUBKEY_V1, + }, + { + keyVersion: 2, + threshold: 2, + polynomial: TestUtils.Values.DOMAINS_THRESHOLD_DEV_POLYNOMIAL_V2, + pubKey: TestUtils.Values.DOMAINS_THRESHOLD_DEV_PUBKEY_V2, + }, + { + keyVersion: 3, + threshold: 2, + polynomial: TestUtils.Values.DOMAINS_THRESHOLD_DEV_POLYNOMIAL_V3, + pubKey: TestUtils.Values.DOMAINS_THRESHOLD_DEV_PUBKEY_V3, + }, + ]), + }, }, - thresholdSignature: { - threshold: 1, - polynomial: DEV_POLYNOMIAL, - pubKey: DEV_PUBLIC_KEY, + cloudFunction: { + minInstances: 0, }, } } else { const functionConfig = functions.config() config = { + serviceName: functionConfig.service_name ?? defaultServiceName, blockchain: { provider: functionConfig.blockchain.provider, apiKey: functionConfig.blockchain.api_key, }, - db: { - user: functionConfig.db.username, - password: functionConfig.db.pass, - database: functionConfig.db.name, - host: `/cloudsql/${functionConfig.db.host}`, - ssl: toBool(functionConfig.db.ssl, true), + phoneNumberPrivacy: { + serviceName: functionConfig.phoneNumberPrivacy.service_name ?? defaultServiceName, + enabled: toBool(functionConfig.phoneNumberPrivacy.enabled, false), + shouldFailOpen: toBool(functionConfig.phoneNumberPrivacy.shouldFailOpen, false), + odisServices: { + signers: functionConfig.phoneNumberPrivacy.odisservices.signers, + timeoutMilliSeconds: + functionConfig.phoneNumberPrivacy.odisservices.timeoutMilliSeconds ?? 5 * 1000, + }, + keys: { + currentVersion: functionConfig.phoneNumberPrivacy.keys.currentVersion, + versions: functionConfig.phoneNumberPrivacy.keys.versions, + }, }, - odisServices: { - signers: functionConfig.odisservices.signers, - timeoutMilliSeconds: functionConfig.odisservices.timeoutMilliSeconds || 5 * 1000, + domains: { + serviceName: functionConfig.domains.service_name ?? defaultServiceName, + enabled: toBool(functionConfig.domains.enabled, false), + shouldFailOpen: toBool(functionConfig.domains.authShouldFailOpen, false), + odisServices: { + signers: functionConfig.domains.odisservices.signers, + timeoutMilliSeconds: functionConfig.domains.odisservices.timeoutMilliSeconds ?? 5 * 1000, + }, + keys: { + currentVersion: functionConfig.domains.keys.currentVersion, + versions: functionConfig.domains.keys.versions, + }, }, - thresholdSignature: { - threshold: functionConfig.threshold_signature.threshold_signature_threshold, - polynomial: functionConfig.threshold_signature.threshold_polynomial, - pubKey: functionConfig.threshold_signature.public_key, + cloudFunction: { + // Keep instances warm for mainnet functions + // @ts-ignore https://firebase.google.com/docs/functions/manage-functions#reduce_the_number_of_cold_starts + minInstances: functionConfig.blockchain.provider === FORNO_ALFAJORES ? 0 : 3, }, } } diff --git a/packages/phone-number-privacy/combiner/src/database/database.ts b/packages/phone-number-privacy/combiner/src/database/database.ts deleted file mode 100644 index 09189512344..00000000000 --- a/packages/phone-number-privacy/combiner/src/database/database.ts +++ /dev/null @@ -1,16 +0,0 @@ -import knex from 'knex' -import config, { DEV_MODE } from '../config' - -const db = knex({ - client: 'pg', - connection: config.db, - debug: DEV_MODE, -}) - -export function getDatabase() { - return db -} - -export function getTransaction() { - return db.transaction() -} diff --git a/packages/phone-number-privacy/combiner/src/database/models/account.ts b/packages/phone-number-privacy/combiner/src/database/models/account.ts deleted file mode 100644 index 88f6968c6b3..00000000000 --- a/packages/phone-number-privacy/combiner/src/database/models/account.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { VerifiedPhoneNumberDekSignature } from '../../match-making/get-contact-matches' - -export const ACCOUNTS_TABLE = 'accounts' -export enum ACCOUNTS_COLUMNS { - address = 'address', - signedUserPhoneNumber = 'signedUserPhoneNumber', - dekSigner = 'dekSigner', - createdAt = 'created_at', - numLookups = 'num_lookups', - didMatchmaking = 'did_matchmaking', -} -export class Account { - [ACCOUNTS_COLUMNS.address]: string | undefined; - [ACCOUNTS_COLUMNS.signedUserPhoneNumber]: string | undefined; - [ACCOUNTS_COLUMNS.dekSigner]: string | undefined; - [ACCOUNTS_COLUMNS.createdAt]: Date = new Date(); - [ACCOUNTS_COLUMNS.didMatchmaking]: Date | null = null - - constructor(address: string, verifiedPhoneNumberDekSig?: VerifiedPhoneNumberDekSignature) { - this.address = address - if (verifiedPhoneNumberDekSig) { - this.signedUserPhoneNumber = verifiedPhoneNumberDekSig.signedUserPhoneNumber - this.dekSigner = verifiedPhoneNumberDekSig.dekSigner - } - } -} diff --git a/packages/phone-number-privacy/combiner/src/database/models/numberPair.ts b/packages/phone-number-privacy/combiner/src/database/models/numberPair.ts deleted file mode 100644 index fdd0154d4ea..00000000000 --- a/packages/phone-number-privacy/combiner/src/database/models/numberPair.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const NUMBER_PAIRS_TABLE = 'number_pairs' -export enum NUMBER_PAIRS_COLUMN { - userPhoneHash = 'user_phone_hash', - contactPhoneHash = 'contact_phone_hash', -} - -// This is to deal with a Typescript bug. -// https://github.com/microsoft/TypeScript/issues/49594 -// Should revert to using the enum directly when this is fixed. -const userPhoneHashField = NUMBER_PAIRS_COLUMN.userPhoneHash -const contactPhoneHashField = NUMBER_PAIRS_COLUMN.contactPhoneHash - -export class NumberPair { - [userPhoneHashField]: string; - [contactPhoneHashField]: string - - constructor(userPhoneHash: string, contactPhoneHash: string) { - this[userPhoneHashField] = userPhoneHash - this[contactPhoneHashField] = contactPhoneHash - } -} diff --git a/packages/phone-number-privacy/combiner/src/database/wrappers/account.ts b/packages/phone-number-privacy/combiner/src/database/wrappers/account.ts deleted file mode 100644 index b27adc2a311..00000000000 --- a/packages/phone-number-privacy/combiner/src/database/wrappers/account.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { DB_TIMEOUT, ErrorMessage } from '@celo/phone-number-privacy-common' -import Logger from 'bunyan' -import { VerifiedPhoneNumberDekSignature } from '../../match-making/get-contact-matches' -import { getDatabase, getTransaction } from '../database' -import { Account, ACCOUNTS_COLUMNS, ACCOUNTS_TABLE } from '../models/account' - -function accounts() { - return getDatabase()(ACCOUNTS_TABLE) -} - -export async function getAccountSignedUserPhoneNumberRecord( - account: string, - logger: Logger -): Promise { - try { - const signedUserPhoneNumberRecord = await accounts() - .where(ACCOUNTS_COLUMNS.address, account) - .select(ACCOUNTS_COLUMNS.signedUserPhoneNumber) - .first() - .timeout(DB_TIMEOUT) - return signedUserPhoneNumberRecord - ? signedUserPhoneNumberRecord[ACCOUNTS_COLUMNS.signedUserPhoneNumber] - : undefined - } catch (err) { - logger.error(ErrorMessage.DATABASE_GET_FAILURE) - logger.error(err) - throw err - } -} - -export async function getDekSignerRecord( - account: string, - logger: Logger -): Promise { - try { - const dekSignerRecord = await accounts() - .where(ACCOUNTS_COLUMNS.address, account) - .select(ACCOUNTS_COLUMNS.dekSigner) - .first() - .timeout(DB_TIMEOUT) - return dekSignerRecord ? dekSignerRecord[ACCOUNTS_COLUMNS.dekSigner] : undefined - } catch (err) { - logger.error(ErrorMessage.DATABASE_GET_FAILURE) - logger.error(err) - return undefined - } -} - -/* - * Returns whether account has already performed matchmaking - */ -export async function getDidMatchmaking(account: string, logger: Logger): Promise { - try { - const didMatchmaking = await accounts() - .where(ACCOUNTS_COLUMNS.address, account) - .select(ACCOUNTS_COLUMNS.didMatchmaking) - .first() - .timeout(DB_TIMEOUT) - return !!didMatchmaking && !!didMatchmaking[ACCOUNTS_COLUMNS.didMatchmaking] - } catch (err) { - logger.error(ErrorMessage.DATABASE_GET_FAILURE) - logger.error(err) - throw err - } -} - -/* - * Set did matchmaking to true in database. If record doesn't exist, create one. - */ -export async function setDidMatchmaking( - account: string, - logger: Logger, - verifiedPhoneNumberDekSig?: VerifiedPhoneNumberDekSignature -) { - logger.debug({ account }, 'Setting did matchmaking') - const trx = await getTransaction() - const accountTrxBase = () => - accounts().transacting(trx).timeout(DB_TIMEOUT).where(ACCOUNTS_COLUMNS.address, account) - return accountTrxBase() - .then(async (res) => { - if (res.length) { - // If account exists in db - await accountTrxBase() - .update(ACCOUNTS_COLUMNS.didMatchmaking, new Date()) - .then(async () => { - if (verifiedPhoneNumberDekSig) { - await accountTrxBase() - .having(ACCOUNTS_COLUMNS.signedUserPhoneNumber, 'is', null) // prevents overwriting - .update( - ACCOUNTS_COLUMNS.signedUserPhoneNumber, - verifiedPhoneNumberDekSig.signedUserPhoneNumber - ) - await accountTrxBase() - .having(ACCOUNTS_COLUMNS.dekSigner, 'is', null) - .update(ACCOUNTS_COLUMNS.dekSigner, verifiedPhoneNumberDekSig.dekSigner) - } - }) - } else { - const newAccount = new Account(account, verifiedPhoneNumberDekSig) - newAccount[ACCOUNTS_COLUMNS.didMatchmaking] = new Date() - await accounts().transacting(trx).timeout(DB_TIMEOUT).insert(newAccount) - } - trx.commit() - }) - .catch((err) => { - logger.error(ErrorMessage.DATABASE_UPDATE_FAILURE) - logger.error(err) - trx.rollback() - }) -} diff --git a/packages/phone-number-privacy/combiner/src/database/wrappers/number-pairs.ts b/packages/phone-number-privacy/combiner/src/database/wrappers/number-pairs.ts deleted file mode 100644 index 49f3c01730d..00000000000 --- a/packages/phone-number-privacy/combiner/src/database/wrappers/number-pairs.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { DB_TIMEOUT, ErrorMessage } from '@celo/phone-number-privacy-common' -import Logger from 'bunyan' -import { getDatabase } from '../database' -import { NUMBER_PAIRS_COLUMN, NUMBER_PAIRS_TABLE, NumberPair } from '../models/numberPair' - -function numberPairs() { - return getDatabase()(NUMBER_PAIRS_TABLE) -} - -/* - * Returns contacts who have already matched with the user (a contact-->user mapping exists). - */ -export async function getNumberPairContacts( - userPhone: string, - contactPhones: string[], - logger: Logger -): Promise { - try { - const contentPairs = await numberPairs() - .select(NUMBER_PAIRS_COLUMN.userPhoneHash) - .where(NUMBER_PAIRS_COLUMN.contactPhoneHash, userPhone) - .timeout(DB_TIMEOUT) - const contactPhonesSet = new Set(contactPhones) - return contentPairs - .map((contactPair) => contactPair[NUMBER_PAIRS_COLUMN.userPhoneHash]) - .filter((number) => contactPhonesSet.has(number)) - } catch (err) { - logger.error(ErrorMessage.DATABASE_GET_FAILURE) - logger.error(err) - return [] - } -} - -/* - * Add record for user-->contact mapping, - */ -export async function setNumberPairContacts( - userPhone: string, - contactPhones: string[], - logger: Logger -): Promise { - const rows: any = [] - for (const contactPhone of contactPhones) { - const data = new NumberPair(userPhone, contactPhone) - rows.push(data) - } - try { - await getDatabase().batchInsert(NUMBER_PAIRS_TABLE, rows) - } catch (err: any) { - // ignore duplicate insertion error (23505) - if (err.code !== '23505') { - logger.error(ErrorMessage.DATABASE_INSERT_FAILURE) - logger.error(err) - } - } -} diff --git a/packages/phone-number-privacy/combiner/src/domain/endpoints/disable/action.ts b/packages/phone-number-privacy/combiner/src/domain/endpoints/disable/action.ts new file mode 100644 index 00000000000..21ab840ee9f --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/domain/endpoints/disable/action.ts @@ -0,0 +1,39 @@ +import { DisableDomainRequest, ErrorMessage } from '@celo/phone-number-privacy-common' +import { CombineAction } from '../../../common/combine' +import { IO } from '../../../common/io' +import { Session } from '../../../common/session' +import { OdisConfig } from '../../../config' +import { DomainSignerResponseLogger } from '../../services/log-responses' +import { DomainThresholdStateService } from '../../services/threshold-state' + +export class DomainDisableAction extends CombineAction { + readonly responseLogger: DomainSignerResponseLogger = new DomainSignerResponseLogger() + + constructor( + readonly config: OdisConfig, + readonly thresholdStateService: DomainThresholdStateService, + readonly io: IO + ) { + super(config, io) + } + + combine(session: Session): void { + this.responseLogger.logResponseDiscrepancies(session) + try { + const disableDomainStatus = this.thresholdStateService.findThresholdDomainState(session) + if (disableDomainStatus.disabled) { + this.io.sendSuccess(200, session.response, disableDomainStatus) + return + } + } catch (err) { + session.logger.error({ err }, 'Error combining signer disable domain status responses') + } + + this.io.sendFailure( + ErrorMessage.THRESHOLD_DISABLE_DOMAIN_FAILURE, + session.getMajorityErrorCode() ?? 500, + session.response, + session.logger + ) + } +} diff --git a/packages/phone-number-privacy/combiner/src/domain/endpoints/disable/io.ts b/packages/phone-number-privacy/combiner/src/domain/endpoints/disable/io.ts new file mode 100644 index 00000000000..37b0422ac86 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/domain/endpoints/disable/io.ts @@ -0,0 +1,86 @@ +import { + CombinerEndpoint, + DisableDomainRequest, + disableDomainRequestSchema, + DisableDomainResponse, + DisableDomainResponseFailure, + disableDomainResponseSchema, + DisableDomainResponseSuccess, + DomainSchema, + DomainState, + ErrorType, + getSignerEndpoint, + send, + SequentialDelayDomainStateSchema, + SignerEndpoint, + verifyDisableDomainRequestAuthenticity, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { Request, Response } from 'express' +import * as t from 'io-ts' +import { IO } from '../../../common/io' +import { Session } from '../../../common/session' +import { VERSION } from '../../../config' + +export class DomainDisableIO extends IO { + readonly endpoint: CombinerEndpoint = CombinerEndpoint.DISABLE_DOMAIN + readonly signerEndpoint: SignerEndpoint = getSignerEndpoint(this.endpoint) + readonly requestSchema: t.Type< + DisableDomainRequest, + DisableDomainRequest, + unknown + > = disableDomainRequestSchema(DomainSchema) + readonly responseSchema: t.Type< + DisableDomainResponse, + DisableDomainResponse, + unknown + > = disableDomainResponseSchema(SequentialDelayDomainStateSchema) + + async init( + request: Request<{}, {}, unknown>, + response: Response + ): Promise | null> { + if (!super.inputChecks(request, response)) { + return null + } + if (!(await this.authenticate(request))) { + this.sendFailure(WarningMessage.UNAUTHENTICATED_USER, 401, response) + return null + } + return new Session(request, response, this.getKeyVersionInfo(request, response.locals.logger)) + } + + authenticate(request: Request<{}, {}, DisableDomainRequest>): Promise { + return Promise.resolve(verifyDisableDomainRequestAuthenticity(request.body)) + } + + sendSuccess( + status: number, + response: Response, + domainState: DomainState + ) { + send( + response, + { + success: true, + version: VERSION, + status: domainState, + }, + status, + response.locals.logger + ) + } + + sendFailure(error: ErrorType, status: number, response: Response) { + send( + response, + { + success: false, + version: VERSION, + error, + }, + status, + response.locals.logger + ) + } +} diff --git a/packages/phone-number-privacy/combiner/src/domain/endpoints/quota/action.ts b/packages/phone-number-privacy/combiner/src/domain/endpoints/quota/action.ts new file mode 100644 index 00000000000..4ba6032fc05 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/domain/endpoints/quota/action.ts @@ -0,0 +1,39 @@ +import { DomainQuotaStatusRequest, ErrorMessage } from '@celo/phone-number-privacy-common' +import { CombineAction } from '../../../common/combine' +import { IO } from '../../../common/io' +import { Session } from '../../../common/session' +import { OdisConfig } from '../../../config' +import { DomainSignerResponseLogger } from '../../services/log-responses' +import { DomainThresholdStateService } from '../../services/threshold-state' + +export class DomainQuotaAction extends CombineAction { + readonly responseLogger = new DomainSignerResponseLogger() + + constructor( + readonly config: OdisConfig, + readonly thresholdStateService: DomainThresholdStateService, + readonly io: IO + ) { + super(config, io) + } + + combine(session: Session): void { + this.responseLogger.logResponseDiscrepancies(session) + const { threshold } = session.keyVersionInfo + if (session.responses.length >= threshold) { + try { + const domainQuotaStatus = this.thresholdStateService.findThresholdDomainState(session) + this.io.sendSuccess(200, session.response, domainQuotaStatus) + return + } catch (err) { + session.logger.error(err, 'Error combining signer quota status responses') + } + } + this.io.sendFailure( + ErrorMessage.THRESHOLD_DOMAIN_QUOTA_STATUS_FAILURE, + session.getMajorityErrorCode() ?? 500, + session.response, + session.logger + ) + } +} diff --git a/packages/phone-number-privacy/combiner/src/domain/endpoints/quota/io.ts b/packages/phone-number-privacy/combiner/src/domain/endpoints/quota/io.ts new file mode 100644 index 00000000000..a5436fcf4a9 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/domain/endpoints/quota/io.ts @@ -0,0 +1,92 @@ +import { + CombinerEndpoint, + DomainQuotaStatusRequest, + domainQuotaStatusRequestSchema, + DomainQuotaStatusResponse, + DomainQuotaStatusResponseFailure, + domainQuotaStatusResponseSchema, + DomainQuotaStatusResponseSuccess, + DomainSchema, + DomainState, + ErrorType, + getSignerEndpoint, + OdisResponse, + send, + SequentialDelayDomainStateSchema, + SignerEndpoint, + verifyDomainQuotaStatusRequestAuthenticity, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { Request, Response } from 'express' +import * as t from 'io-ts' +import { IO } from '../../../common/io' +import { Session } from '../../../common/session' +import { VERSION } from '../../../config' + +export class DomainQuotaIO extends IO { + readonly endpoint: CombinerEndpoint = CombinerEndpoint.DOMAIN_QUOTA_STATUS + readonly signerEndpoint: SignerEndpoint = getSignerEndpoint(this.endpoint) + readonly requestSchema: t.Type< + DomainQuotaStatusRequest, + DomainQuotaStatusRequest, + unknown + > = domainQuotaStatusRequestSchema(DomainSchema) + readonly responseSchema: t.Type< + DomainQuotaStatusResponse, + DomainQuotaStatusResponse, + unknown + > = domainQuotaStatusResponseSchema(SequentialDelayDomainStateSchema) + + async init( + request: Request<{}, {}, unknown>, + response: Response> + ): Promise | null> { + if (!super.inputChecks(request, response)) { + return null + } + if (!(await this.authenticate(request))) { + this.sendFailure(WarningMessage.UNAUTHENTICATED_USER, 401, response) + return null + } + const keyVersionInfo = this.getKeyVersionInfo(request, response.locals.logger) + return new Session(request, response, keyVersionInfo) + } + + authenticate(request: Request<{}, {}, DomainQuotaStatusRequest>): Promise { + return Promise.resolve(verifyDomainQuotaStatusRequestAuthenticity(request.body)) + } + + sendSuccess( + status: number, + response: Response, + domainState: DomainState + ) { + send( + response, + { + success: true, + version: VERSION, + status: domainState, + }, + status, + response.locals.logger + ) + } + + sendFailure( + error: ErrorType, + status: number, + response: Response + ) { + send( + response, + { + success: false, + version: VERSION, + error, + }, + status, + response.locals.logger + ) + } +} diff --git a/packages/phone-number-privacy/combiner/src/domain/endpoints/sign/action.ts b/packages/phone-number-privacy/combiner/src/domain/endpoints/sign/action.ts new file mode 100644 index 00000000000..e7f74b36d21 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/domain/endpoints/sign/action.ts @@ -0,0 +1,56 @@ +import { + DomainRestrictedSignatureRequest, + ErrorMessage, + ErrorType, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { CryptoSession } from '../../../common/crypto-session' +import { SignAction } from '../../../common/sign' +import { DomainSignerResponseLogger } from '../../services/log-responses' + +export class DomainSignAction extends SignAction { + readonly responseLogger = new DomainSignerResponseLogger() + + combine(session: CryptoSession): void { + this.responseLogger.logResponseDiscrepancies(session) + + if (session.crypto.hasSufficientSignatures()) { + try { + const combinedSignature = session.crypto.combineBlindedSignatureShares( + this.parseBlindedMessage(session.request.body), + session.logger + ) + + return this.io.sendSuccess( + 200, + session.response, + combinedSignature, + this.thresholdStateService.findThresholdDomainState(session) + ) + } catch (err) { + // May fail upon combining signatures if too many sigs are invalid + session.logger.error('Combining signatures failed in combine') + session.logger.error(err) + // Fallback to handleMissingSignatures + } + } + + this.handleMissingSignatures(session) + } + + protected parseBlindedMessage(req: DomainRestrictedSignatureRequest): string { + return req.blindedMessage + } + + protected errorCodeToError(errorCode: number): ErrorType { + switch (errorCode) { + case 429: + return WarningMessage.EXCEEDED_QUOTA + case 401: + // Authentication is checked in the combiner, but invalid nonces are passed through + return WarningMessage.INVALID_NONCE + default: + return ErrorMessage.NOT_ENOUGH_PARTIAL_SIGNATURES + } + } +} diff --git a/packages/phone-number-privacy/combiner/src/domain/endpoints/sign/io.ts b/packages/phone-number-privacy/combiner/src/domain/endpoints/sign/io.ts new file mode 100644 index 00000000000..e02e0678bfd --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/domain/endpoints/sign/io.ts @@ -0,0 +1,105 @@ +import { + CombinerEndpoint, + DomainRestrictedSignatureRequest, + domainRestrictedSignatureRequestSchema, + DomainRestrictedSignatureResponse, + DomainRestrictedSignatureResponseFailure, + domainRestrictedSignatureResponseSchema, + DomainRestrictedSignatureResponseSuccess, + DomainSchema, + DomainState, + ErrorType, + getSignerEndpoint, + send, + SequentialDelayDomainStateSchema, + verifyDomainRestrictedSignatureRequestAuthenticity, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { Request, Response } from 'express' +import * as t from 'io-ts' +import { DomainCryptoClient } from '../../../common/crypto-clients/domain-crypto-client' +import { CryptoSession } from '../../../common/crypto-session' +import { IO } from '../../../common/io' +import { VERSION } from '../../../config' + +export class DomainSignIO extends IO { + readonly endpoint = CombinerEndpoint.DOMAIN_SIGN + readonly signerEndpoint = getSignerEndpoint(this.endpoint) + readonly requestSchema: t.Type< + DomainRestrictedSignatureRequest, + DomainRestrictedSignatureRequest, + unknown + > = domainRestrictedSignatureRequestSchema(DomainSchema) + readonly responseSchema: t.Type< + DomainRestrictedSignatureResponse, + DomainRestrictedSignatureResponse, + unknown + > = domainRestrictedSignatureResponseSchema(SequentialDelayDomainStateSchema) + + async init( + request: Request<{}, {}, unknown>, + response: Response + ): Promise | null> { + if (!super.inputChecks(request, response)) { + return null + } + if (!this.requestHasSupportedKeyVersion(request, response.locals.logger)) { + this.sendFailure(WarningMessage.INVALID_KEY_VERSION_REQUEST, 400, response) + return null + } + if (!(await this.authenticate(request))) { + this.sendFailure(WarningMessage.UNAUTHENTICATED_USER, 401, response) + return null + } + const keyVersionInfo = this.getKeyVersionInfo(request, response.locals.logger) + return new CryptoSession( + request, + response, + keyVersionInfo, + new DomainCryptoClient(keyVersionInfo) + ) + } + + authenticate(request: Request<{}, {}, DomainRestrictedSignatureRequest>): Promise { + // Note that signing requests may include a nonce for replay protection that will be checked by + // the signer, but is not checked here. As a result, requests that pass the authentication check + // here may still fail when sent to the signer. + return Promise.resolve(verifyDomainRestrictedSignatureRequestAuthenticity(request.body)) + } + + sendSuccess( + status: number, + response: Response, + signature: string, + domainState: DomainState + ) { + send( + response, + { + success: true, + version: VERSION, + signature, + status: domainState, + }, + status, + response.locals.logger + ) + } + + sendFailure( + error: ErrorType, + status: number, + response: Response + ) { + send( + response, + { + success: false, + version: VERSION, + error, + }, + status, + response.locals.logger + ) + } +} diff --git a/packages/phone-number-privacy/combiner/src/domain/services/log-responses.ts b/packages/phone-number-privacy/combiner/src/domain/services/log-responses.ts new file mode 100644 index 00000000000..4e78834751a --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/domain/services/log-responses.ts @@ -0,0 +1,59 @@ +import { + DomainRequest, + DomainRestrictedSignatureRequest, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { CryptoSession } from '../../common/crypto-session' +import { Session } from '../../common/session' + +export class DomainSignerResponseLogger { + logResponseDiscrepancies( + session: Session | CryptoSession + ): void { + const parsedResponses: Array<{ + signerUrl: string + values: { + version: string + counter: number + disabled: boolean + timer: number + } + }> = [] + session.responses.forEach((response) => { + if (response.res.success) { + const { version, status } = response.res + parsedResponses.push({ + signerUrl: response.url, + values: { + version, + counter: status.counter, + disabled: status.disabled, + timer: status.timer, + }, + }) + } + }) + if (parsedResponses.length === 0) { + session.logger.warn('No successful signer responses found!') + return + } + + // log all responses if we notice any discrepancies to aid with debugging + const first = JSON.stringify(parsedResponses[0].values) + for (let i = 1; i < parsedResponses.length; i++) { + if (JSON.stringify(parsedResponses[i].values) !== first) { + session.logger.warn({ parsedResponses }, WarningMessage.SIGNER_RESPONSE_DISCREPANCIES) + break + } + } + + // disabled + const numDisabled = parsedResponses.filter((res) => res.values.disabled).length + if (numDisabled > 0 && numDisabled < parsedResponses.length) { + session.logger.error( + { parsedResponses }, + WarningMessage.INCONSISTENT_SIGNER_DOMAIN_DISABLED_STATES + ) + } + } +} diff --git a/packages/phone-number-privacy/combiner/src/domain/services/threshold-state.ts b/packages/phone-number-privacy/combiner/src/domain/services/threshold-state.ts new file mode 100644 index 00000000000..38cdf62e8e8 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/domain/services/threshold-state.ts @@ -0,0 +1,78 @@ +import { DomainRequest, DomainState } from '@celo/phone-number-privacy-common' +import { Session } from '../../common/session' +import { OdisConfig } from '../../config' + +export class DomainThresholdStateService { + constructor(readonly config: OdisConfig) {} + + findThresholdDomainState(session: Session): DomainState { + // Get the domain status from the responses, filtering out responses that don't have the status. + const domainStates = session.responses + .map((signerResponse) => ('status' in signerResponse.res ? signerResponse.res.status : null)) + .filter((state: DomainState | null | undefined): state is DomainState => !!state) + + const { threshold } = session.keyVersionInfo + + // Note: when the threshold > # total signers - threshold, it's possible that we + // throw an error here when the domain is disabled. While the domain is technically disabled, + // the hope is to increase the "safety margin" of the number of signers that have + // also disabled this domain.This can be changed in the future (if we think that + // the safety margin is no longer needed) by simply checking if the domain is disabled + // before checking if the threshold of enabled responses has been met. + if (domainStates.length < threshold) { + throw new Error('Insufficient number of signer responses') + } + + // Check whether the domain is disabled, either by all signers or by some. + const domainStatesEnabled = domainStates.filter((ds) => !ds.disabled) + const numDisabled = domainStates.length - domainStatesEnabled.length + + const signersLength = JSON.parse(this.config.odisServices.signers).length + if (signersLength - numDisabled < threshold) { + return { timer: 0, counter: 0, disabled: true, now: 0 } + } + + // Ideally users will resubmit the request in this case. + if (domainStatesEnabled.length < threshold) { + throw new Error('Insufficient number of signer responses. Domain may be disabled') + } + + // Set n to last signer index in a quorum of signers are sorted from least to most restrictive. + const n = threshold - 1 + + const domainStatesAscendingByCounter = domainStatesEnabled.sort((a, b) => a.counter - b.counter) + const nthLeastRestrictiveByCounter = domainStatesAscendingByCounter[n] + const thresholdCounter = nthLeastRestrictiveByCounter.counter + + // Client should submit requests with nonce === thresholdCounter + + const domainStatesWithThresholdCounter = domainStatesEnabled.filter( + (ds) => ds.counter <= thresholdCounter + ) + + const domainStatesAscendingByTimestampRestrictiveness = domainStatesWithThresholdCounter.sort( + (a, b) => a.timer - a.now - (b.timer - b.now) + /** + * Please see '@celo/phone-number-privacy-common/src/domains/sequential-delay.ts' + * and https://github.com/celo-org/celo-proposals/blob/master/CIPs/CIP-0040/sequentialDelayDomain.md + * + * For a given DomainState, it is always the case that 'now' >= 'timer'. This ordering ensures + * that we take the 'timer' and 'date' from the same DomainState while still returning a reasonable + * definition of the "nth least restrictive" values. For simplicity, we do not take into consideration + * the 'delay' until the next request will be accepted as that would require calculating this value for + * each DomainState with the checkSequentialDelayDomainState algorithm in sequential-delay.ts. + * This would add complexity because DomainStates may have different values for 'counter' that dramatically + * alter this 'delay' and we want to protect the user's quota by returning the lowest possible + * threshold 'counter'. Feel free to implement a more exact solution if you're up for a coding challenge :) + */ + ) + const nthLeastRestrictiveByTimestamps = domainStatesAscendingByTimestampRestrictiveness[n] + + return { + timer: nthLeastRestrictiveByTimestamps.timer, + counter: thresholdCounter, + disabled: false, + now: nthLeastRestrictiveByTimestamps.now, + } + } +} diff --git a/packages/phone-number-privacy/combiner/src/index.ts b/packages/phone-number-privacy/combiner/src/index.ts index 545cc30877c..0ed633927fb 100644 --- a/packages/phone-number-privacy/combiner/src/index.ts +++ b/packages/phone-number-privacy/combiner/src/index.ts @@ -1,86 +1,80 @@ -import { ErrorMessage, loggerMiddleware } from '@celo/phone-number-privacy-common' -import Logger from 'bunyan' import * as functions from 'firebase-functions' -import { performance, PerformanceObserver } from 'perf_hooks' -import config, { FORNO_ALFAJORES } from './config' -import { handleGetContactMatches } from './match-making/get-contact-matches' -import { handleGetBlindedMessageSig } from './signing/get-threshold-signature' +import config from './config' +import { startCombiner } from './server' require('dotenv').config() -async function meterResponse( - handler: (req: functions.Request, res: functions.Response) => Promise, - req: functions.Request, - res: functions.Response, - endpoint?: string -) { - if (!res.locals) { - res.locals = {} - } - const logger: Logger = loggerMiddleware(req, res) - logger.fields.endpoint = endpoint - logger.info({ req: req.body }, 'Request received') - const eventLoopLagMeasurementStart = Date.now() - setTimeout(() => { - const eventLoopLag = Date.now() - eventLoopLagMeasurementStart - logger.info({ eventLoopLag }, 'Measure event loop lag') - }) - const startMark = `Begin ${handler.name}` - const endMark = `End ${handler.name}` - const entryName = `${handler.name} latency` - - const obs = new PerformanceObserver((list) => { - const entry = list.getEntriesByName(entryName)[0] - if (entry) { - logger.info({ latency: entry }, 'e2e response latency measured') - } - }) - obs.observe({ entryTypes: ['measure'], buffered: true }) +export const combiner = functions + .region('us-central1', 'europe-west3') + .runWith(config.cloudFunction) + .https.onRequest(startCombiner(config)) - performance.mark(startMark) - await handler(req, res) - .then(() => { - logger.info({ res }, 'Response sent') - }) - .catch((err) => { - logger.error(ErrorMessage.UNKNOWN_ERROR) - logger.error(err) - }) - performance.mark(endMark) - performance.measure(entryName, startMark, endMark) - performance.clearMarks() - obs.disconnect() -} +// TODO(2.0.0, deployment) determine if we can delete these endpoints in favor of the above in a backwards compatible way. This will require testing e2e against a deployed service. +/* -// EG. curl -v "http://localhost:5000/celo-phone-number-privacy/us-central1/getBlindedMessageSig" -H "Authorization: 0xfc2ee61c4d18b93374fdd525c9de09d01398f7d153d17340b9ae156f94a1eb3237207d9aacb42e7f2f4ee0cf2621ab6d5a0837211665a99e16e3367f5209a56b1b" -d '{"blindedQueryPhoneNumber":"+Dzuylsdcv1ZxbRcQwhQ29O0UJynTNYufjWc4jpw2Zr9FLu5gSU8bvvDJ3r/Nj+B","account":"0xdd18d08f1c2619ede729c26cc46da19af0a2aa7f", "hashedPhoneNumber":"0x8fb77f2aff2ef0343706535dc702fc99f61a5d1b8e46d7c144c80fd156826a77"}' -H 'Content-Type: application/json' +const pnpSignHandler = new Controller( + new PnpSignAction( + config.phoneNumberPrivacy, + new PnpSignIO(config.phoneNumberPrivacy) + ) +) export const getBlindedMessageSig = functions .region('us-central1', 'europe-west3') - .runWith({ - // Keep instances warm for this latency-critical function - // @ts-ignore https://firebase.google.com/docs/functions/manage-functions#reduce_the_number_of_cold_starts - minInstances: config.blockchain.provider === FORNO_ALFAJORES ? 0 : 3, + .runWith(config.cloudFunction) + .https.onRequest(async (req: functions.Request, res: functions.Response) => { + return meterResponse( + pnpSignHandler.handle.bind(pnpSignHandler), + req, + res, + Endpoint.SIGN_MESSAGE + ) }) - .https.onRequest(async (req: functions.Request, res: functions.Response) => - meterResponse(handleGetBlindedMessageSig, req, res, '/getBlindedMessageSig') - ) -// EG. curl -v "http://localhost:5000/celo-phone-number-privacy/us-central1/getContactMatches" -H "Authorization: " -d '{"userPhoneNumber": "+99999999999", "contactPhoneNumbers": ["+5555555555", "+3333333333"], "account": "0x117ea45d497ab022b85494ba3ab6f52969bf6812"}' -H 'Content-Type: application/json' -export const getContactMatches = functions +const domainSignHandler = new Controller( + new DomainSignAction( + config.domains, + new DomainSignIO( + config.domains + ), + new DomainThresholdStateService(config.domains) + ) +) +export const domainSign = functions .region('us-central1', 'europe-west3') - .runWith({ - // Keep instances warm for this latency-critical function - // @ts-ignore https://firebase.google.com/docs/functions/manage-functions#reduce_the_number_of_cold_starts - minInstances: config.blockchain.provider === FORNO_ALFAJORES ? 0 : 3, + .runWith(config.cloudFunction) + .https.onRequest(async (req: functions.Request, res: functions.Response) => { + return meterResponse(domainSignHandler.handle.bind(domainSignHandler), req, res, Endpoint.DOMAIN_SIGN) }) - .https.onRequest(async (req: functions.Request, res: functions.Response) => - meterResponse(handleGetContactMatches, req, res, '/getContactMatches') + +const domainQuotaStatusHandler = new Controller( + new DomainQuotaAction( + config.domains, + new DomainQuotaIO( + config.domains + ), + new DomainThresholdStateService(config.domains) ) +) +export const domainQuotaStatus = functions + .region('us-central1', 'europe-west3') + .runWith(config.cloudFunction) + .https.onRequest(async (req: functions.Request, res: functions.Response) => { + return meterResponse(domainQuotaStatusHandler.handle.bind(domainQuotaStatusHandler), req, res, Endpoint.DOMAIN_QUOTA_STATUS) + }) -// TODO: Fix status cloud function. It currenly just returns an empty object. -// export const status = functions -// .region('us-central1', 'europe-west3') -// .https.onRequest(async (_req: functions.Request, res: functions.Response) => { -// await Promise.resolve(res.status(200).json({ -// version: VERSION, -// })) -// }) +const domainDisableHandler = new Controller( + new DomainDisableAction( + config.domains, + new DomainDisableIO( + config.domains, + ) + ) +) +export const domainDisable = functions + .region('us-central1', 'europe-west3') + .runWith(config.cloudFunction) + .https.onRequest(async (req: functions.Request, res: functions.Response) => { + return meterResponse(domainDisableHandler.handle.bind(domainDisableHandler), req, res, Endpoint.DISABLE_DOMAIN) + }) +*/ +export * from './config' diff --git a/packages/phone-number-privacy/combiner/src/match-making/get-contact-matches.ts b/packages/phone-number-privacy/combiner/src/match-making/get-contact-matches.ts deleted file mode 100644 index b3eb7776c84..00000000000 --- a/packages/phone-number-privacy/combiner/src/match-making/get-contact-matches.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { - authenticateUser, - ErrorMessage, - GetContactMatchesRequest, - getDataEncryptionKey, - hasValidAccountParam, - hasValidContactPhoneNumbersParam, - hasValidIdentifier, - hasValidUserPhoneNumberParam, - isVerified, - verifyDEKSignature, - WarningMessage, -} from '@celo/phone-number-privacy-common' -import Logger from 'bunyan' -import { Request, Response } from 'firebase-functions' -import { respondWithError } from '../common/error-utils' -import config, { - E2E_TEST_ACCOUNTS, - E2E_TEST_PHONE_NUMBERS, - FORNO_ALFAJORES, - VERSION, -} from '../config' -import { - getAccountSignedUserPhoneNumberRecord, - getDekSignerRecord, - getDidMatchmaking, - setDidMatchmaking, -} from '../database/wrappers/account' -import { getNumberPairContacts, setNumberPairContacts } from '../database/wrappers/number-pairs' -import { getContractKit } from '../web3/contracts' - -interface ContactMatch { - phoneNumber: string -} -export interface VerifiedPhoneNumberDekSignature { - signedUserPhoneNumber: string - dekSigner: string -} - -export async function handleGetContactMatches(request: Request, response: Response) { - const logger: Logger = response.locals.logger - try { - if (!isValidGetContactMatchesInput(request.body)) { - respondWithError(response, 400, WarningMessage.INVALID_INPUT, logger) - return - } - - // TODO: this error handling shouldn't be necessary here but there is a bug in the common package's - // error handling. Remove this or refactor once that bug is resolved. - let isAuthenticated = true // We assume user is authenticated on Forno errors - try { - isAuthenticated = await authenticateUser(request, getContractKit(), logger) - } catch { - logger.error('Forno error caught in handleGetContactMatches line 57') // Temporary for debugging - logger.error(ErrorMessage.CONTRACT_GET_FAILURE) - } - if (!isAuthenticated) { - respondWithError(response, 401, WarningMessage.UNAUTHENTICATED_USER, logger) - return - } - - const { - account, - userPhoneNumber, - contactPhoneNumbers, - hashedPhoneNumber, - signedUserPhoneNumber, - } = request.body - - if (!shouldBypassVerificationForE2ETesting(userPhoneNumber, account)) { - // TODO: this error handling shouldn't be necessary here but there is a bug in the common package's - // error handling. Remove this or refactor once that bug is resolved. - let _isVerified = true // We assume user is authenticated on Forno errors - try { - _isVerified = await isVerified(account, hashedPhoneNumber, getContractKit(), logger) - } catch { - logger.error('Forno error caught in handleGetContactMatches line 80') // Temporary for debugging - logger.error(ErrorMessage.CONTRACT_GET_FAILURE) - } - if (!_isVerified) { - respondWithError(response, 403, WarningMessage.UNVERIFIED_USER_ATTEMPT_TO_MATCHMAKE, logger) - return - } - } else { - logger.warn( - { account, userPhoneNumber }, - 'Allowing request to bypass verification for e2e testing' - ) - } - - // If we are unsure whether a phone number signature is valid but we don't want to block the user, - // we just set verifiedPhoneNumberDekSig to undefined so that it is not stored in the database - // and fulfill the request as usual. - let verifiedPhoneNumberDekSig: VerifiedPhoneNumberDekSignature | undefined - if (signedUserPhoneNumber) { - // TODO: this error handling shouldn't be necessary here but there is a bug in the common package's - // error handling. Remove this or refactor once that bug is resolved. - let dekSigner = '' - try { - dekSigner = await getDataEncryptionKey(account, getContractKit(), logger) - } catch { - logger.error(ErrorMessage.CONTRACT_GET_FAILURE) - logger.warn( - 'Failed to retrieve DEK to verify signedUserPhoneNumber. Request wont be recorded.' - ) - logger.error('Forno error caught in handleGetContactMatches line 109') // Temporary for debugging - } - if (dekSigner) { - if ( - !verifyDEKSignature(userPhoneNumber, signedUserPhoneNumber, dekSigner, logger, { - insecureAllowIncorrectlyGeneratedSignature: true, - }) - ) { - respondWithError( - response, - 403, - WarningMessage.INVALID_USER_PHONE_NUMBER_SIGNATURE, - logger - ) - return - } - verifiedPhoneNumberDekSig = { dekSigner, signedUserPhoneNumber } - } - } - - const invalidReplay = await isInvalidReplay( - account, - userPhoneNumber, - logger, - signedUserPhoneNumber - ).catch(() => { - logger.warn( - 'Failed to determine that user is not requerying matches for a new number. Fullfilling request without recording signature.' - ) - verifiedPhoneNumberDekSig = undefined - }) - if (invalidReplay) { - respondWithError(response, 403, WarningMessage.DUPLICATE_REQUEST_TO_MATCHMAKE, logger) - return - } - - await finishMatchmaking( - account, - userPhoneNumber, - contactPhoneNumbers, - response, - logger, - verifiedPhoneNumberDekSig - ) - } catch (err) { - logger.error('Failed to getContactMatches') - logger.error(err) - respondWithError(response, 500, ErrorMessage.UNKNOWN_ERROR, logger) - } -} - -async function finishMatchmaking( - account: string, - userPhoneNumber: string, - contactPhoneNumbers: string[], - response: Response, - logger: Logger, - verifiedPhoneNumberDekSig?: VerifiedPhoneNumberDekSignature -) { - const matchedContacts: ContactMatch[] = ( - await getNumberPairContacts(userPhoneNumber, contactPhoneNumbers, logger) - ).map((numberPair) => ({ phoneNumber: numberPair })) - logger.info( - { - percentageOfContactsCoveredByMatchmaking: matchedContacts.length / contactPhoneNumbers.length, - }, - 'measured percentage of contacts covered by matchmaking' - ) - await setNumberPairContacts(userPhoneNumber, contactPhoneNumbers, logger) - await setDidMatchmaking(account, logger, verifiedPhoneNumberDekSig) - response.json({ success: true, matchedContacts, version: VERSION }) -} - -async function isReplay(account: string, logger: Logger): Promise { - return getDidMatchmaking(account, logger).catch((err) => { - logger.warn('Failed to determine if user has performed matchmaking.') - throw err - }) -} - -async function isInvalidReplay( - account: string, - userPhoneNumber: string, - logger: Logger, - signedUserPhoneNumber?: string -) { - if (!(await isReplay(account, logger))) { - return false - } - if (!signedUserPhoneNumber) { - // If the account has performed matchmaking before and does not provide their signed phone number in the request, - // we return an error bc they could be querying matches for a new number that isn't theirs. - logger.info( - { account }, - 'Blocking account from requerying matches without providing a phone number signature.' - ) - return true - } - const signedUserPhoneNumberRecord = await getAccountSignedUserPhoneNumberRecord( - account, - logger - ).catch((err) => { - logger.warn( - { account }, - 'Allowing account to perform matchmaking due to db error finding phone number record. We will not record their phone number this time.' - ) - throw err - }) - if (!signedUserPhoneNumberRecord) { - logger.info( - { account }, - 'Allowing account to perform matchmaking since we have no record of the phone number it used before.' - ) - return false - } - if (signedUserPhoneNumberRecord !== signedUserPhoneNumber) { - if (await userHasNewDek(account, userPhoneNumber, signedUserPhoneNumberRecord, logger)) { - logger.info({ account }, 'Allowing account to requery matches after key rotation.') - return false - } - logger.info( - { account }, - 'Blocking account from querying matches for a different phone number than before.' - ) - return true - } - logger.info( - { account }, - 'Allowing account to requery matches for the same phone number as before.' - ) - return false -} - -async function userHasNewDek( - account: string, - userPhoneNumber: string, - signedUserPhoneNumberRecord: string, - logger: Logger -): Promise { - const dekSignerRecord = await getDekSignerRecord(account, logger) - const isKeyRotation = - !!dekSignerRecord && - verifyDEKSignature(userPhoneNumber, signedUserPhoneNumberRecord, dekSignerRecord, logger, { - insecureAllowIncorrectlyGeneratedSignature: true, - }) - if (isKeyRotation) { - logger.info( - { - account, - dekSignerRecord, - }, - 'User has rotated their dek since first requesting matches.' - ) - } - return isKeyRotation -} - -function isValidGetContactMatchesInput(requestBody: GetContactMatchesRequest): boolean { - return ( - hasValidAccountParam(requestBody) && - hasValidUserPhoneNumberParam(requestBody) && - hasValidContactPhoneNumbersParam(requestBody) && - hasValidIdentifier(requestBody) - ) -} - -function shouldBypassVerificationForE2ETesting(userPhoneNumber: string, account: string): boolean { - return ( - config.blockchain.provider === FORNO_ALFAJORES && - E2E_TEST_PHONE_NUMBERS.includes(userPhoneNumber) && - E2E_TEST_ACCOUNTS.includes(account) - ) -} diff --git a/packages/phone-number-privacy/combiner/src/pnp/endpoints/quota/action.ts b/packages/phone-number-privacy/combiner/src/pnp/endpoints/quota/action.ts new file mode 100644 index 00000000000..e8f607965f1 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/pnp/endpoints/quota/action.ts @@ -0,0 +1,42 @@ +import { ErrorMessage, PnpQuotaRequest } from '@celo/phone-number-privacy-common' +import { CombineAction } from '../../../common/combine' +import { IO } from '../../../common/io' +import { Session } from '../../../common/session' +import { OdisConfig } from '../../../config' +import { PnpSignerResponseLogger } from '../../services/log-responses' +import { PnpThresholdStateService } from '../../services/threshold-state' + +export class PnpQuotaAction extends CombineAction { + readonly responseLogger: PnpSignerResponseLogger = new PnpSignerResponseLogger() + + constructor( + readonly config: OdisConfig, + readonly thresholdStateService: PnpThresholdStateService, + readonly io: IO + ) { + super(config, io) + } + + async combine(session: Session): Promise { + this.responseLogger.logResponseDiscrepancies(session) + this.responseLogger.logFailOpenResponses(session) + + const { threshold } = session.keyVersionInfo + + if (session.responses.length >= threshold) { + try { + const quotaStatus = this.thresholdStateService.findCombinerQuotaState(session) + this.io.sendSuccess(200, session.response, quotaStatus, session.warnings) + return + } catch (err) { + session.logger.error(err, 'Error combining signer quota status responses') + } + } + this.io.sendFailure( + ErrorMessage.THRESHOLD_PNP_QUOTA_STATUS_FAILURE, + session.getMajorityErrorCode() ?? 500, + session.response, + session.logger + ) + } +} diff --git a/packages/phone-number-privacy/combiner/src/pnp/endpoints/quota/io.ts b/packages/phone-number-privacy/combiner/src/pnp/endpoints/quota/io.ts new file mode 100644 index 00000000000..ac34c49bd86 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/pnp/endpoints/quota/io.ts @@ -0,0 +1,103 @@ +import { ContractKit } from '@celo/contractkit' +import { + authenticateUser, + CombinerEndpoint, + ErrorType, + getSignerEndpoint, + hasValidAccountParam, + identifierIsValidIfExists, + isBodyReasonablySized, + PnpQuotaRequest, + PnpQuotaRequestSchema, + PnpQuotaResponse, + PnpQuotaResponseFailure, + PnpQuotaResponseSchema, + PnpQuotaResponseSuccess, + PnpQuotaStatus, + send, + SignerEndpoint, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import { Request, Response } from 'express' +import * as t from 'io-ts' +import { IO } from '../../../common/io' +import { Session } from '../../../common/session' +import { OdisConfig, VERSION } from '../../../config' + +export class PnpQuotaIO extends IO { + readonly endpoint: CombinerEndpoint = CombinerEndpoint.PNP_QUOTA + readonly signerEndpoint: SignerEndpoint = getSignerEndpoint(this.endpoint) + readonly requestSchema: t.Type = PnpQuotaRequestSchema + readonly responseSchema: t.Type< + PnpQuotaResponse, + PnpQuotaResponse, + unknown + > = PnpQuotaResponseSchema + + constructor(readonly config: OdisConfig, readonly kit: ContractKit) { + super(config) + } + + async init( + request: Request<{}, {}, unknown>, + response: Response + ): Promise | null> { + if (!super.inputChecks(request, response)) { + return null + } + if (!(await this.authenticate(request, response.locals.logger))) { + this.sendFailure(WarningMessage.UNAUTHENTICATED_USER, 401, response) + return null + } + const keyVersionInfo = this.getKeyVersionInfo(request, response.locals.logger) + return new Session(request, response, keyVersionInfo) + } + + validateClientRequest( + request: Request<{}, {}, unknown> + ): request is Request<{}, {}, PnpQuotaRequest> { + return ( + super.validateClientRequest(request) && + hasValidAccountParam(request.body) && + identifierIsValidIfExists(request.body) && + isBodyReasonablySized(request.body) + ) + } + + async authenticate(request: Request<{}, {}, PnpQuotaRequest>, logger: Logger): Promise { + return authenticateUser(request, this.kit, logger, this.config.shouldFailOpen) + } + + sendSuccess( + status: number, + response: Response, + quotaStatus: PnpQuotaStatus, + warnings: string[] + ) { + send( + response, + { + success: true, + version: VERSION, + ...quotaStatus, + warnings, + }, + status, + response.locals.logger + ) + } + + sendFailure(error: ErrorType, status: number, response: Response) { + send( + response, + { + success: false, + version: VERSION, + error, + }, + status, + response.locals.logger + ) + } +} diff --git a/packages/phone-number-privacy/combiner/src/pnp/endpoints/sign/action.ts b/packages/phone-number-privacy/combiner/src/pnp/endpoints/sign/action.ts new file mode 100644 index 00000000000..ed011827808 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/pnp/endpoints/sign/action.ts @@ -0,0 +1,56 @@ +import { + ErrorMessage, + ErrorType, + LegacySignMessageRequest, + SignMessageRequest, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { CryptoSession } from '../../../common/crypto-session' +import { SignAction } from '../../../common/sign' +import { PnpSignerResponseLogger } from '../../services/log-responses' + +export class PnpSignAction extends SignAction { + readonly responseLogger: PnpSignerResponseLogger = new PnpSignerResponseLogger() + + combine(session: CryptoSession): void { + this.responseLogger.logResponseDiscrepancies(session) + this.responseLogger.logFailOpenResponses(session) + + if (session.crypto.hasSufficientSignatures()) { + try { + const combinedSignature = session.crypto.combineBlindedSignatureShares( + this.parseBlindedMessage(session.request.body), + session.logger + ) + + const quotaStatus = this.thresholdStateService.findCombinerQuotaState(session) + return this.io.sendSuccess( + 200, + session.response, + combinedSignature, + quotaStatus, + session.warnings + ) + } catch (error) { + // May fail upon combining signatures if too many sigs are invalid + // Fallback to handleMissingSignatures + session.logger.error(error) + } + } + + this.handleMissingSignatures(session) + } + + protected parseBlindedMessage(req: SignMessageRequest | LegacySignMessageRequest): string { + return req.blindedQueryPhoneNumber + } + + protected errorCodeToError(errorCode: number): ErrorType { + switch (errorCode) { + case 403: + return WarningMessage.EXCEEDED_QUOTA + default: + return ErrorMessage.NOT_ENOUGH_PARTIAL_SIGNATURES + } + } +} diff --git a/packages/phone-number-privacy/combiner/src/pnp/endpoints/sign/io.legacy.ts b/packages/phone-number-privacy/combiner/src/pnp/endpoints/sign/io.legacy.ts new file mode 100644 index 00000000000..6b8c03ca1c4 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/pnp/endpoints/sign/io.legacy.ts @@ -0,0 +1,124 @@ +import { ContractKit } from '@celo/contractkit' +import { + authenticateUser, + CombinerEndpoint, + ErrorType, + getSignerEndpoint, + hasValidAccountParam, + hasValidBlindedPhoneNumberParam, + identifierIsValidIfExists, + isBodyReasonablySized, + LegacySignMessageRequest, + LegacySignMessageRequestSchema, + PnpQuotaStatus, + send, + SignerEndpoint, + SignMessageResponse, + SignMessageResponseFailure, + SignMessageResponseSchema, + SignMessageResponseSuccess, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import { Request, Response } from 'express' +import * as t from 'io-ts' +import { BLSCryptographyClient } from '../../../common/crypto-clients/bls-crypto-client' +import { CryptoSession } from '../../../common/crypto-session' +import { IO } from '../../../common/io' +import { OdisConfig, VERSION } from '../../../config' + +export class LegacyPnpSignIO extends IO { + readonly endpoint: CombinerEndpoint = CombinerEndpoint.LEGACY_PNP_SIGN + readonly signerEndpoint: SignerEndpoint = getSignerEndpoint(this.endpoint) + readonly requestSchema: t.Type< + LegacySignMessageRequest, + LegacySignMessageRequest, + unknown + > = LegacySignMessageRequestSchema + readonly responseSchema: t.Type< + SignMessageResponse, + SignMessageResponse, + unknown + > = SignMessageResponseSchema + + constructor(readonly config: OdisConfig, readonly kit: ContractKit) { + super(config) + } + + async init( + request: Request<{}, {}, unknown>, + response: Response + ): Promise | null> { + if (!super.inputChecks(request, response)) { + return null + } + if (!this.requestHasSupportedKeyVersion(request, response.locals.logger)) { + this.sendFailure(WarningMessage.INVALID_KEY_VERSION_REQUEST, 400, response) + return null + } + if (!(await this.authenticate(request, response.locals.logger))) { + this.sendFailure(WarningMessage.UNAUTHENTICATED_USER, 401, response) + return null + } + const keyVersionInfo = this.getKeyVersionInfo(request, response.locals.logger) + return new CryptoSession( + request, + response, + keyVersionInfo, + new BLSCryptographyClient(keyVersionInfo) + ) + } + + validateClientRequest( + request: Request<{}, {}, unknown> + ): request is Request<{}, {}, LegacySignMessageRequest> { + return ( + super.validateClientRequest(request) && + hasValidAccountParam(request.body) && + hasValidBlindedPhoneNumberParam(request.body) && + identifierIsValidIfExists(request.body) && + isBodyReasonablySized(request.body) + ) + } + + async authenticate( + request: Request<{}, {}, LegacySignMessageRequest>, + logger: Logger + ): Promise { + return authenticateUser(request, this.kit, logger, this.config.shouldFailOpen) + } + + sendSuccess( + status: number, + response: Response, + signature: string, + quotaStatus: PnpQuotaStatus, + warnings: string[] + ) { + send( + response, + { + success: true, + version: VERSION, + signature, + ...quotaStatus, + warnings, + }, + status, + response.locals.logger + ) + } + + sendFailure(error: ErrorType, status: number, response: Response) { + send( + response, + { + success: false, + version: VERSION, + error, + }, + status, + response.locals.logger + ) + } +} diff --git a/packages/phone-number-privacy/combiner/src/pnp/endpoints/sign/io.ts b/packages/phone-number-privacy/combiner/src/pnp/endpoints/sign/io.ts new file mode 100644 index 00000000000..601f1ed384f --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/pnp/endpoints/sign/io.ts @@ -0,0 +1,123 @@ +import { ContractKit } from '@celo/contractkit' +import { + authenticateUser, + CombinerEndpoint, + ErrorType, + getSignerEndpoint, + hasValidAccountParam, + hasValidBlindedPhoneNumberParam, + isBodyReasonablySized, + PnpQuotaStatus, + send, + SignerEndpoint, + SignMessageRequest, + SignMessageRequestSchema, + SignMessageResponse, + SignMessageResponseFailure, + SignMessageResponseSchema, + SignMessageResponseSuccess, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import { Request, Response } from 'express' +import * as t from 'io-ts' +import { BLSCryptographyClient } from '../../../common/crypto-clients/bls-crypto-client' +import { CryptoSession } from '../../../common/crypto-session' +import { IO } from '../../../common/io' +import { Session } from '../../../common/session' +import { OdisConfig, VERSION } from '../../../config' + +export class PnpSignIO extends IO { + readonly endpoint: CombinerEndpoint = CombinerEndpoint.PNP_SIGN + readonly signerEndpoint: SignerEndpoint = getSignerEndpoint(this.endpoint) + readonly requestSchema: t.Type< + SignMessageRequest, + SignMessageRequest, + unknown + > = SignMessageRequestSchema + readonly responseSchema: t.Type< + SignMessageResponse, + SignMessageResponse, + unknown + > = SignMessageResponseSchema + + constructor(readonly config: OdisConfig, readonly kit: ContractKit) { + super(config) + } + + async init( + request: Request<{}, {}, unknown>, + response: Response + ): Promise | null> { + if (!super.inputChecks(request, response)) { + return null + } + if (!this.requestHasSupportedKeyVersion(request, response.locals.logger)) { + this.sendFailure(WarningMessage.INVALID_KEY_VERSION_REQUEST, 400, response) + return null + } + if (!(await this.authenticate(request, response.locals.logger))) { + this.sendFailure(WarningMessage.UNAUTHENTICATED_USER, 401, response) + return null + } + const keyVersionInfo = this.getKeyVersionInfo(request, response.locals.logger) + return new CryptoSession( + request, + response, + keyVersionInfo, + new BLSCryptographyClient(keyVersionInfo) + ) + } + + validateClientRequest( + request: Request<{}, {}, unknown> + ): request is Request<{}, {}, SignMessageRequest> { + return ( + super.validateClientRequest(request) && + hasValidAccountParam(request.body) && + hasValidBlindedPhoneNumberParam(request.body) && + isBodyReasonablySized(request.body) + ) + } + + async authenticate( + request: Request<{}, {}, SignMessageRequest>, + logger: Logger + ): Promise { + return authenticateUser(request, this.kit, logger, this.config.shouldFailOpen) + } + + sendSuccess( + status: number, + response: Response, + signature: string, + quotaStatus: PnpQuotaStatus, + warnings: string[] + ) { + send( + response, + { + success: true, + version: VERSION, + signature, + ...quotaStatus, + warnings, + }, + status, + response.locals.logger + ) + } + + sendFailure(error: ErrorType, status: number, response: Response) { + send( + response, + { + success: false, + version: VERSION, + error, + }, + status, + response.locals.logger + ) + } +} diff --git a/packages/phone-number-privacy/combiner/src/pnp/services/log-responses.ts b/packages/phone-number-privacy/combiner/src/pnp/services/log-responses.ts new file mode 100644 index 00000000000..26f50453783 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/pnp/services/log-responses.ts @@ -0,0 +1,130 @@ +import { + ErrorMessage, + PnpQuotaRequest, + SignMessageRequest, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { Session } from '../../common/session' +import { + MAX_BLOCK_DISCREPANCY_THRESHOLD, + MAX_QUERY_COUNT_DISCREPANCY_THRESHOLD, + MAX_TOTAL_QUOTA_DISCREPANCY_THRESHOLD, +} from '../../config' + +export class PnpSignerResponseLogger { + logResponseDiscrepancies(session: Session | Session): void { + // TODO responses should all already be successes due to CombineAction receiveSuccess + // https://github.com/celo-org/celo-monorepo/issues/9826 + + const parsedResponses: Array<{ + signerUrl: string + values: { + version: string + performedQueryCount: number + totalQuota: number + blockNumber?: number + warnings?: string[] + } + }> = [] + session.responses.forEach((response) => { + if (response.res.success) { + const { version, performedQueryCount, totalQuota, blockNumber, warnings } = response.res + parsedResponses.push({ + signerUrl: response.url, + values: { version, performedQueryCount, totalQuota, blockNumber, warnings }, + }) + } + }) + if (parsedResponses.length === 0) { + session.logger.warn('No successful signer responses found!') + return + } + + // log all responses if we notice any discrepancies to aid with debugging + const first = JSON.stringify(parsedResponses[0].values) + for (let i = 1; i < parsedResponses.length; i++) { + if (JSON.stringify(parsedResponses[i].values) !== first) { + session.logger.warn({ parsedResponses }, WarningMessage.SIGNER_RESPONSE_DISCREPANCIES) + session.warnings.push(WarningMessage.SIGNER_RESPONSE_DISCREPANCIES) + break + } + } + + // blockNumber + parsedResponses.forEach((res) => { + if (res.values.blockNumber === undefined) { + session.logger.warn( + { signerUrl: res.signerUrl }, + 'Signer responded with undefined blockNumber' + ) + } + }) + const sortedByBlockNumber = parsedResponses + .filter((res) => !!res.values.blockNumber) + .sort((a, b) => a.values.blockNumber! - b.values.blockNumber!) + if ( + sortedByBlockNumber.length && + sortedByBlockNumber[sortedByBlockNumber.length - 1].values.blockNumber! - + sortedByBlockNumber[0].values.blockNumber! >= + MAX_BLOCK_DISCREPANCY_THRESHOLD + ) { + session.logger.error( + { sortedByBlockNumber }, + WarningMessage.INCONSISTENT_SIGNER_BLOCK_NUMBERS + ) + session.warnings.push(WarningMessage.INCONSISTENT_SIGNER_BLOCK_NUMBERS) + } + + // totalQuota + const sortedByTotalQuota = parsedResponses.sort( + (a, b) => a.values.totalQuota - b.values.totalQuota + ) + if ( + sortedByTotalQuota[sortedByTotalQuota.length - 1].values.totalQuota - + sortedByTotalQuota[0].values.totalQuota >= + MAX_TOTAL_QUOTA_DISCREPANCY_THRESHOLD + ) { + session.logger.error( + { sortedByTotalQuota }, + WarningMessage.INCONSISTENT_SIGNER_QUOTA_MEASUREMENTS + ) + session.warnings.push(WarningMessage.INCONSISTENT_SIGNER_QUOTA_MEASUREMENTS) + } + + // performedQueryCount + const sortedByQueryCount = parsedResponses.sort( + (a, b) => a.values.performedQueryCount - b.values.performedQueryCount + ) + if ( + sortedByQueryCount[sortedByQueryCount.length - 1].values.performedQueryCount - + sortedByQueryCount[0].values.performedQueryCount >= + MAX_QUERY_COUNT_DISCREPANCY_THRESHOLD + ) { + session.logger.error( + { sortedByQueryCount }, + WarningMessage.INCONSISTENT_SIGNER_QUERY_MEASUREMENTS + ) + session.warnings.push(WarningMessage.INCONSISTENT_SIGNER_QUERY_MEASUREMENTS) + } + } + + logFailOpenResponses(session: Session | Session): void { + session.responses.forEach((response) => { + if (response.res.success) { + const { warnings } = response.res + if (warnings) { + warnings.forEach((warning) => { + switch (warning) { + case ErrorMessage.FAILING_OPEN: + case ErrorMessage.FAILURE_TO_GET_TOTAL_QUOTA: + case ErrorMessage.FAILURE_TO_GET_DEK: + session.logger.error({ warning, service: response.url }, ErrorMessage.FAILING_OPEN) + default: + break + } + }) + } + } + }) + } +} diff --git a/packages/phone-number-privacy/combiner/src/pnp/services/threshold-state.ts b/packages/phone-number-privacy/combiner/src/pnp/services/threshold-state.ts new file mode 100644 index 00000000000..ee46bab51d4 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/pnp/services/threshold-state.ts @@ -0,0 +1,49 @@ +import { + PnpQuotaRequest, + PnpQuotaStatus, + SignMessageRequest, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { Session } from '../../common/session' +import { MAX_TOTAL_QUOTA_DISCREPANCY_THRESHOLD } from '../../config' +export class PnpThresholdStateService { + findCombinerQuotaState(session: Session): PnpQuotaStatus { + const { threshold } = session.keyVersionInfo + const signerResponses = session.responses + .map((signerResponse) => signerResponse.res) + .filter((res) => res.success) as PnpQuotaStatus[] + const sortedResponses = signerResponses.sort( + (a, b) => b.totalQuota - b.performedQueryCount - (a.totalQuota - a.performedQueryCount) + ) + + const totalQuotaAvg = + sortedResponses.map((r) => r.totalQuota).reduce((a, b) => a + b) / sortedResponses.length + const totalQuotaStDev = Math.sqrt( + sortedResponses.map((r) => (r.totalQuota - totalQuotaAvg) ** 2).reduce((a, b) => a + b) / + sortedResponses.length + ) + if (totalQuotaStDev > MAX_TOTAL_QUOTA_DISCREPANCY_THRESHOLD) { + // TODO(2.0.0): add alerting for this + throw new Error(WarningMessage.INCONSISTENT_SIGNER_QUOTA_MEASUREMENTS) + } else if (totalQuotaStDev > 0) { + session.warnings.push( + WarningMessage.INCONSISTENT_SIGNER_QUOTA_MEASUREMENTS + + ', using threshold signer as best guess' + ) + } + + // TODO(2.0.0) currently this check is not needed, as checking for sufficient number of responses and + // filtering for successes is already done in the action. Consider adding back in based on the + // result of https://github.com/celo-org/celo-monorepo/issues/9826 + // if (signerResponses.length < threshold) { + // throw new Error('Insufficient number of successful signer responses') + // } + + const thresholdSigner = sortedResponses[threshold - 1] + return { + performedQueryCount: thresholdSigner.performedQueryCount, + totalQuota: thresholdSigner.totalQuota, + blockNumber: thresholdSigner.blockNumber, + } + } +} diff --git a/packages/phone-number-privacy/combiner/src/server.ts b/packages/phone-number-privacy/combiner/src/server.ts new file mode 100644 index 00000000000..791c8870d54 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/server.ts @@ -0,0 +1,203 @@ +import { ContractKit } from '@celo/contractkit' +import { + CombinerEndpoint, + Endpoint, + ErrorMessage, + getContractKit, + loggerMiddleware, + rootLogger, +} from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import express, { Request, Response } from 'express' +import { performance, PerformanceObserver } from 'perf_hooks' +import { CombinerConfig } from '.' +import { Controller } from './common/controller' +import { DomainDisableAction } from './domain/endpoints/disable/action' +import { DomainDisableIO } from './domain/endpoints/disable/io' +import { DomainQuotaAction } from './domain/endpoints/quota/action' +import { DomainQuotaIO } from './domain/endpoints/quota/io' +import { DomainSignAction } from './domain/endpoints/sign/action' +import { DomainSignIO } from './domain/endpoints/sign/io' +import { DomainThresholdStateService } from './domain/services/threshold-state' +import { PnpQuotaAction } from './pnp/endpoints/quota/action' +import { PnpQuotaIO } from './pnp/endpoints/quota/io' +import { PnpSignAction } from './pnp/endpoints/sign/action' +import { PnpSignIO } from './pnp/endpoints/sign/io' +import { LegacyPnpSignIO } from './pnp/endpoints/sign/io.legacy' +import { PnpThresholdStateService } from './pnp/services/threshold-state' + +require('events').EventEmitter.defaultMaxListeners = 15 + +export function startCombiner(config: CombinerConfig, kit?: ContractKit) { + const logger = rootLogger(config.serviceName) + + logger.info('Creating combiner express server') + const app = express() + // TODO get logger to show accurate serviceName + // (https://github.com/celo-org/celo-monorepo/issues/9809) + app.use(express.json({ limit: '0.2mb' }), loggerMiddleware(config.serviceName)) + + // Enable cross origin resource sharing from any domain so ODIS can be interacted with from web apps + // + // Security note: Allowing unrestricted cross-origin requests is acceptable here because any authenticated actions + // must include a signature in the request body. In particular, ODIS _does not_ use cookies to transmit authentication + // data. If ODIS is altered to use cookies for authentication data, this CORS policy should be reconsidered. + app.use((_, res, next) => { + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept') + next() + }) + + kit = kit ?? getContractKit(config.blockchain) + + const pnpThresholdStateService = new PnpThresholdStateService() + + const legacyPnpSign = new Controller( + new PnpSignAction( + config.phoneNumberPrivacy, + pnpThresholdStateService, + new LegacyPnpSignIO(config.phoneNumberPrivacy, kit) + ) + ) + app.post(CombinerEndpoint.LEGACY_PNP_SIGN, (req, res) => + meterResponse( + legacyPnpSign.handle.bind(legacyPnpSign), + req, + res, + CombinerEndpoint.LEGACY_PNP_SIGN, + config + ) + ) + + const pnpQuota = new Controller( + new PnpQuotaAction( + config.phoneNumberPrivacy, + pnpThresholdStateService, + new PnpQuotaIO(config.phoneNumberPrivacy, kit) + ) + ) + app.post(CombinerEndpoint.PNP_QUOTA, (req, res) => + meterResponse(pnpQuota.handle.bind(pnpQuota), req, res, CombinerEndpoint.PNP_QUOTA, config) + ) + + const pnpSign = new Controller( + new PnpSignAction( + config.phoneNumberPrivacy, + pnpThresholdStateService, + new PnpSignIO(config.phoneNumberPrivacy, kit) + ) + ) + app.post(CombinerEndpoint.PNP_SIGN, (req, res) => + meterResponse(pnpSign.handle.bind(pnpSign), req, res, CombinerEndpoint.PNP_SIGN, config) + ) + + const domainThresholdStateService = new DomainThresholdStateService(config.domains) + + const domainQuota = new Controller( + new DomainQuotaAction( + config.domains, + domainThresholdStateService, + new DomainQuotaIO(config.domains) + ) + ) + app.post(CombinerEndpoint.DOMAIN_QUOTA_STATUS, (req, res) => + meterResponse( + domainQuota.handle.bind(domainQuota), + req, + res, + CombinerEndpoint.DOMAIN_QUOTA_STATUS, + config + ) + ) + const domainSign = new Controller( + new DomainSignAction( + config.domains, + domainThresholdStateService, + new DomainSignIO(config.domains) + ) + ) + app.post(CombinerEndpoint.DOMAIN_SIGN, (req, res) => + meterResponse( + domainSign.handle.bind(domainSign), + req, + res, + CombinerEndpoint.DOMAIN_SIGN, + config + ) + ) + const domainDisable = new Controller( + new DomainDisableAction( + config.domains, + domainThresholdStateService, + new DomainDisableIO(config.domains) + ) + ) + app.post(CombinerEndpoint.DISABLE_DOMAIN, (req, res) => + meterResponse( + domainDisable.handle.bind(domainDisable), + req, + res, + CombinerEndpoint.DISABLE_DOMAIN, + config + ) + ) + + return app +} + +export async function meterResponse( + handler: (req: Request, res: Response) => Promise, + req: Request, + res: Response, + endpoint: Endpoint, + config: CombinerConfig +) { + if (!res.locals) { + res.locals = {} + } + const logger: Logger = loggerMiddleware(config.serviceName)(req, res) + logger.fields.endpoint = endpoint + logger.info({ req: req.body }, 'Request received') + const eventLoopLagMeasurementStart = Date.now() + setTimeout(() => { + const eventLoopLag = Date.now() - eventLoopLagMeasurementStart + logger.info({ eventLoopLag }, 'Measure event loop lag') + }) + const startMark = `Begin ${handler.name}` + const endMark = `End ${handler.name}` + const entryName = `${handler.name} latency` + + const obs = new PerformanceObserver((list) => { + const entry = list.getEntriesByName(entryName)[0] + if (entry) { + logger.info({ latency: entry }, 'e2e response latency measured') + } + }) + obs.observe({ entryTypes: ['measure'], buffered: true }) + + performance.mark(startMark) + await handler(req, res) + .then(() => { + logger.info({ res }, 'Response sent') + }) + .catch((err) => { + logger.error(ErrorMessage.CAUGHT_ERROR_IN_ENDPOINT_HANDLER) + logger.error(err) + if (!res.headersSent) { + logger.info('Responding with error in outer endpoint handler') + res.status(500).json({ + success: false, + error: ErrorMessage.UNKNOWN_ERROR, + }) + } else { + logger.error(ErrorMessage.ERROR_AFTER_RESPONSE_SENT) + } + }) + .finally(() => { + performance.mark(endMark) + performance.measure(entryName, startMark, endMark) + performance.clearMarks() + obs.disconnect() + }) +} diff --git a/packages/phone-number-privacy/combiner/src/signing/get-threshold-signature.ts b/packages/phone-number-privacy/combiner/src/signing/get-threshold-signature.ts deleted file mode 100644 index 60741efd4da..00000000000 --- a/packages/phone-number-privacy/combiner/src/signing/get-threshold-signature.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { - authenticateUser, - ErrorMessage, - GetBlindedMessageSigRequest, - hasValidAccountParam, - hasValidBlindedPhoneNumberParam, - identifierIsValidIfExists, - isBodyReasonablySized, - MAX_BLOCK_DISCREPANCY_THRESHOLD, - SignMessageResponse, - SignMessageResponseFailure, - SignMessageResponseSuccess, - WarningMessage, -} from '@celo/phone-number-privacy-common' -import AbortController from 'abort-controller' -import Logger from 'bunyan' -import { Request, Response } from 'firebase-functions' -import fetch, { Response as FetchResponse } from 'node-fetch' -import { performance, PerformanceObserver } from 'perf_hooks' -import { BLSCryptographyClient } from '../bls/bls-cryptography-client' -import { respondWithError } from '../common/error-utils' -import config, { VERSION } from '../config' -import { getContractKit } from '../web3/contracts' - -const PARTIAL_SIGN_MESSAGE_ENDPOINT = '/getBlindedMessagePartialSig' - -type SignerResponse = SignMessageResponseSuccess | SignMessageResponseFailure - -interface SignerService { - url: string - fallbackUrl?: string -} - -interface SignMsgRespWithStatus { - url: string - signMessageResponse: SignMessageResponse - status: number -} - -export async function handleGetBlindedMessageSig(request: Request, response: Response) { - const logger: Logger = response.locals.logger - - try { - if (!isValidGetSignatureInput(request.body)) { - respondWithError(response, 400, WarningMessage.INVALID_INPUT, logger) - return - } - if (!(await authenticateUser(request, getContractKit(), logger))) { - respondWithError(response, 401, WarningMessage.UNAUTHENTICATED_USER, logger) - return - } - logger.debug('Requesting signatures') - await requestSignatures(request, response) - } catch (err) { - logger.error('Unknown error in handleGetBlindedMessageSig') - logger.error(err) - respondWithError(response, 500, ErrorMessage.UNKNOWN_ERROR, logger) - } -} - -async function requestSignatures(request: Request, response: Response) { - const responses: SignMsgRespWithStatus[] = [] - const failedRequests = new Set() - const errorCodes: Map = new Map() - const blsCryptoClient = new BLSCryptographyClient() - - const logger: Logger = response.locals.logger - - const obs = new PerformanceObserver((list) => { - const entry = list.getEntries()[0] - logger.info({ latency: entry, signer: entry!.name }, 'Signer response latency measured') - }) - obs.observe({ entryTypes: ['measure'], buffered: true }) - - const signers = JSON.parse(config.odisServices.signers) as SignerService[] - let timedOut = false - const controller = new AbortController() - const timeout = setTimeout(() => { - timedOut = true - controller.abort() - }, config.odisServices.timeoutMilliSeconds) - - const signerReqs = signers.map((service) => { - const startMark = `Begin requestSignature ${service.url}` - const endMark = `End requestSignature ${service.url}` - const entryName = service.url - performance.mark(startMark) - - return requestSignature(service, request, controller, logger) - .then(async (res: FetchResponse) => { - const data = await res.text() - logger.info( - { signer: service, res: data, status: res.status }, - 'received requestSignature response from signer' - ) - if (res.ok) { - await handleSuccessResponse( - data, - res.status, - response, - responses, - service.url, - blsCryptoClient, - request.body.blindedQueryPhoneNumber, - controller - ) - } else { - handleFailedResponse( - service, - res.status, - signers.length, - failedRequests, - response, - controller, - errorCodes - ) - } - }) - .catch((err) => { - let status: number | undefined = 500 - if (err.name === 'AbortError') { - if (timedOut) { - status = 408 - logger.error({ signer: service }, ErrorMessage.TIMEOUT_FROM_SIGNER) - } else { - // Request was cancelled, assuming it would have been successful - status = undefined - logger.info({ signer: service }, WarningMessage.CANCELLED_REQUEST_TO_SIGNER) - } - } else { - logger.error({ signer: service }, ErrorMessage.ERROR_REQUESTING_SIGNATURE) - } - logger.error(err) - handleFailedResponse( - service, - status, - signers.length, - failedRequests, - response, - controller, - errorCodes - ) - }) - .finally(() => { - performance.mark(endMark) - performance.measure(entryName, startMark, endMark) - }) - }) - - await Promise.all(signerReqs) - clearTimeout(timeout) - performance.clearMarks() - obs.disconnect() - - logResponseDiscrepancies(responses, logger) - const majorityErrorCode = getMajorityErrorCode(errorCodes, logger) - if (blsCryptoClient.hasSufficientSignatures()) { - try { - const combinedSignature = await blsCryptoClient.combinePartialBlindedSignatures( - request.body.blindedQueryPhoneNumber, - logger - ) - response.json({ success: true, combinedSignature, version: VERSION }) - return - } catch { - // May fail upon combining signatures if too many sigs are invalid - // Fallback to handleMissingSignatures - } - } - handleMissingSignatures(majorityErrorCode, response, logger) -} - -async function handleSuccessResponse( - data: string, - status: number, - response: Response, - responses: SignMsgRespWithStatus[], - serviceUrl: string, - blsCryptoClient: BLSCryptographyClient, - blindedQueryPhoneNumber: string, - controller: AbortController -) { - const logger: Logger = response.locals.logger - const signResponse = JSON.parse(data) as SignerResponse - if (!signResponse.success) { - // Continue on failure as long as signature is present to unblock user - logger.error( - { - error: signResponse.error, - signer: serviceUrl, - }, - 'Signer responded with error' - ) - } - if (!signResponse.signature) { - throw new Error(`Signature is missing from signer ${serviceUrl}`) - } - responses.push({ url: serviceUrl, signMessageResponse: signResponse, status }) - const partialSig = { url: serviceUrl, signature: signResponse.signature } - logger.info({ signer: serviceUrl }, 'Add signature') - const signatureAdditionStart = Date.now() - await blsCryptoClient.addSignature(partialSig) - logger.info( - { - signer: serviceUrl, - hasSufficientSignatures: blsCryptoClient.hasSufficientSignatures(), - additionLatency: Date.now() - signatureAdditionStart, - }, - 'Added signature' - ) - // Send response immediately once we cross threshold - // BLS threshold signatures can be combined without all partial signatures - if (blsCryptoClient.hasSufficientSignatures()) { - try { - await blsCryptoClient.combinePartialBlindedSignatures(blindedQueryPhoneNumber, logger) - // Close outstanding requests - controller.abort() - } catch { - // Already logged, continue to collect signatures - } - } -} - -// Fail fast if a sufficient number of signatures cannot be collected -function handleFailedResponse( - service: SignerService, - status: number | undefined, - signerCount: number, - failedRequests: Set, - response: Response, - controller: AbortController, - errorCodes: Map -) { - if (status) { - errorCodes.set(status, (errorCodes.get(status) || 0) + 1) - } - const logger: Logger = response.locals.logger - // Tracking failed request count via signer url prevents - // double counting the same failed request by mistake - failedRequests.add(service.url) - const shouldFailFast = signerCount - failedRequests.size < config.thresholdSignature.threshold - logger.info(`Recieved failure from ${failedRequests.size}/${signerCount} signers.`) - if (shouldFailFast) { - logger.info('Not possible to reach a sufficient number of signatures. Failing fast.') - controller.abort() - } -} - -function logResponseDiscrepancies(responses: SignMsgRespWithStatus[], logger: Logger) { - // Only compare responses which have values for the quota fields - const successfulResponses = responses.filter( - (response) => - response.signMessageResponse && - response.signMessageResponse.performedQueryCount && - response.signMessageResponse.totalQuota && - response.signMessageResponse.blockNumber - ) - - if (successfulResponses.length === 0) { - return - } - // Compare the first response to the rest of the responses - const expectedQueryCount = successfulResponses[0].signMessageResponse.performedQueryCount - const expectedTotalQuota = successfulResponses[0].signMessageResponse.totalQuota - const expectedBlockNumber = successfulResponses[0].signMessageResponse.blockNumber! - let discrepancyFound = false - for (const resp of successfulResponses) { - // Performed query count should never diverge; however the totalQuota may - // diverge if the queried block number is different - if ( - resp.signMessageResponse.performedQueryCount !== expectedQueryCount || - (resp.signMessageResponse.totalQuota !== expectedTotalQuota && - resp.signMessageResponse.blockNumber === expectedBlockNumber) - ) { - const values = successfulResponses.map((response) => { - return { - signer: response.url, - performedQueryCount: response.signMessageResponse.performedQueryCount, - totalQuota: response.signMessageResponse.totalQuota, - } - }) - logger.error({ values }, WarningMessage.INCONSISTENT_SIGNER_QUOTA_MEASUREMENTS) - discrepancyFound = true - } - if ( - Math.abs(resp.signMessageResponse.blockNumber! - expectedBlockNumber) > - MAX_BLOCK_DISCREPANCY_THRESHOLD - ) { - const values = successfulResponses.map((response) => { - return { - signer: response.url, - blockNumber: response.signMessageResponse.blockNumber, - } - }) - logger.error({ values }, WarningMessage.INCONSISTENT_SIGNER_BLOCK_NUMBERS) - discrepancyFound = true - } - if (discrepancyFound) { - return - } - } -} - -function requestSignature( - service: SignerService, - request: Request, - controller: AbortController, - logger: Logger -): Promise { - return parameterizedSignatureRequest(service.url, request, controller, logger).catch((e) => { - logger.error(`Signer failed with primary url ${service.url}`, e) - if (service.fallbackUrl) { - logger.warn(`Using fallback url to call signer ${service.fallbackUrl!}`) - return parameterizedSignatureRequest(service.fallbackUrl!, request, controller, logger) - } - throw e - }) -} - -function parameterizedSignatureRequest( - baseUrl: string, - request: Request, - controller: AbortController, - logger: Logger -): Promise { - logger.debug({ signer: baseUrl }, `Requesting partial sig`) - const url = baseUrl + PARTIAL_SIGN_MESSAGE_ENDPOINT - return fetch(url, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: request.headers.authorization!, - }, - body: JSON.stringify(request.body), - signal: controller.signal, - }) -} - -function getMajorityErrorCode(errorCodes: Map, logger: Logger) { - // Ignore timeouts - const ignoredErrorCodes = [408] - const uniqueErrorCount = Array.from(errorCodes.keys()).filter( - (status) => !ignoredErrorCodes.includes(status) - ).length - if (uniqueErrorCount > 1) { - logger.error( - { errorCodes: JSON.stringify([...errorCodes]) }, - ErrorMessage.INCONSISTENT_SIGNER_RESPONSES - ) - } - - let maxErrorCode = -1 - let maxCount = -1 - errorCodes.forEach((count, errorCode) => { - // This gives priority to the lower status codes in the event of a tie - // because 400s are more helpful than 500s for user feedback - if (count > maxCount || (count === maxCount && errorCode < maxErrorCode)) { - maxCount = count - maxErrorCode = errorCode - } - }) - return maxErrorCode > 0 ? maxErrorCode : null -} - -function isValidGetSignatureInput(requestBody: GetBlindedMessageSigRequest): boolean { - return ( - hasValidAccountParam(requestBody) && - hasValidBlindedPhoneNumberParam(requestBody) && - identifierIsValidIfExists(requestBody) && - isBodyReasonablySized(requestBody) - ) -} - -function handleMissingSignatures( - majorityErrorCode: number | null, - response: Response, - logger: Logger -) { - if (majorityErrorCode === 403) { - respondWithError(response, 403, WarningMessage.EXCEEDED_QUOTA, logger) - } else { - respondWithError( - response, - majorityErrorCode || 500, - ErrorMessage.NOT_ENOUGH_PARTIAL_SIGNATURES, - logger - ) - } -} diff --git a/packages/phone-number-privacy/combiner/src/web3/contracts.ts b/packages/phone-number-privacy/combiner/src/web3/contracts.ts deleted file mode 100644 index af5c56b4258..00000000000 --- a/packages/phone-number-privacy/combiner/src/web3/contracts.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ContractKit, newKit, newKitWithApiKey } from '@celo/contractkit' -import config from '../config' - -const contractKit = config.blockchain.apiKey - ? newKitWithApiKey(config.blockchain.provider, config.blockchain.apiKey) - : newKit(config.blockchain.provider) - -export function getContractKit(): ContractKit { - return contractKit -} diff --git a/packages/phone-number-privacy/combiner/test/end-to-end/get-blinded-sig.test.ts b/packages/phone-number-privacy/combiner/test/end-to-end/get-blinded-sig.test.ts index 2f6bbadd336..69f5e6aa676 100644 --- a/packages/phone-number-privacy/combiner/test/end-to-end/get-blinded-sig.test.ts +++ b/packages/phone-number-privacy/combiner/test/end-to-end/get-blinded-sig.test.ts @@ -1,10 +1,10 @@ -import { OdisUtils } from '@celo/identity/lib/odis' +import { OdisUtils } from '@celo/identity' +import { ErrorMessages } from '@celo/identity/lib/odis/query' import { AuthenticationMethod, - ErrorMessages, + Endpoint, SignMessageRequest, -} from '@celo/identity/lib/odis/query' -import { Endpoints } from '@celo/phone-number-privacy-common' +} from '@celo/phone-number-privacy-common' import { genSessionID } from '@celo/phone-number-privacy-common/lib/utils/logger' import 'isomorphic-fetch' import { replenishQuota } from '../../../common/src/test/utils' @@ -34,7 +34,7 @@ describe('Running against a deployed service', () => { // This test is disabled because the Combiner status endpoint doesn't work xit('Service is deployed at correct version', async () => { - const response = await fetch(process.env.ODIS_COMBINER_SERVICE_URL + Endpoints.STATUS, { + const response = await fetch(process.env.ODIS_COMBINER_SERVICE_URL + Endpoint.STATUS, { method: 'GET', }) const body = await response.json() @@ -53,7 +53,7 @@ describe('Running against a deployed service', () => { } await expect( - OdisUtils.Query.queryOdis(dekAuthSigner(0), body, SERVICE_CONTEXT, SIGN_MESSAGE_ENDPOINT) + OdisUtils.Query.queryOdis(dekAuthSigner(0), body, SERVICE_CONTEXT, Endpoint.LEGACY_PNP_SIGN) ).rejects.toThrow(ErrorMessages.ODIS_INPUT_ERROR) }) @@ -66,7 +66,7 @@ describe('Running against a deployed service', () => { sessionID: genSessionID(), } await expect( - OdisUtils.Query.queryOdis(walletAuthSigner, body, SERVICE_CONTEXT, SIGN_MESSAGE_ENDPOINT) + OdisUtils.Query.queryOdis(walletAuthSigner, body, SERVICE_CONTEXT, Endpoint.LEGACY_PNP_SIGN) ).rejects.toThrow(ErrorMessages.ODIS_INPUT_ERROR) }) }) @@ -80,7 +80,7 @@ describe('Running against a deployed service', () => { version: 'ignore', } await expect( - OdisUtils.Query.queryOdis(dekAuthSigner(0), body, SERVICE_CONTEXT, SIGN_MESSAGE_ENDPOINT) + OdisUtils.Query.queryOdis(dekAuthSigner(0), body, SERVICE_CONTEXT, Endpoint.LEGACY_PNP_SIGN) ).rejects.toThrow(ErrorMessages.ODIS_AUTH_ERROR) }) }) @@ -116,7 +116,7 @@ describe('Running against a deployed service', () => { walletAuthSigner, body, SERVICE_CONTEXT, - SIGN_MESSAGE_ENDPOINT + Endpoint.LEGACY_PNP_SIGN ) await expect(result).resolves.toMatchObject({ success: true }) } diff --git a/packages/phone-number-privacy/combiner/test/end-to-end/get-contact-matches.test.ts b/packages/phone-number-privacy/combiner/test/end-to-end/get-contact-matches.test.ts deleted file mode 100644 index cba04f88a25..00000000000 --- a/packages/phone-number-privacy/combiner/test/end-to-end/get-contact-matches.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { OdisUtils } from '@celo/identity/lib/odis' -import { ErrorMessages } from '@celo/identity/lib/odis/query' -import { ensureLeading0x } from '@celo/utils/lib/address' -import 'isomorphic-fetch' -import { E2E_TEST_ACCOUNTS, E2E_TEST_PHONE_NUMBERS_RAW } from '../../src/config' -import { - ACCOUNT_ADDRESS, - CONTACT_PHONE_NUMBERS, - contractKit, - dekAuthSigner, - deks, - PHONE_HASH_IDENTIFIER, - PHONE_NUMBER, - SERVICE_CONTEXT, - walletAuthSigner, -} from './resources' - -require('dotenv').config() - -jest.setTimeout(60000) - -describe('Running against a deployed service', () => { - it('Returns input error for invalid phone hash', async () => { - await expect( - OdisUtils.Matchmaking.getContactMatches( - PHONE_NUMBER, - CONTACT_PHONE_NUMBERS, - ACCOUNT_ADDRESS, - 'invalid-phone-hash', - walletAuthSigner, - SERVICE_CONTEXT, - dekAuthSigner(0) - ) - ).rejects.toThrow(ErrorMessages.ODIS_INPUT_ERROR) - }) - - it('Returns error when querying with an unauthenticated request', async () => { - await expect( - OdisUtils.Matchmaking.getContactMatches( - PHONE_NUMBER, - CONTACT_PHONE_NUMBERS, - ACCOUNT_ADDRESS, - PHONE_HASH_IDENTIFIER, - { ...dekAuthSigner(0), rawKey: 'fake' }, - SERVICE_CONTEXT - ) - ).rejects.toThrow(ErrorMessages.ODIS_AUTH_ERROR) - }) - - it('Returns error when querying with an unverified account', async () => { - await expect( - OdisUtils.Matchmaking.getContactMatches( - PHONE_NUMBER, - CONTACT_PHONE_NUMBERS, - ACCOUNT_ADDRESS, - PHONE_HASH_IDENTIFIER, - walletAuthSigner, - SERVICE_CONTEXT, - dekAuthSigner(0) - ) - ).rejects.toThrow(ErrorMessages.ODIS_QUOTA_ERROR) - }) - - it('Returns error when querying with invalid DEK', async () => { - await expect( - OdisUtils.Matchmaking.getContactMatches( - E2E_TEST_PHONE_NUMBERS_RAW[0], - CONTACT_PHONE_NUMBERS, - E2E_TEST_ACCOUNTS[0], - PHONE_HASH_IDENTIFIER, - walletAuthSigner, - SERVICE_CONTEXT, - { ...dekAuthSigner(0), rawKey: 'fake' } - ) - ).rejects.toThrow(ErrorMessages.ODIS_QUOTA_ERROR) - }) - - describe('When requerying matches', () => { - beforeAll(async () => { - // set DEK - const accounts = await contractKit.contracts.getAccounts() - await accounts - .setAccountDataEncryptionKey(ensureLeading0x(deks[0].publicKey)) - .sendAndWaitForReceipt() - }) - - it('Returns success when requerying with same phone number and valid DEK', async () => { - await expect( - OdisUtils.Matchmaking.getContactMatches( - E2E_TEST_PHONE_NUMBERS_RAW[0], - CONTACT_PHONE_NUMBERS, - E2E_TEST_ACCOUNTS[0], - PHONE_HASH_IDENTIFIER, - dekAuthSigner(0), - SERVICE_CONTEXT - ) - ).resolves.toBeInstanceOf(Array) - }) - - it('Returns error when requerying with same phone number and no DEK', async () => { - await expect( - OdisUtils.Matchmaking.getContactMatches( - E2E_TEST_PHONE_NUMBERS_RAW[0], - CONTACT_PHONE_NUMBERS, - E2E_TEST_ACCOUNTS[0], - PHONE_HASH_IDENTIFIER, - walletAuthSigner, - SERVICE_CONTEXT - ) - ).rejects.toThrow(ErrorMessages.ODIS_QUOTA_ERROR) - }) - - it('Returns error when requerying with different phone number and valid DEK', async () => { - await expect( - OdisUtils.Matchmaking.getContactMatches( - E2E_TEST_PHONE_NUMBERS_RAW[1], - CONTACT_PHONE_NUMBERS, - E2E_TEST_ACCOUNTS[0], - PHONE_HASH_IDENTIFIER, - dekAuthSigner(0), - SERVICE_CONTEXT - ) - ).rejects.toThrow(ErrorMessages.ODIS_QUOTA_ERROR) - }) - - it('Returns success when requerying with same phone number and valid DEK after key rotation', async () => { - // Key rotation - const accounts = await contractKit.contracts.getAccounts() - await accounts - .setAccountDataEncryptionKey(ensureLeading0x(deks[1].publicKey)) - .sendAndWaitForReceipt() - - await expect( - OdisUtils.Matchmaking.getContactMatches( - E2E_TEST_PHONE_NUMBERS_RAW[0], - CONTACT_PHONE_NUMBERS, - E2E_TEST_ACCOUNTS[0], - PHONE_HASH_IDENTIFIER, - dekAuthSigner(1), - SERVICE_CONTEXT - ) - ).resolves.toBeInstanceOf(Array) - }) - }) -}) diff --git a/packages/phone-number-privacy/combiner/test/end-to-end/resources.ts b/packages/phone-number-privacy/combiner/test/end-to-end/resources.ts index 9b22dd148fc..18555e0081e 100644 --- a/packages/phone-number-privacy/combiner/test/end-to-end/resources.ts +++ b/packages/phone-number-privacy/combiner/test/end-to-end/resources.ts @@ -1,10 +1,6 @@ import { newKit } from '@celo/contractkit' -import { - AuthenticationMethod, - EncryptionKeySigner, - ServiceContext, - WalletKeySigner, -} from '@celo/identity/lib/odis/query' +import { EncryptionKeySigner, ServiceContext, WalletKeySigner } from '@celo/identity/lib/odis/query' +import { AuthenticationMethod } from '@celo/phone-number-privacy-common' import { PhoneNumberUtils } from '@celo/phone-utils' import { ensureLeading0x, diff --git a/packages/phone-number-privacy/combiner/test/index.test.ts b/packages/phone-number-privacy/combiner/test/index.test.ts deleted file mode 100644 index d8465209e85..00000000000 --- a/packages/phone-number-privacy/combiner/test/index.test.ts +++ /dev/null @@ -1,567 +0,0 @@ -import { signWithDEK } from '@celo/identity/lib/odis/query' -import { getDataEncryptionKey, isVerified } from '@celo/phone-number-privacy-common' -import { Request, Response } from 'firebase-functions' -import { BLSCryptographyClient } from '../src/bls/bls-cryptography-client' -import config, { E2E_TEST_ACCOUNTS, E2E_TEST_PHONE_NUMBERS, VERSION } from '../src/config' -import { getTransaction } from '../src/database/database' -import { - getAccountSignedUserPhoneNumberRecord, - getDekSignerRecord, - getDidMatchmaking, - setDidMatchmaking, -} from '../src/database/wrappers/account' -import { getNumberPairContacts, setNumberPairContacts } from '../src/database/wrappers/number-pairs' -import { getBlindedMessageSig, getContactMatches } from '../src/index' -import { BLINDED_PHONE_NUMBER, dekAuthSigner, deks } from './end-to-end/resources' -const BLS_SIGNATURE = '0Uj+qoAu7ASMVvm6hvcUGx2eO/cmNdyEgGn0mSoZH8/dujrC1++SZ1N6IP6v2I8A' - -jest.mock('@celo/phone-number-privacy-common', () => ({ - ...jest.requireActual('@celo/phone-number-privacy-common'), - authenticateUser: jest.fn().mockReturnValue(true), - isVerified: jest.fn(), - getDataEncryptionKey: jest.fn(), -})) -const mockIsVerified = isVerified as jest.Mock -const mockGetDataEncryptionKey = getDataEncryptionKey as jest.Mock - -jest.mock('../src/bls/bls-cryptography-client') -const mockComputeBlindedSignature = jest.fn() -BLSCryptographyClient.prototype.combinePartialBlindedSignatures = mockComputeBlindedSignature -mockComputeBlindedSignature.mockResolvedValue(BLS_SIGNATURE) -const mockSufficientVerifiedSigs = jest.fn() -BLSCryptographyClient.prototype.hasSufficientSignatures = mockSufficientVerifiedSigs -mockSufficientVerifiedSigs.mockReturnValue(true) - -jest.mock('../src/database/wrappers/account') -const mockGetDidMatchmaking = getDidMatchmaking as jest.Mock -const mockSetDidMatchmaking = setDidMatchmaking as jest.Mock -mockSetDidMatchmaking.mockImplementation() -const mockGetAccountSignedUserPhoneNumberRecord = getAccountSignedUserPhoneNumberRecord as jest.Mock -const mockGetDekSignerRecord = getDekSignerRecord as jest.Mock - -jest.mock('../src/database/wrappers/number-pairs') -const mockSetNumberPairContacts = setNumberPairContacts as jest.Mock -mockSetNumberPairContacts.mockImplementation() -const mockGetNumberPairContacts = getNumberPairContacts as jest.Mock -mockGetNumberPairContacts.mockResolvedValue([]) - -jest.mock('../src/database/database') -const mockGetTransaction = getTransaction as jest.Mock -mockGetTransaction.mockReturnValue({}) - -jest.mock('node-fetch') -const fetchMock: jest.Mock = require('node-fetch') -const FetchResponse: typeof Response = jest.requireActual('node-fetch').Response -const defaultResponseJson = JSON.stringify({ - success: true, - signature: 'string', -}) - -const mockHeaders = { authorization: 'fdsfdsfs' } - -const invalidResponseExpected = (done: any, code: number) => - ({ - status(status: any) { - try { - expect(status).toEqual(code) - done() - } catch (e) { - done(e) - } - return { - json() { - return {} - }, - } - }, - } as Response) - -describe(`POST /getBlindedMessageSig endpoint`, () => { - const validRequest = { - blindedQueryPhoneNumber: BLINDED_PHONE_NUMBER, - hashedPhoneNumber: '0x5f6e88c3f724b3a09d3194c0514426494955eff7127c29654e48a361a19b4b96', - account: '0x78dc5D2D739606d31509C31d654056A45185ECb6', - } - - beforeEach(() => { - fetchMock.mockClear() - fetchMock.mockImplementation(() => Promise.resolve(new FetchResponse(defaultResponseJson))) - }) - - describe('with valid input', () => { - const req = { - body: validRequest, - headers: mockHeaders, - } as Request - - const validResponseExpected = (done: any, code: number) => - ({ - json(body: any) { - expect(body.success).toEqual(true) - expect(body.combinedSignature).toEqual(BLS_SIGNATURE) - expect(body.version).toEqual(VERSION) - done() - }, - status(status: any) { - try { - expect(status).toEqual(code) - done() - } catch (e) { - done(e) - } - return { - json() { - return {} - }, - } - }, - } as Response) - - it('provides signature', (done) => { - getBlindedMessageSig(req, validResponseExpected(done, 200)) - }) - - it('provides signature from a fallback', (done) => { - let numberOfCalls = 0 - fetchMock.mockClear() - fetchMock.mockImplementation((url) => { - const primaryUrl = - JSON.parse(config.odisServices.signers)[0].url + '/getBlindedMessagePartialSig' - numberOfCalls += 1 - if (url === primaryUrl) { - return Promise.reject() - } - return Promise.resolve(new FetchResponse(defaultResponseJson)) - }) - getBlindedMessageSig( - req, - validResponseExpected(() => { - expect(numberOfCalls).toEqual(2) - done() - }, 200) - ) - }) - - it('returns 500 on bls error', (done) => { - mockSufficientVerifiedSigs.mockReturnValueOnce(false) - mockComputeBlindedSignature.mockImplementationOnce(() => { - throw Error() - }) - - getBlindedMessageSig(req, invalidResponseExpected(done, 500)) - }) - }) - - describe('with invalid input', () => { - it('invalid address returns 400', (done) => { - const req = { - body: { - ...validRequest, - account: 'd31509C31d654056A45185ECb6', - }, - headers: mockHeaders, - } as Request - - getBlindedMessageSig(req, invalidResponseExpected(done, 400)) - }) - it('invalid hashedPhoneNumber returns 400', (done) => { - const req = { - body: { - ...validRequest, - hashedPhoneNumber: '+1234567890', - }, - headers: mockHeaders, - } as Request - - getBlindedMessageSig(req, invalidResponseExpected(done, 400)) - }) - it('invalid blinded phone number returns 400', (done) => { - const req = { - body: { - ...validRequest, - blindedQueryPhoneNumber: '+1234567890', - }, - headers: mockHeaders, - } as Request - - getBlindedMessageSig(req, invalidResponseExpected(done, 400)) - }) - }) -}) - -describe(`POST /getContactMatches endpoint`, () => { - const validInput = { - userPhoneNumber: 'o+EZnvfWS3K9X1krfcuH68Ueg1OPzqSnTyFzgtpCGlY=', - contactPhoneNumbers: ['aXq4I31oe0pSQtl8nq7vTorY9ehCz0z0pN0UMePWK9Y='], - account: '0x78dc5D2D739606d31509C31d654056A45185ECb6', - hashedPhoneNumber: '0x5f6e88c3f724b3a09d3194c0514426494955eff7127c29654e48a361a19b4b96', - } - - const expectFailure = (req: Request, code: number) => { - it(`Rejects request to matchmake with ${code}`, (done) => { - getContactMatches(req, invalidResponseExpected(done, code)) - }) - } - const expectSuccess = (req: Request) => { - it('provides matches', (done) => expectMatches(req, req.body.contactPhoneNumbers, done)) - it('provides matches empty array', (done) => expectMatches(req, [], done)) - } - const expectMatches = (req: Request, numbers: string[], done: jest.DoneCallback) => { - mockGetNumberPairContacts.mockResolvedValue(numbers) - const res = { - json(body: any) { - try { - expect(body.success).toEqual(true) - expect(body.matchedContacts).toEqual( - numbers.map((number) => ({ - phoneNumber: number, - })) - ) - done() - } catch (e) { - done(e) - } - }, - status(status: any) { - try { - expect(status).toEqual(200) - done() - } catch (e) { - done(e) - } - return { - json() { - return {} - }, - } - }, - } as Response - - getContactMatches(req, res) - } - const expectSuccessWithRecord = (req: Request) => { - expectSuccess(req) - expectSignatureWasRecorded(req) - } - const expectSuccessWithoutRecord = (req: Request) => { - expectSuccess(req) - expectSignatureWasNotRecorded() - } - const expectFirstMatchmakingToSucceed = (req: Request) => { - mockGetDidMatchmaking.mockResolvedValue(false) - expectSuccess(req) - } - const expectFirstMatchmakingToSucceedWithoutRecord = (req: Request) => { - expectFirstMatchmakingToSucceed(req) - expectSignatureWasNotRecorded() - } - const expectFirstMatchmakingToSucceedWithRecord = (req: Request) => { - expectFirstMatchmakingToSucceed(req) - expectSignatureWasRecorded(req) - } - const expectSignatureWasRecorded = (req: Request) => { - it('Should have recorded dek phone number signature for the last request', () => { - expect(mockSetDidMatchmaking).toHaveBeenLastCalledWith( - req.body.account, - expect.anything(), - expect.anything() - ) - }) - } - const expectSignatureWasNotRecorded = () => { - it('Should not have recorded dek phone number signature for the last request', () => { - expect(mockSetDidMatchmaking).toHaveBeenLastCalledWith( - validInput.account, - expect.anything(), - undefined - ) - }) - } - const expectReplaysToFail = (req: Request) => { - describe('When user has already performed matchmaking', () => { - beforeAll(() => { - mockGetDidMatchmaking.mockResolvedValueOnce(true) - }) - expectFailure(req, 403) - }) - } - const expectPotentialReplaysToSucceedWithoutRecord = (req: Request) => { - it('provides matches on getDidMatchmaking error', (done) => { - mockGetDidMatchmaking.mockRejectedValueOnce(new Error()) - expectMatches(req, req.body.contactPhoneNumbers, done) - }) - expectSignatureWasNotRecorded() - } - - describe('with valid input', () => { - beforeAll(() => { - mockIsVerified.mockResolvedValue(true) - }) - - describe('w/o signedUserPhoneNumber', () => { - const req = { - body: validInput, - headers: mockHeaders, - } as Request - expectFirstMatchmakingToSucceedWithoutRecord(req) - expectReplaysToFail(req) - expectPotentialReplaysToSucceedWithoutRecord(req) - }) - - describe('w/ signedUserPhoneNumber', () => { - const signedUserPhoneNumber = signWithDEK(validInput.userPhoneNumber, dekAuthSigner(0)) - const req = { - body: { - ...validInput, - signedUserPhoneNumber, - }, - headers: mockHeaders, - } as Request - - describe('When DEK is fetched successfully', () => { - beforeAll(() => { - mockGetDataEncryptionKey.mockResolvedValue(deks[0].publicKey) - }) - - describe('When DEK signedUserPhoneNumber signature is invalid', () => { - beforeAll(() => { - req.body.signedUserPhoneNumber = 'fake' - }) - afterAll(() => { - req.body.signedUserPhoneNumber = signedUserPhoneNumber - }) - expectFailure(req, 403) - }) - - describe('When DEK signedUserPhoneNumber signature is valid', () => { - expectFirstMatchmakingToSucceedWithRecord(req) - expectPotentialReplaysToSucceedWithoutRecord(req) - - describe('With replayed requests', () => { - beforeAll(() => { - mockGetDidMatchmaking.mockResolvedValue(true) - }) - describe('When signedUserPhoneNumberRecord matches request', () => { - beforeAll(() => { - mockGetAccountSignedUserPhoneNumberRecord.mockResolvedValue( - req.body.signedUserPhoneNumber - ) - }) - expectSuccessWithRecord(req) - describe('Should bypass verification when e2e test phone number and account are provided', () => { - beforeAll(() => { - mockIsVerified.mockResolvedValue(false) - req.body.account = E2E_TEST_ACCOUNTS[0] - req.body.userPhoneNumber = E2E_TEST_PHONE_NUMBERS[0] - req.body.signedUserPhoneNumber = signWithDEK( - req.body.userPhoneNumber, - dekAuthSigner(0) - ) - mockGetAccountSignedUserPhoneNumberRecord.mockResolvedValue( - req.body.signedUserPhoneNumber - ) - }) - afterAll(() => { - mockIsVerified.mockResolvedValue(true) - req.body.account = validInput.account - req.body.userPhoneNumber = validInput.userPhoneNumber - req.body.signedUserPhoneNumber = signWithDEK( - req.body.userPhoneNumber, - dekAuthSigner(0) - ) - mockGetAccountSignedUserPhoneNumberRecord.mockResolvedValue( - req.body.signedUserPhoneNumber - ) - }) - expectSuccessWithRecord(req) - }) - }) - - describe('When signedUserPhoneNumberRecord does not match request', () => { - describe('When user has not rotated their dek', () => { - beforeAll(() => { - mockGetAccountSignedUserPhoneNumberRecord.mockResolvedValue('fake') - }) - expectFailure(req, 403) - }) - - describe('When user has rotated their dek', () => { - beforeAll(() => { - mockGetDataEncryptionKey.mockResolvedValue(deks[1].publicKey) - req.body.signedUserPhoneNumber = signWithDEK( - validInput.userPhoneNumber, - dekAuthSigner(1) - ) - mockGetAccountSignedUserPhoneNumberRecord.mockResolvedValue( - signWithDEK(validInput.userPhoneNumber, dekAuthSigner(0)) - ) - mockGetDekSignerRecord.mockResolvedValue(deks[0].publicKey) - }) - expectSuccessWithRecord(req) - }) - - describe('When we cannot find a dekSignerRecord for the user', () => { - beforeAll(() => { - mockGetAccountSignedUserPhoneNumberRecord.mockResolvedValue('fake') - mockGetDekSignerRecord.mockResolvedValue(undefined) - }) - expectFailure(req, 403) - }) - }) - - describe('When signedUserPhoneNumberRecord does not exist in db', () => { - beforeAll(() => { - mockGetAccountSignedUserPhoneNumberRecord.mockResolvedValue(undefined) - }) - expectSuccessWithRecord(req) - }) - - describe('When GetAccountSignedUserPhoneNumberRecord throws db error', () => { - beforeAll(() => { - mockGetAccountSignedUserPhoneNumberRecord.mockRejectedValue(new Error()) - }) - expectSuccessWithoutRecord(req) - }) - }) - }) - }) - - describe('When DEK is not fetched succesfully', () => { - beforeAll(() => { - mockGetDataEncryptionKey.mockRejectedValue(new Error()) - }) - - expectFirstMatchmakingToSucceedWithoutRecord(req) - expectPotentialReplaysToSucceedWithoutRecord(req) - - describe('With replayed requests', () => { - beforeAll(() => { - mockGetDidMatchmaking.mockResolvedValue(true) - }) - - describe('When signedUserPhoneNumberRecord matches request', () => { - beforeAll(() => { - req.body.signedUserPhoneNumber = signedUserPhoneNumber - mockGetAccountSignedUserPhoneNumberRecord.mockResolvedValue(signedUserPhoneNumber) - }) - expectSuccessWithoutRecord(req) - }) - - describe('When signedUserPhoneNumberRecord does not match request', () => { - describe('When user has not rotated their dek', () => { - beforeAll(() => { - mockGetAccountSignedUserPhoneNumberRecord.mockResolvedValue('fake') - }) - expectFailure(req, 403) - }) - - describe('When user has rotated their dek', () => { - beforeAll(() => { - mockGetAccountSignedUserPhoneNumberRecord.mockResolvedValue( - signWithDEK(validInput.userPhoneNumber, dekAuthSigner(1)) - ) - mockGetDekSignerRecord.mockResolvedValue(deks[1].publicKey) - }) - expectSuccessWithoutRecord(req) - }) - - describe('When we cannot find a dekSignerRecord for the user', () => { - beforeAll(() => { - mockGetDekSignerRecord.mockResolvedValue(undefined) - }) - expectFailure(req, 403) - }) - }) - - describe('When signedUserPhoneNumberRecord does not exist in db', () => { - beforeAll(() => { - mockGetAccountSignedUserPhoneNumberRecord.mockResolvedValue(undefined) - }) - expectSuccessWithoutRecord(req) - }) - - describe('When GetAccountSignedUserPhoneNumberRecord throws db error', () => { - beforeAll(() => { - mockGetAccountSignedUserPhoneNumberRecord.mockRejectedValue(new Error()) - }) - expectSuccessWithoutRecord(req) - }) - }) - }) - }) - }) - - describe('with invalid input', () => { - it('missing user number returns 400', (done) => { - const req = { - body: { - ...validInput, - userPhoneNumber: undefined, - }, - headers: mockHeaders, - } as Request - - getContactMatches(req, invalidResponseExpected(done, 400)) - }) - - it('invalid user number returns 400', (done) => { - const req = { - body: { - ...validInput, - userPhoneNumber: '+14155550123', - }, - headers: mockHeaders, - } as Request - - getContactMatches(req, invalidResponseExpected(done, 400)) - }) - - it('invalid account returns 400', (done) => { - const req = { - body: { - ...validInput, - account: 'garbage', - }, - headers: mockHeaders, - } as Request - - getContactMatches(req, invalidResponseExpected(done, 400)) - }) - - it('missing contact phone numbers returns 400', (done) => { - const req = { - body: { - ...validInput, - contactPhoneNumbers: undefined, - }, - headers: mockHeaders, - } as Request - - getContactMatches(req, invalidResponseExpected(done, 400)) - }) - - it('empty contact phone numbers returns 400', (done) => { - const req = { - body: { - ...validInput, - contactPhoneNumbers: [], - }, - headers: mockHeaders, - } as Request - - getContactMatches(req, invalidResponseExpected(done, 400)) - }) - - it('invalid contact phone numbers returns 400', (done) => { - const req = { - body: { - ...validInput, - contactPhoneNumbers: ['+14155550123'], - }, - headers: mockHeaders, - } as Request - - getContactMatches(req, invalidResponseExpected(done, 400)) - }) - }) -}) diff --git a/packages/phone-number-privacy/combiner/test/integration/domain.test.ts b/packages/phone-number-privacy/combiner/test/integration/domain.test.ts new file mode 100644 index 00000000000..fd0b334c802 --- /dev/null +++ b/packages/phone-number-privacy/combiner/test/integration/domain.test.ts @@ -0,0 +1,1120 @@ +import { + CombinerEndpoint, + DisableDomainRequest, + disableDomainRequestEIP712, + DisableDomainResponse, + domainHash, + DomainIdentifiers, + DomainQuotaStatusRequest, + domainQuotaStatusRequestEIP712, + DomainQuotaStatusResponse, + DomainRequestTypeTag, + DomainRestrictedSignatureRequest, + domainRestrictedSignatureRequestEIP712, + DomainRestrictedSignatureResponse, + ErrorMessage, + genSessionID, + KEY_VERSION_HEADER, + PoprfClient, + SequentialDelayDomain, + SequentialDelayStage, + TestUtils, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { + initDatabase as initSignerDatabase, + startSigner, + SupportedDatabase, + SupportedKeystore, +} from '@celo/phone-number-privacy-signer' +import { + DefaultKeyName, + KeyProvider, +} from '@celo/phone-number-privacy-signer/dist/common/key-management/key-provider-base' +import { SignerConfig } from '@celo/phone-number-privacy-signer/dist/config' +import { defined, noBool, noNumber, noString } from '@celo/utils/lib/sign-typed-data-utils' +import { LocalWallet } from '@celo/wallet-local' +import BigNumber from 'bignumber.js' +import { Server as HttpsServer } from 'https' +import { Knex } from 'knex' +import { Server } from 'net' +import request from 'supertest' +import { MockKeyProvider } from '../../../signer/dist/common/key-management/mock-key-provider' +import config from '../../src/config' +import { startCombiner } from '../../src/server' + +const { + DOMAINS_THRESHOLD_DEV_PK_SHARE_1_V1, + DOMAINS_THRESHOLD_DEV_PK_SHARE_1_V2, + DOMAINS_THRESHOLD_DEV_PK_SHARE_1_V3, + DOMAINS_THRESHOLD_DEV_PK_SHARE_2_V1, + DOMAINS_THRESHOLD_DEV_PK_SHARE_2_V2, + DOMAINS_THRESHOLD_DEV_PK_SHARE_2_V3, + DOMAINS_THRESHOLD_DEV_PK_SHARE_3_V1, + DOMAINS_THRESHOLD_DEV_PK_SHARE_3_V2, + DOMAINS_THRESHOLD_DEV_PK_SHARE_3_V3, +} = TestUtils.Values + +// create deep copy of config +const combinerConfig: typeof config = JSON.parse(JSON.stringify(config)) +combinerConfig.domains.enabled = true + +const signerConfig: SignerConfig = { + serviceName: 'odis-signer', + server: { + port: undefined, + sslKeyPath: undefined, + sslCertPath: undefined, + }, + quota: { + unverifiedQueryMax: 10, + additionalVerifiedQueryMax: 30, + queryPerTransaction: 2, + // Min balance is .01 cUSD + minDollarBalance: new BigNumber(1e16), + // Min balance is .01 cEUR + minEuroBalance: new BigNumber(1e16), + // Min balance is .005 CELO + minCeloBalance: new BigNumber(5e15), + // Equivalent to 0.001 cUSD/query + queryPriceInCUSD: new BigNumber(0.001), + }, + api: { + domains: { + enabled: true, + }, + phoneNumberPrivacy: { + enabled: false, + shouldFailOpen: false, + }, + legacyPhoneNumberPrivacy: { + enabled: false, + shouldFailOpen: false, + }, + }, + attestations: { + numberAttestationsRequired: 3, + }, + blockchain: { + provider: 'https://alfajores-forno.celo-testnet.org', + apiKey: undefined, + }, + db: { + type: SupportedDatabase.Sqlite, + user: '', + password: '', + database: '', + host: 'http://localhost', + port: undefined, + ssl: true, + poolMaxSize: 50, + }, + keystore: { + type: SupportedKeystore.MOCK_SECRET_MANAGER, + keys: { + phoneNumberPrivacy: { + name: 'phoneNumberPrivacy', + latest: 2, + }, + domains: { + name: 'domains', + latest: 1, + }, + }, + azure: { + clientID: '', + clientSecret: '', + tenant: '', + vaultName: '', + secretName: '', + }, + google: { + projectId: '', + secretName: '', + secretVersion: 'latest', + }, + aws: { + region: '', + secretName: '', + secretKey: '', + }, + }, + timeout: 5000, + test_quota_bypass_percentage: 0, +} + +describe('domainService', () => { + const wallet = new LocalWallet() + wallet.addAccount('0x00000000000000000000000000000000000000000000000000000000deadbeef') + const walletAddress = wallet.getAccounts()[0]! + + const domainStages = (): SequentialDelayStage[] => [ + { delay: 0, resetTimer: noBool, batchSize: defined(2), repetitions: defined(10) }, + ] + + const authenticatedDomain = (_stages?: SequentialDelayStage[]): SequentialDelayDomain => ({ + name: DomainIdentifiers.SequentialDelay, + version: '1', + stages: _stages ?? domainStages(), + address: defined(walletAddress), + salt: defined('himalayanPink'), + }) + + const signatureRequest = async ( + _domain?: SequentialDelayDomain, + _nonce?: number, + keyVersion: number = config.domains.keys.currentVersion + ): Promise<[DomainRestrictedSignatureRequest, PoprfClient]> => { + const domain = _domain ?? authenticatedDomain() + const poprfClient = new PoprfClient( + Buffer.from(TestUtils.Values.DOMAINS_THRESHOLD_DEV_PUBKEYS[keyVersion - 1], 'base64'), + domainHash(domain), + Buffer.from('test message', 'utf8') + ) + + const req: DomainRestrictedSignatureRequest = { + type: DomainRequestTypeTag.SIGN, + domain: domain, + options: { + signature: noString, + nonce: defined(_nonce ?? 0), + }, + blindedMessage: poprfClient.blindedMessage.toString('base64'), + sessionID: defined(genSessionID()), + } + req.options.signature = defined( + await wallet.signTypedData(walletAddress, domainRestrictedSignatureRequestEIP712(req)) + ) + return [req, poprfClient] + } + + const quotaRequest = async (): Promise> => { + const req: DomainQuotaStatusRequest = { + type: DomainRequestTypeTag.QUOTA, + domain: authenticatedDomain(), + options: { + signature: noString, + nonce: noNumber, + }, + sessionID: defined(genSessionID()), + } + req.options.signature = defined( + await wallet.signTypedData(walletAddress, domainQuotaStatusRequestEIP712(req)) + ) + return req + } + + // Build and sign an example disable domain request. + const disableRequest = async ( + _domain?: SequentialDelayDomain + ): Promise> => { + const req: DisableDomainRequest = { + type: DomainRequestTypeTag.DISABLE, + domain: _domain ?? authenticatedDomain(), + options: { + signature: noString, + nonce: noNumber, + }, + sessionID: defined(genSessionID()), + } + req.options.signature = defined( + await wallet.signTypedData(walletAddress, disableDomainRequestEIP712(req)) + ) + return req + } + + let keyProvider1: KeyProvider + let keyProvider2: KeyProvider + let keyProvider3: KeyProvider + let signerDB1: Knex + let signerDB2: Knex + let signerDB3: Knex + let signer1: Server | HttpsServer + let signer2: Server | HttpsServer + let signer3: Server | HttpsServer + let app: any + + const signerMigrationsPath = '../signer/src/common/database/migrations' + + const expectedEvals: string[] = [ + '3QLFPV6VvnhhnZ7mOu0xm7BUUJIUVY6vEHvZONOtZ/c=', + 'BBG0fAZJ6VNQwjge+3vOCF3uBo5KCs2+er/f/2QcV58=', + '1/otd1fW1nhUoU3ubjFDS8/RX0OClvHDsmGdnz6fZVE=', + ] + const expectedEval = expectedEvals[config.domains.keys.currentVersion - 1] + + beforeAll(async () => { + keyProvider1 = new MockKeyProvider( + new Map([ + [`${DefaultKeyName.DOMAINS}-1`, DOMAINS_THRESHOLD_DEV_PK_SHARE_1_V1], + [`${DefaultKeyName.DOMAINS}-2`, DOMAINS_THRESHOLD_DEV_PK_SHARE_1_V2], + [`${DefaultKeyName.DOMAINS}-3`, DOMAINS_THRESHOLD_DEV_PK_SHARE_1_V3], + ]) + ) + keyProvider2 = new MockKeyProvider( + new Map([ + [`${DefaultKeyName.DOMAINS}-1`, DOMAINS_THRESHOLD_DEV_PK_SHARE_2_V1], + [`${DefaultKeyName.DOMAINS}-2`, DOMAINS_THRESHOLD_DEV_PK_SHARE_2_V2], + [`${DefaultKeyName.DOMAINS}-3`, DOMAINS_THRESHOLD_DEV_PK_SHARE_2_V3], + ]) + ) + keyProvider3 = new MockKeyProvider( + new Map([ + [`${DefaultKeyName.DOMAINS}-1`, DOMAINS_THRESHOLD_DEV_PK_SHARE_3_V1], + [`${DefaultKeyName.DOMAINS}-2`, DOMAINS_THRESHOLD_DEV_PK_SHARE_3_V2], + [`${DefaultKeyName.DOMAINS}-3`, DOMAINS_THRESHOLD_DEV_PK_SHARE_3_V3], + ]) + ) + + app = startCombiner(combinerConfig) + }) + + beforeEach(async () => { + signerDB1 = await initSignerDatabase(signerConfig, signerMigrationsPath) + signerDB2 = await initSignerDatabase(signerConfig, signerMigrationsPath) + signerDB3 = await initSignerDatabase(signerConfig, signerMigrationsPath) + }) + + afterEach(async () => { + await signerDB1?.destroy() + await signerDB2?.destroy() + await signerDB3?.destroy() + signer1?.close() + signer2?.close() + signer3?.close() + }) + + describe('when signers are operating correctly', () => { + beforeEach(async () => { + signer1 = startSigner(signerConfig, signerDB1, keyProvider1).listen(3001) + signer2 = startSigner(signerConfig, signerDB2, keyProvider2).listen(3002) + signer3 = startSigner(signerConfig, signerDB3, keyProvider3).listen(3003) + }) + + describe(`${CombinerEndpoint.DISABLE_DOMAIN}`, () => { + it('Should respond with 200 on valid request', async () => { + const res = await request(app) + .post(CombinerEndpoint.DISABLE_DOMAIN) + .send(await disableRequest()) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + status: { disabled: true, counter: 0, timer: 0, now: res.body.status.now }, + }) + }) + + it('Should respond with 200 on repeated valid requests', async () => { + const req = await disableRequest() + const res1 = await request(app).post(CombinerEndpoint.DISABLE_DOMAIN).send(req) + expect(res1.status).toBe(200) + const expectedResponse: DisableDomainResponse = { + success: true, + version: res1.body.version, + status: { disabled: true, counter: 0, timer: 0, now: res1.body.status.now }, + } + expect(res1.body).toStrictEqual(expectedResponse) + const res2 = await request(app).post(CombinerEndpoint.DISABLE_DOMAIN).send(req) + expect(res2.status).toBe(200) + expectedResponse.status.now = res2.body.status.now + expect(res2.body).toStrictEqual(expectedResponse) + }) + + it('Should respond with 200 on extra request fields', async () => { + const req = await disableRequest() + // @ts-ignore Intentionally adding an extra field to the request type + req.options.extraField = noString + + const res = await request(app).post(CombinerEndpoint.DISABLE_DOMAIN).send(req) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + status: { disabled: true, counter: 0, timer: 0, now: res.body.status.now }, + }) + }) + + it('Should respond with 400 on missing request fields', async () => { + const badRequest = await disableRequest() + // @ts-ignore Intentionally deleting required field + delete badRequest.domain.version + + const res = await request(app).post(CombinerEndpoint.DISABLE_DOMAIN).send(badRequest) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on unknown domain', async () => { + // Create a requests with an invalid domain identifier. + const unknownRequest = await disableRequest() + // @ts-ignore UnknownDomain is (intentionally) not a valid domain identifier. + unknownRequest.domain.name = 'UnknownDomain' + + const res = await request(app).post(CombinerEndpoint.DISABLE_DOMAIN).send(unknownRequest) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on bad encoding', async () => { + const badRequest1 = await disableRequest() + // @ts-ignore Intentionally not JSON + badRequest1.domain = 'Freddy' + + const res1 = await request(app).post(CombinerEndpoint.DISABLE_DOMAIN).send(badRequest1) + + expect(res1.status).toBe(400) + expect(res1.body).toStrictEqual({ + success: false, + version: res1.body.version, + error: WarningMessage.INVALID_INPUT, + }) + + const badRequest2 = '' + + const res2 = await request(app).post(CombinerEndpoint.DISABLE_DOMAIN).send(badRequest2) + + expect(res2.status).toBe(400) + expect(res2.body).toStrictEqual({ + success: false, + version: res2.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 401 on failed auth', async () => { + // Create a manipulated request, which will have a bad signature. + const badRequest = await disableRequest() + badRequest.domain.salt = defined('badSalt') + + const res = await request(app).post(CombinerEndpoint.DISABLE_DOMAIN).send(badRequest) + + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + + it('Should respond with 503 on disabled api', async () => { + const configWithApiDisabled: typeof combinerConfig = JSON.parse( + JSON.stringify(combinerConfig) + ) + configWithApiDisabled.domains.enabled = false + const appWithApiDisabled = startCombiner(configWithApiDisabled) + + const req = await disableRequest() + + const res = await request(appWithApiDisabled) + .post(CombinerEndpoint.DISABLE_DOMAIN) + .send(req) + + expect(res.status).toBe(503) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.API_UNAVAILABLE, + }) + }) + }) + + describe(`${CombinerEndpoint.DOMAIN_QUOTA_STATUS}`, () => { + it('Should respond with 200 on valid request', async () => { + const res = await request(app) + .post(CombinerEndpoint.DOMAIN_QUOTA_STATUS) + .send(await quotaRequest()) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + status: { disabled: false, counter: 0, timer: 0, now: res.body.status.now }, + }) + }) + + it('Should respond with 200 on repeated valid requests', async () => { + const req = await quotaRequest() + const res1 = await request(app).post(CombinerEndpoint.DOMAIN_QUOTA_STATUS).send(req) + const expectedResponse: DomainQuotaStatusResponse = { + success: true, + version: res1.body.version, + status: { disabled: false, counter: 0, timer: 0, now: res1.body.status.now }, + } + + expect(res1.status).toBe(200) + expect(res1.body).toStrictEqual(expectedResponse) + + const res2 = await request(app).post(CombinerEndpoint.DOMAIN_QUOTA_STATUS).send(req) + expect(res2.status).toBe(200) + // Prevent flakiness due to slight timing inconsistencies + expectedResponse.status.now = res2.body.status.now + expect(res2.body).toStrictEqual(expectedResponse) + }) + + it('Should respond with 200 on extra request fields', async () => { + const req = await quotaRequest() + // @ts-ignore Intentionally adding an extra field to the request type + req.options.extraField = noString + + const res = await request(app).post(CombinerEndpoint.DOMAIN_QUOTA_STATUS).send(req) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + status: { disabled: false, counter: 0, timer: 0, now: res.body.status.now }, + }) + }) + + it('Should respond with 400 on missing request fields', async () => { + const badRequest = await quotaRequest() + // @ts-ignore Intentionally deleting required field + delete badRequest.domain.version + + const res = await request(app).post(CombinerEndpoint.DOMAIN_QUOTA_STATUS).send(badRequest) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on unknown domain', async () => { + // Create a requests with an invalid domain identifier. + const unknownRequest = await quotaRequest() + // @ts-ignore UnknownDomain is (intentionally) not a valid domain identifier. + unknownRequest.domain.name = 'UnknownDomain' + + const res = await request(app) + .post(CombinerEndpoint.DOMAIN_QUOTA_STATUS) + .send(unknownRequest) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on bad encoding', async () => { + const badRequest1 = await quotaRequest() + // @ts-ignore Intentionally not JSON + badRequest1.domain = 'Freddy' + + const res1 = await request(app).post(CombinerEndpoint.DOMAIN_QUOTA_STATUS).send(badRequest1) + + expect(res1.status).toBe(400) + expect(res1.body).toStrictEqual({ + success: false, + version: res1.body.version, + error: WarningMessage.INVALID_INPUT, + }) + + const badRequest2 = '' + + const res2 = await request(app).post(CombinerEndpoint.DOMAIN_QUOTA_STATUS).send(badRequest2) + + expect(res2.status).toBe(400) + expect(res2.body).toStrictEqual({ + success: false, + version: res2.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 401 on failed auth', async () => { + // Create a manipulated request, which will have a bad signature. + const badRequest = await quotaRequest() + badRequest.domain.salt = defined('badSalt') + + const res = await request(app).post(CombinerEndpoint.DOMAIN_QUOTA_STATUS).send(badRequest) + + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + + it('Should respond with 503 on disabled api', async () => { + const configWithApiDisabled: typeof combinerConfig = JSON.parse( + JSON.stringify(combinerConfig) + ) + configWithApiDisabled.domains.enabled = false + const appWithApiDisabled = startCombiner(configWithApiDisabled) + + const req = await quotaRequest() + + const res = await request(appWithApiDisabled) + .post(CombinerEndpoint.DOMAIN_QUOTA_STATUS) + .send(req) + + expect(res.status).toBe(503) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.API_UNAVAILABLE, + }) + }) + }) + + describe(`${CombinerEndpoint.DOMAIN_SIGN}`, () => { + it('Should respond with 200 on valid request', async () => { + const [req, poprfClient] = await signatureRequest() + const res = await request(app).post(CombinerEndpoint.DOMAIN_SIGN).send(req) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: res.body.signature, + status: { + disabled: false, + counter: 1, + timer: res.body.status.timer, + now: res.body.status.now, + }, + }) + const evaluation = poprfClient.unblindResponse(Buffer.from(res.body.signature, 'base64')) + expect(evaluation.toString('base64')).toEqual(expectedEval) + }) + + for (let i = 1; i <= 3; i++) { + it(`Should respond with 200 on valid request with key version header ${i}`, async () => { + const [req, poprfClient] = await signatureRequest(undefined, undefined, i) + + const res = await request(app) + .post(CombinerEndpoint.DOMAIN_SIGN) + .set(KEY_VERSION_HEADER, i.toString()) + .send(req) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: res.body.signature, + status: { + disabled: false, + counter: 1, + timer: res.body.status.timer, + now: res.body.status.now, + }, + }) + const evaluation = poprfClient.unblindResponse(Buffer.from(res.body.signature, 'base64')) + expect(evaluation.toString('base64')).toEqual(expectedEvals[i - 1]) + }) + } + + it('Should respond with 200 if nonce > domainState', async () => { + const [req, poprfClient] = await signatureRequest(undefined, 2) + + const res = await request(app).post(CombinerEndpoint.DOMAIN_SIGN).send(req) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: res.body.signature, + status: { + disabled: false, + counter: 1, // counter gets incremented, not set to nonce value + timer: res.body.status.timer, + now: res.body.status.now, + }, + }) + const evaluation = poprfClient.unblindResponse(Buffer.from(res.body.signature, 'base64')) + expect(evaluation.toString('base64')).toEqual(expectedEval) + }) + + it('Should respond with 200 on repeated valid requests', async () => { + const [req1, poprfClient] = await signatureRequest() + + const res1 = await request(app).post(CombinerEndpoint.DOMAIN_SIGN).send(req1) + expect(res1.status).toBe(200) + expect(res1.body).toStrictEqual({ + success: true, + version: res1.body.version, + signature: res1.body.signature, + status: { + disabled: false, + counter: 1, + timer: res1.body.status.timer, + now: res1.body.status.now, + }, + }) + const eval1 = poprfClient.unblindResponse(Buffer.from(res1.body.signature, 'base64')) + expect(eval1.toString('base64')).toEqual(expectedEval) + + // submit identical request with nonce set to 1 + req1.options.nonce = defined(1) + req1.options.signature = noString + req1.options.signature = defined( + await wallet.signTypedData(walletAddress, domainRestrictedSignatureRequestEIP712(req1)) + ) + const res2 = await request(app).post(CombinerEndpoint.DOMAIN_SIGN).send(req1) + + expect(res2.status).toBe(200) + expect(res2.body).toStrictEqual({ + success: true, + version: res2.body.version, + signature: res2.body.signature, + status: { + disabled: false, + counter: 2, + timer: res2.body.status.timer, + now: res2.body.status.now, + }, + }) + const eval2 = poprfClient.unblindResponse(Buffer.from(res1.body.signature, 'base64')) + expect(eval2).toEqual(eval1) + }) + + it('Should respond with 200 on extra request fields', async () => { + const [req, poprfClient] = await signatureRequest() + // @ts-ignore Intentionally adding an extra field to the request type + req.options.extraField = noString + + const res = await request(app).post(CombinerEndpoint.DOMAIN_SIGN).send(req) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: res.body.signature, + status: { + disabled: false, + counter: 1, + timer: res.body.status.timer, + now: res.body.status.now, + }, + }) + const evaluation = poprfClient.unblindResponse(Buffer.from(res.body.signature, 'base64')) + expect(evaluation.toString('base64')).toEqual(expectedEval) + }) + + it('Should respond with 400 on missing request fields', async () => { + const [badRequest, _] = await signatureRequest() + // @ts-ignore Intentionally deleting required field + delete badRequest.domain.version + + const res = await request(app).post(CombinerEndpoint.DOMAIN_SIGN).send(badRequest) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on unknown domain', async () => { + // Create a requests with an invalid domain identifier. + const [unknownRequest, _] = await signatureRequest() + // @ts-ignore UnknownDomain is (intentionally) not a valid domain identifier. + unknownRequest.domain.name = 'UnknownDomain' + + const res = await request(app).post(CombinerEndpoint.DOMAIN_SIGN).send(unknownRequest) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on bad encoding', async () => { + const [badRequest1, _] = await signatureRequest() + // @ts-ignore Intentionally not JSON + badRequest1.domain = 'Freddy' + + const res1 = await request(app).post(CombinerEndpoint.DOMAIN_SIGN).send(badRequest1) + + expect(res1.status).toBe(400) + expect(res1.body).toStrictEqual({ + success: false, + version: res1.body.version, + error: WarningMessage.INVALID_INPUT, + }) + + const badRequest2 = '' + + const res2 = await request(app).post(CombinerEndpoint.DOMAIN_SIGN).send(badRequest2) + + expect(res2.status).toBe(400) + expect(res2.body).toStrictEqual({ + success: false, + version: res2.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on invalid key version', async () => { + const [badRequest, _] = await signatureRequest() + + const res = await request(app) + .post(CombinerEndpoint.DOMAIN_SIGN) + .set(KEY_VERSION_HEADER, 'a') + .send(badRequest) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_KEY_VERSION_REQUEST, + }) + }) + + it('Should respond with 400 on unsupported key version', async () => { + const [badRequest, _] = await signatureRequest() + + const res = await request(app) + .post(CombinerEndpoint.DOMAIN_SIGN) + .set(KEY_VERSION_HEADER, '4') + .send(badRequest) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_KEY_VERSION_REQUEST, + }) + }) + + it('Should respond with 401 on failed auth', async () => { + // Create a manipulated request, which will have a bad signature. + const [badRequest, _] = await signatureRequest() + badRequest.domain.salt = defined('badSalt') + + const res = await request(app).post(CombinerEndpoint.DOMAIN_SIGN).send(badRequest) + + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + + it('Should respond with 401 on invalid nonce', async () => { + // Request must be sent first since nonce check is >= 0 + const [req1, _] = await signatureRequest() + const res1 = await request(app).post(CombinerEndpoint.DOMAIN_SIGN).send(req1) + + expect(res1.status).toBe(200) + expect(res1.body).toStrictEqual({ + success: true, + version: res1.body.version, + signature: res1.body.signature, + status: { + disabled: false, + counter: 1, + timer: res1.body.status.timer, + now: res1.body.status.now, + }, + }) + const res2 = await request(app).post(CombinerEndpoint.DOMAIN_SIGN).send(req1) + expect(res2.status).toBe(401) + expect(res2.body).toStrictEqual({ + success: false, + version: res2.body.version, + error: WarningMessage.INVALID_NONCE, + }) + }) + + it('Should respond with 429 on out of quota', async () => { + const noQuotaDomain = authenticatedDomain([ + { delay: 0, resetTimer: noBool, batchSize: defined(0), repetitions: defined(0) }, + ]) + const [badRequest, _] = await signatureRequest(noQuotaDomain) + + const res = await request(app).post(CombinerEndpoint.DOMAIN_SIGN).send(badRequest) + + expect(res.status).toBe(429) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.EXCEEDED_QUOTA, + }) + }) + + it('Should respond with 429 on request too early', async () => { + // This domain won't accept requests until ~10 seconds after test execution + const noQuotaDomain = authenticatedDomain([ + { + delay: Math.floor(Date.now() / 1000) + 10, + resetTimer: noBool, + batchSize: defined(2), + repetitions: defined(1), + }, + ]) + const [badRequest, _] = await signatureRequest(noQuotaDomain) + + const res = await request(app).post(CombinerEndpoint.DOMAIN_SIGN).send(badRequest) + + expect(res.status).toBe(429) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.EXCEEDED_QUOTA, + }) + }) + + it('Should respond with 429 when requesting a signature from a disabled domain', async () => { + const testDomain = authenticatedDomain() + const resDisable = await request(app) + .post(CombinerEndpoint.DISABLE_DOMAIN) + .send(await disableRequest(testDomain)) + expect(resDisable.status).toBe(200) + expect(resDisable.body).toStrictEqual({ + success: true, + version: resDisable.body.version, + status: { disabled: true, counter: 0, timer: 0, now: resDisable.body.status.now }, + }) + }) + + it('Should respond with 503 on disabled api', async () => { + const configWithApiDisabled: typeof combinerConfig = JSON.parse( + JSON.stringify(combinerConfig) + ) + configWithApiDisabled.domains.enabled = false + const appWithApiDisabled = startCombiner(configWithApiDisabled) + + const [req, _] = await signatureRequest() + + const res = await request(appWithApiDisabled).post(CombinerEndpoint.DOMAIN_SIGN).send(req) + + expect(res.status).toBe(503) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.API_UNAVAILABLE, + }) + }) + }) + }) + + describe('when signers are not operating correctly', () => { + // In this case (1/3 signers are correct), response unblinding is guaranteed to fail + // Testing 2/3 signers is flaky since the combiner sometimes combines two + // correct signatures and returns, and sometimes combines one wrong/one correct + // since it cannot verify the sigs server-side. + describe('when 1/3 signers return correct signatures', () => { + beforeEach(async () => { + // Signer 1 & 2's v1 keys are misconfigured to point to the v3 share + const badKeyProvider1 = new MockKeyProvider( + new Map([[`${DefaultKeyName.DOMAINS}-1`, DOMAINS_THRESHOLD_DEV_PK_SHARE_1_V3]]) + ) + const badKeyProvider2 = new MockKeyProvider( + new Map([[`${DefaultKeyName.DOMAINS}-1`, DOMAINS_THRESHOLD_DEV_PK_SHARE_2_V3]]) + ) + signer1 = startSigner(signerConfig, signerDB1, badKeyProvider1).listen(3001) + signer2 = startSigner(signerConfig, signerDB2, badKeyProvider2).listen(3002) + signer3 = startSigner(signerConfig, signerDB3, keyProvider3).listen(3003) + }) + + describe(`${CombinerEndpoint.DOMAIN_SIGN}`, () => { + it('Should respond with 200 on valid request', async () => { + // Ensure requested keyVersion is one that signer1 does not have + const [req, poprfClient] = await signatureRequest(undefined, undefined, 1) + const res = await request(app).post(CombinerEndpoint.DOMAIN_SIGN).send(req) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: res.body.signature, + status: { + disabled: false, + counter: 1, + timer: res.body.status.timer, + now: res.body.status.now, + }, + }) + expect(() => + poprfClient.unblindResponse(Buffer.from(res.body.signature, 'base64')) + ).toThrow(/verification failed/) + }) + }) + }) + + describe('when 2/3 of signers are disabled', () => { + beforeEach(async () => { + const configWithApiDisabled: SignerConfig = JSON.parse(JSON.stringify(signerConfig)) + configWithApiDisabled.api.domains.enabled = false + signer1 = startSigner(signerConfig, signerDB1, keyProvider1).listen(3001) + signer2 = startSigner(configWithApiDisabled, signerDB2, keyProvider2).listen(3002) + signer3 = startSigner(configWithApiDisabled, signerDB3, keyProvider3).listen(3003) + }) + + describe(`${CombinerEndpoint.DISABLE_DOMAIN}`, () => { + it('Should fail to reach threshold of signers on valid request', async () => { + const res = await request(app) + .post(CombinerEndpoint.DISABLE_DOMAIN) + .send(await disableRequest()) + expect(res.status).toBe(503) // majority error code in this case + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: ErrorMessage.THRESHOLD_DISABLE_DOMAIN_FAILURE, + }) + }) + }) + + describe(`${CombinerEndpoint.DOMAIN_QUOTA_STATUS}`, () => { + it('Should fail to reach threshold of signers on valid request', async () => { + const res = await request(app) + .post(CombinerEndpoint.DOMAIN_QUOTA_STATUS) + .send(await quotaRequest()) + expect(res.status).toBe(503) // majority error code in this case + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: ErrorMessage.THRESHOLD_DOMAIN_QUOTA_STATUS_FAILURE, + }) + }) + }) + + describe(`${CombinerEndpoint.DOMAIN_SIGN}`, () => { + it('Should fail to reach threshold of signers on valid request', async () => { + const [req, _] = await signatureRequest() + const res = await request(app).post(CombinerEndpoint.DOMAIN_SIGN).send(req) + expect(res.status).toBe(503) // majority error code in this case + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: ErrorMessage.NOT_ENOUGH_PARTIAL_SIGNATURES, + }) + }) + }) + }) + + describe('when 1/3 of signers are disabled', () => { + beforeEach(async () => { + const configWithApiDisabled: SignerConfig = JSON.parse(JSON.stringify(signerConfig)) + configWithApiDisabled.api.domains.enabled = false + signer1 = startSigner(signerConfig, signerDB1, keyProvider1).listen(3001) + signer2 = startSigner(signerConfig, signerDB2, keyProvider2).listen(3002) + signer3 = startSigner(configWithApiDisabled, signerDB3, keyProvider3).listen(3003) + }) + + describe(`${CombinerEndpoint.DISABLE_DOMAIN}`, () => { + it('Should respond with 200 on valid request', async () => { + const res = await request(app) + .post(CombinerEndpoint.DISABLE_DOMAIN) + .send(await disableRequest()) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + status: { disabled: true, counter: 0, timer: 0, now: res.body.status.now }, + }) + }) + }) + + describe(`${CombinerEndpoint.DOMAIN_QUOTA_STATUS}`, () => { + it('Should respond with 200 on valid request', async () => { + const res = await request(app) + .post(CombinerEndpoint.DOMAIN_QUOTA_STATUS) + .send(await quotaRequest()) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + status: { disabled: false, counter: 0, timer: 0, now: res.body.status.now }, + }) + }) + }) + + describe(`${CombinerEndpoint.DOMAIN_SIGN}`, () => { + it('Should respond with 200 on valid request', async () => { + const [req, poprfClient] = await signatureRequest() + const res = await request(app).post(CombinerEndpoint.DOMAIN_SIGN).send(req) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: res.body.signature, + status: { + disabled: false, + counter: 1, + timer: res.body.status.timer, + now: res.body.status.now, + }, + }) + const evaluation = poprfClient.unblindResponse(Buffer.from(res.body.signature, 'base64')) + expect(evaluation.toString('base64')).toEqual(expectedEval) + }) + }) + }) + + describe('when signers timeout', () => { + beforeEach(async () => { + const testTimeoutMS = 0 + + const configWithShortTimeout: SignerConfig = JSON.parse(JSON.stringify(signerConfig)) + configWithShortTimeout.timeout = testTimeoutMS + // Test this with all signers timing out to decrease possibility of race conditions + signer1 = startSigner(configWithShortTimeout, signerDB1, keyProvider1).listen(3001) + signer2 = startSigner(configWithShortTimeout, signerDB2, keyProvider2).listen(3002) + signer3 = startSigner(configWithShortTimeout, signerDB3, keyProvider3).listen(3003) + }) + + describe(`${CombinerEndpoint.DISABLE_DOMAIN}`, () => { + it('Should fail to reach threshold of signers on valid request', async () => { + const res = await request(app) + .post(CombinerEndpoint.DISABLE_DOMAIN) + .send(await disableRequest()) + expect(res.status).toBe(500) // majority error code in this case + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: ErrorMessage.THRESHOLD_DISABLE_DOMAIN_FAILURE, + }) + }) + }) + + describe(`${CombinerEndpoint.DOMAIN_QUOTA_STATUS}`, () => { + it('Should fail to reach threshold of signers on valid request', async () => { + const res = await request(app) + .post(CombinerEndpoint.DOMAIN_QUOTA_STATUS) + .send(await quotaRequest()) + expect(res.status).toBe(500) // majority error code in this case + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: ErrorMessage.THRESHOLD_DOMAIN_QUOTA_STATUS_FAILURE, + }) + }) + }) + + describe(`${CombinerEndpoint.DOMAIN_SIGN}`, () => { + it('Should fail to reach threshold of signers on valid request', async () => { + const [req, _] = await signatureRequest() + const res = await request(app).post(CombinerEndpoint.DOMAIN_SIGN).send(req) + expect(res.status).toBe(500) // majority error code in this case + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: ErrorMessage.NOT_ENOUGH_PARTIAL_SIGNATURES, + }) + }) + }) + }) + }) +}) diff --git a/packages/phone-number-privacy/combiner/test/integration/legacypnp.test.ts b/packages/phone-number-privacy/combiner/test/integration/legacypnp.test.ts new file mode 100644 index 00000000000..7a8a855e604 --- /dev/null +++ b/packages/phone-number-privacy/combiner/test/integration/legacypnp.test.ts @@ -0,0 +1,806 @@ +import { AttestationsStatus } from '@celo/base' +import { newKit } from '@celo/contractkit' +import { + AuthenticationMethod, + CombinerEndpoint, + ErrorMessage, + genSessionID, + KEY_VERSION_HEADER, + LegacySignMessageRequest, + SignMessageResponseFailure, + SignMessageResponseSuccess, + TestUtils, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { IDENTIFIER } from '@celo/phone-number-privacy-common/lib/test/values' +import { + initDatabase as initSignerDatabase, + startSigner, + SupportedDatabase, + SupportedKeystore, +} from '@celo/phone-number-privacy-signer' +import { + DefaultKeyName, + KeyProvider, +} from '@celo/phone-number-privacy-signer/dist/common/key-management/key-provider-base' +import { MockKeyProvider } from '@celo/phone-number-privacy-signer/dist/common/key-management/mock-key-provider' +import { getVersion, SignerConfig } from '@celo/phone-number-privacy-signer/dist/config' +import BigNumber from 'bignumber.js' +import threshold_bls from 'blind-threshold-bls' +import { Server as HttpsServer } from 'https' +import { Knex } from 'knex' +import { Server } from 'net' +import request from 'supertest' +import config from '../../src/config' +import { startCombiner } from '../../src/server' + +const { + ContractRetrieval, + createMockContractKit, + createMockAccounts, + createMockToken, + createMockWeb3, + getPnpRequestAuthorization, + createMockAttestation, +} = TestUtils.Utils +const { + PRIVATE_KEY1, + ACCOUNT_ADDRESS1, + mockAccount, + DEK_PRIVATE_KEY, + DEK_PUBLIC_KEY, + PNP_THRESHOLD_DEV_PK_SHARE_1_V1, + PNP_THRESHOLD_DEV_PK_SHARE_1_V2, + PNP_THRESHOLD_DEV_PK_SHARE_1_V3, + PNP_THRESHOLD_DEV_PK_SHARE_2_V1, + PNP_THRESHOLD_DEV_PK_SHARE_2_V2, + PNP_THRESHOLD_DEV_PK_SHARE_2_V3, + PNP_THRESHOLD_DEV_PK_SHARE_3_V1, + PNP_THRESHOLD_DEV_PK_SHARE_3_V2, + PNP_THRESHOLD_DEV_PK_SHARE_3_V3, +} = TestUtils.Values + +// create deep copy +const combinerConfig: typeof config = JSON.parse(JSON.stringify(config)) +combinerConfig.phoneNumberPrivacy.enabled = true + +const signerConfig: SignerConfig = { + serviceName: 'odis-signer', + server: { + port: undefined, + sslKeyPath: undefined, + sslCertPath: undefined, + }, + quota: { + unverifiedQueryMax: 10, + additionalVerifiedQueryMax: 30, + queryPerTransaction: 2, + // Min balance is .01 cUSD + minDollarBalance: new BigNumber(1e16), + // Min balance is .01 cEUR + minEuroBalance: new BigNumber(1e16), + // Min balance is .005 CELO + minCeloBalance: new BigNumber(5e15), + // Equivalent to 0.001 cUSD/query + queryPriceInCUSD: new BigNumber(0.001), + }, + api: { + domains: { + enabled: false, + }, + phoneNumberPrivacy: { + enabled: false, + shouldFailOpen: true, + }, + legacyPhoneNumberPrivacy: { + enabled: true, + shouldFailOpen: true, + }, + }, + attestations: { + numberAttestationsRequired: 3, + }, + blockchain: { + provider: 'https://alfajores-forno.celo-testnet.org', + apiKey: undefined, + }, + db: { + type: SupportedDatabase.Sqlite, + user: '', + password: '', + database: '', + host: 'http://localhost', + port: undefined, + ssl: true, + poolMaxSize: 50, + }, + keystore: { + type: SupportedKeystore.MOCK_SECRET_MANAGER, + keys: { + phoneNumberPrivacy: { + name: 'phoneNumberPrivacy', + latest: 2, + }, + domains: { + name: 'domains', + latest: 1, + }, + }, + azure: { + clientID: '', + clientSecret: '', + tenant: '', + vaultName: '', + secretName: '', + }, + google: { + projectId: '', + secretName: '', + secretVersion: 'latest', + }, + aws: { + region: '', + secretName: '', + secretKey: '', + }, + }, + timeout: 5000, + test_quota_bypass_percentage: 0, +} + +const testBlockNumber = 1000000 + +const mockTokenBalance = jest.fn() +const mockGetVerifiedStatus = jest.fn() +const mockGetWalletAddress = jest.fn() +const mockGetDataEncryptionKey = jest.fn() + +const mockContractKit = createMockContractKit( + { + // getWalletAddress stays constant across all old query-quota.test.ts unit tests + [ContractRetrieval.getAccounts]: createMockAccounts( + mockGetWalletAddress, + mockGetDataEncryptionKey + ), + [ContractRetrieval.getStableToken]: createMockToken(mockTokenBalance), + [ContractRetrieval.getGoldToken]: createMockToken(mockTokenBalance), + [ContractRetrieval.getAttestations]: createMockAttestation(mockGetVerifiedStatus), + }, + createMockWeb3(5, testBlockNumber) +) + +// Mock newKit as opposed to the CK constructor +// Returns an object of type ContractKit that can be passed into the signers + combiner +jest.mock('@celo/contractkit', () => ({ + ...jest.requireActual('@celo/contractkit'), + newKit: jest.fn().mockImplementation(() => mockContractKit), +})) + +describe(`legacyPnpService: ${CombinerEndpoint.LEGACY_PNP_SIGN}`, () => { + let keyProvider1: KeyProvider + let keyProvider2: KeyProvider + let keyProvider3: KeyProvider + let signerDB1: Knex + let signerDB2: Knex + let signerDB3: Knex + let signer1: Server | HttpsServer + let signer2: Server | HttpsServer + let signer3: Server | HttpsServer + let app: any + + // Used by PNP_SIGN tests for various configurations of signers + let userSeed: Uint8Array + let blindedMsgResult: threshold_bls.BlindedMessage + + const signerMigrationsPath = '../signer/src/common/database/migrations' + const expectedVersion = getVersion() + + const message = Buffer.from('test message', 'utf8') + const expectedQuota = 410 + const expectedSignatures: string[] = [ + 'xgFMQtcgAMHJAEX/m9B4VFopYtxqPFSw0024sWzRYvQDvnmFqhXOPdnRDfa8WCEA', + 'wUuFV8yFBXGyEzKbyWjBChG6dER264nwjOsqErd/UZieVKE0oDMZcMDG+qObu4QB', + 'PJHqBGavcQG3NGFl3hiR8GymeDNumxbl1DnCJzWz+Ik5yCN2ZpAITBe24RTX0iMA', + ] + const expectedSignature = expectedSignatures[config.phoneNumberPrivacy.keys.currentVersion - 1] + + const expectedUnblindedSigs: string[] = [ + 'lOASnDJNbJBTMYfkbU4fMiK7FcNwSyqZo8iQSM95X8YK+/158be4S1A+jcQsCUYA', + 'QIT7HtHTe/d0Tq40Mf3rpHCT8qY20+8q7ZW9PXHFMWGvwSGhk7l3Pfwnx8YdXomB', + 'XW//DolLzaXYS/gk9WBHfeKy5HKrGjuF/OpCok/i6fprE4AGFH2PjE7zeKTfOQ+A', + ] + const expectedUnblindedSig = + expectedUnblindedSigs[config.phoneNumberPrivacy.keys.currentVersion - 1] + + // In current setup, the same mocked kit is used for the combiner and signers + const mockKit = newKit('dummyKit') + + const sendLegacyPnpSignRequest = async ( + req: LegacySignMessageRequest, + authorization: string, + app: any, + keyVersionHeader?: string + ) => { + let reqWithHeaders = request(app) + .post(CombinerEndpoint.LEGACY_PNP_SIGN) + .set('Authorization', authorization) + + if (keyVersionHeader) { + reqWithHeaders = reqWithHeaders.set(KEY_VERSION_HEADER, keyVersionHeader) + } + return reqWithHeaders.send(req) + } + + const getLegacySignRequest = ( + _blindedMsgResult: threshold_bls.BlindedMessage + ): LegacySignMessageRequest => { + return { + account: ACCOUNT_ADDRESS1, + blindedQueryPhoneNumber: Buffer.from(_blindedMsgResult.message).toString('base64'), + sessionID: genSessionID(), + } + } + + const prepMocks = (hasQuota: boolean) => { + const [transactionCount, isVerified, balanceToken] = hasQuota + ? [100, true, new BigNumber(200000000000000000)] + : [0, false, new BigNumber(0)] + ;[ + mockContractKit.connection.getTransactionCount, + mockGetVerifiedStatus, + mockGetVerifiedStatus, + mockTokenBalance, + mockGetDataEncryptionKey, + mockGetWalletAddress, + ].forEach((mockFn) => mockFn.mockReset()) + + mockContractKit.connection.getTransactionCount.mockReturnValue(transactionCount) + mockGetVerifiedStatus.mockReturnValue( + // only the isVerified value below matters + { isVerified, completed: 1, total: 1, numAttestationsRemaining: 1 } + ) + mockTokenBalance.mockReturnValue(balanceToken) + mockGetDataEncryptionKey.mockReturnValue(DEK_PUBLIC_KEY) + mockGetWalletAddress.mockReturnValue(mockAccount) + } + + beforeAll(async () => { + keyProvider1 = new MockKeyProvider( + new Map([ + [`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-1`, PNP_THRESHOLD_DEV_PK_SHARE_1_V1], + [`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-2`, PNP_THRESHOLD_DEV_PK_SHARE_1_V2], + [`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-3`, PNP_THRESHOLD_DEV_PK_SHARE_1_V3], + ]) + ) + keyProvider2 = new MockKeyProvider( + new Map([ + [`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-1`, PNP_THRESHOLD_DEV_PK_SHARE_2_V1], + [`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-2`, PNP_THRESHOLD_DEV_PK_SHARE_2_V2], + [`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-3`, PNP_THRESHOLD_DEV_PK_SHARE_2_V3], + ]) + ) + keyProvider3 = new MockKeyProvider( + new Map([ + [`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-1`, PNP_THRESHOLD_DEV_PK_SHARE_3_V1], + [`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-2`, PNP_THRESHOLD_DEV_PK_SHARE_3_V2], + [`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-3`, PNP_THRESHOLD_DEV_PK_SHARE_3_V3], + ]) + ) + + app = startCombiner(combinerConfig, mockKit) + }) + + let req: LegacySignMessageRequest + beforeEach(async () => { + signerDB1 = await initSignerDatabase(signerConfig, signerMigrationsPath) + signerDB2 = await initSignerDatabase(signerConfig, signerMigrationsPath) + signerDB3 = await initSignerDatabase(signerConfig, signerMigrationsPath) + + // this needs to be defined here to avoid errors + userSeed = new Uint8Array(32) + for (let i = 0; i < userSeed.length - 1; i++) { + userSeed[i] = i + } + + blindedMsgResult = threshold_bls.blind(message, userSeed) + + req = getLegacySignRequest(blindedMsgResult) + prepMocks(true) + }) + + afterEach(async () => { + await signerDB1?.destroy() + await signerDB2?.destroy() + await signerDB3?.destroy() + signer1?.close() + signer2?.close() + signer3?.close() + }) + + describe('when signers are operating correctly', () => { + beforeEach(async () => { + signer1 = startSigner(signerConfig, signerDB1, keyProvider1, mockKit).listen(3001) + signer2 = startSigner(signerConfig, signerDB2, keyProvider2, mockKit).listen(3002) + signer3 = startSigner(signerConfig, signerDB3, keyProvider3, mockKit).listen(3003) + }) + + it('Should respond with 200 on valid request', async () => { + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendLegacyPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + const unblindedSig = threshold_bls.unblind( + Buffer.from(res.body.signature, 'base64'), + blindedMsgResult.blindingFactor + ) + + expect(Buffer.from(unblindedSig).toString('base64')).toEqual(expectedUnblindedSig) + }) + + for (let i = 1; i <= 3; i++) { + it(`Should respond with 200 on valid request with key version header ${i}`, async () => { + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendLegacyPnpSignRequest(req, authorization, app, i.toString()) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + signature: expectedSignatures[i - 1], + performedQueryCount: 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + const unblindedSig = threshold_bls.unblind( + Buffer.from(res.body.signature, 'base64'), + blindedMsgResult.blindingFactor + ) + + expect(Buffer.from(unblindedSig).toString('base64')).toEqual(expectedUnblindedSigs[i - 1]) + }) + } + + it('Should respond with 200 on valid request with identifier', async () => { + // Ensure that this gets passed through the combiner to the signer + req.hashedPhoneNumber = IDENTIFIER + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendLegacyPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: 440, // Additional quota gets unlocked with an identifier + blockNumber: testBlockNumber, + warnings: [], + }) + }) + + it('Should respond with 200 on repeated valid requests', async () => { + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res1 = await sendLegacyPnpSignRequest(req, authorization, app) + + expect(res1.status).toBe(200) + expect(res1.body).toStrictEqual({ + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + + // performedQueryCount should remain the same; same request should not + // consume any quota + const res2 = await sendLegacyPnpSignRequest(req, authorization, app) + expect(res2.status).toBe(200) + expect(res2.body).toStrictEqual(res1.body) + }) + + it('Should increment performedQueryCount on request from the same account with a new message', async () => { + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res1 = await sendLegacyPnpSignRequest(req, authorization, app) + + const expectedResponse: SignMessageResponseSuccess = { + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + } + + expect(res1.status).toBe(200) + expect(res1.body).toStrictEqual(expectedResponse) + + // Second request for the same account but with new message + const message2 = Buffer.from('second test message', 'utf8') + const blindedMsg2 = threshold_bls.blind(message2, userSeed) + const req2 = getLegacySignRequest(blindedMsg2) + const authorization2 = getPnpRequestAuthorization(req2, PRIVATE_KEY1) + + // Expect performedQueryCount to increase + expectedResponse.performedQueryCount++ + expectedResponse.signature = + 'PWvuSYIA249x1dx+qzgl6PKSkoulXXE/P4WHJvGmtw77pCRilEWTn3xSp+6JS9+A' + const res2 = await sendLegacyPnpSignRequest(req2, authorization2, app) + expect(res2.status).toBe(200) + expect(res2.body).toStrictEqual(expectedResponse) + }) + + it('Should respond with 200 on extra request fields', async () => { + // @ts-ignore Intentionally adding an extra field to the request type + req.extraField = 'dummyString' + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendLegacyPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + + it('Should respond with 200 when authenticated with DEK', async () => { + req.authenticationMethod = AuthenticationMethod.ENCRYPTION_KEY + const authorization = getPnpRequestAuthorization(req, DEK_PRIVATE_KEY) + const res = await sendLegacyPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + + it('Should get the same unblinded signatures from the same message (different seed)', async () => { + const authorization1 = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res1 = await sendLegacyPnpSignRequest(req, authorization1, app) + + expect(res1.status).toBe(200) + expect(res1.body).toStrictEqual({ + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + + const secondUserSeed = new Uint8Array(userSeed) + secondUserSeed[0]++ + // Ensure message is identical except for message + const req2 = { ...req } + const blindedMsgResult2 = threshold_bls.blind(message, secondUserSeed) + req2.blindedQueryPhoneNumber = Buffer.from(blindedMsgResult2.message).toString('base64') + + // Sanity check + expect(req2.blindedQueryPhoneNumber).not.toEqual(req.blindedQueryPhoneNumber) + + const authorization2 = getPnpRequestAuthorization(req2, PRIVATE_KEY1) + const res2 = await sendLegacyPnpSignRequest(req2, authorization2, app) + expect(res2.status).toBe(200) + const unblindedSig1 = threshold_bls.unblind( + Buffer.from(res1.body.signature, 'base64'), + blindedMsgResult.blindingFactor + ) + const unblindedSig2 = threshold_bls.unblind( + Buffer.from(res2.body.signature, 'base64'), + blindedMsgResult2.blindingFactor + ) + expect(Buffer.from(unblindedSig1).toString('base64')).toEqual(expectedUnblindedSig) + expect(unblindedSig1).toEqual(unblindedSig2) + }) + + it('Should respond with 400 on missing request fields', async () => { + // @ts-ignore Intentionally deleting required field + delete req.account + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendLegacyPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on invalid key version', async () => { + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendLegacyPnpSignRequest(req, authorization, app, 'a') + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.INVALID_KEY_VERSION_REQUEST, + }) + }) + + it('Should respond with 400 on unsupported key version', async () => { + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendLegacyPnpSignRequest(req, authorization, app, '4') + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.INVALID_KEY_VERSION_REQUEST, + }) + }) + + it('Should respond with 400 on request with invalid identifier', async () => { + // Ensure that this gets passed through the combiner to the signer + req.hashedPhoneNumber = '+1234567890' + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendLegacyPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 401 on failed WALLET_KEY auth', async () => { + req.account = mockAccount + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendLegacyPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + + it('Should respond with 401 on failed DEK auth', async () => { + req.account = mockAccount + req.authenticationMethod = AuthenticationMethod.ENCRYPTION_KEY + const differentPk = '0x00000000000000000000000000000000000000000000000000000000ddddbbbb' + const authorization = getPnpRequestAuthorization(req, differentPk) + const res = await sendLegacyPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + + it('Should respond with 403 on out of quota', async () => { + prepMocks(false) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendLegacyPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(403) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.EXCEEDED_QUOTA, + }) + }) + + it('Should respond with 503 on disabled api', async () => { + const configWithApiDisabled: typeof combinerConfig = JSON.parse( + JSON.stringify(combinerConfig) + ) + configWithApiDisabled.phoneNumberPrivacy.enabled = false + const appWithApiDisabled = startCombiner(configWithApiDisabled) + + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendLegacyPnpSignRequest(req, authorization, appWithApiDisabled) + + expect(res.status).toBe(503) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.API_UNAVAILABLE, + }) + }) + + describe('functionality in case of errors', () => { + it('Should respond with 200 on failure to fetch DEK when shouldFailOpen is true', async () => { + mockGetDataEncryptionKey.mockImplementation(() => { + throw new Error() + }) + + // Would fail authentication if getDataEncryptionKey succeeded + const differentPk = '0x00000000000000000000000000000000000000000000000000000000ddddbbbb' + req.authenticationMethod = AuthenticationMethod.ENCRYPTION_KEY + const authorization = getPnpRequestAuthorization(req, differentPk) + + const combinerConfigWithFailOpenEnabled: typeof combinerConfig = JSON.parse( + JSON.stringify(combinerConfig) + ) + combinerConfigWithFailOpenEnabled.phoneNumberPrivacy.shouldFailOpen = true + const appWithFailOpenEnabled = startCombiner(combinerConfigWithFailOpenEnabled) + const res = await sendLegacyPnpSignRequest(req, authorization, appWithFailOpenEnabled) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + const unblindedSig = threshold_bls.unblind( + Buffer.from(res.body.signature, 'base64'), + blindedMsgResult.blindingFactor + ) + expect(Buffer.from(unblindedSig).toString('base64')).toEqual(expectedUnblindedSig) + }) + }) + }) + + // For testing combiner code paths when signers do not behave as expected + describe('when signers are not operating correctly', () => { + let authorization: string + + beforeEach(() => { + authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + }) + + describe('when 2/3 signers return correct signatures', () => { + beforeEach(async () => { + const badBlsShare1 = + '000000002e50aa714ef6b865b5de89c56969ef9f8f27b6b0a6d157c9cc01c574ac9df604' + const badKeyProvider1 = new MockKeyProvider( + new Map([[`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-1`, badBlsShare1]]) + ) + + signer1 = startSigner(signerConfig, signerDB1, badKeyProvider1, mockKit).listen(3001) + signer2 = startSigner(signerConfig, signerDB2, keyProvider2, mockKit).listen(3002) + signer3 = startSigner(signerConfig, signerDB3, keyProvider3, mockKit).listen(3003) + }) + + it('Should respond with 200 on valid request', async () => { + const res = await sendLegacyPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + const unblindedSig = threshold_bls.unblind( + Buffer.from(res.body.signature, 'base64'), + blindedMsgResult.blindingFactor + ) + expect(Buffer.from(unblindedSig).toString('base64')).toEqual(expectedUnblindedSig) + }) + }) + + describe('when 1/3 signers return correct signatures', () => { + beforeEach(async () => { + const badBlsShare1 = + '000000002e50aa714ef6b865b5de89c56969ef9f8f27b6b0a6d157c9cc01c574ac9df604' + const badBlsShare2 = + '01000000b8f0ef841dcf8d7bd1da5e8025e47d729eb67f513335784183b8fa227a0b9a0b' + + const badKeyProvider1 = new MockKeyProvider( + new Map([[`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-1`, badBlsShare1]]) + ) + const badKeyProvider2 = new MockKeyProvider( + new Map([[`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-1`, badBlsShare2]]) + ) + + signer1 = startSigner(signerConfig, signerDB1, keyProvider1, mockKit).listen(3001) + signer2 = startSigner(signerConfig, signerDB2, badKeyProvider1, mockKit).listen(3002) + signer3 = startSigner(signerConfig, signerDB3, badKeyProvider2, mockKit).listen(3003) + }) + + it('Should respond with 500 even if request is valid', async () => { + const res = await sendLegacyPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: ErrorMessage.NOT_ENOUGH_PARTIAL_SIGNATURES, + }) + }) + }) + + describe('when 2/3 of signers are disabled', () => { + beforeEach(async () => { + const configWithApiDisabled: SignerConfig = JSON.parse(JSON.stringify(signerConfig)) + configWithApiDisabled.api.legacyPhoneNumberPrivacy.enabled = false + signer1 = startSigner(signerConfig, signerDB1, keyProvider1).listen(3001) + signer2 = startSigner(configWithApiDisabled, signerDB2, keyProvider2).listen(3002) + signer3 = startSigner(configWithApiDisabled, signerDB3, keyProvider3).listen(3003) + }) + + it('Should fail to reach threshold of signers on valid request', async () => { + const res = await sendLegacyPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(503) // majority error code in this case + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: ErrorMessage.NOT_ENOUGH_PARTIAL_SIGNATURES, + }) + }) + }) + + describe('when 1/3 of signers are disabled', () => { + beforeEach(async () => { + const configWithApiDisabled: SignerConfig = JSON.parse(JSON.stringify(signerConfig)) + configWithApiDisabled.api.legacyPhoneNumberPrivacy.enabled = false + signer1 = startSigner(signerConfig, signerDB1, keyProvider1).listen(3001) + signer2 = startSigner(signerConfig, signerDB2, keyProvider2).listen(3002) + signer3 = startSigner(configWithApiDisabled, signerDB3, keyProvider3).listen(3003) + }) + + it('Should respond with 200 on valid request', async () => { + const res = await sendLegacyPnpSignRequest(req, authorization, app) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + }) + + describe('when signers timeout', () => { + beforeEach(async () => { + const testTimeoutMS = 0 + + const configWithShortTimeout: SignerConfig = JSON.parse(JSON.stringify(signerConfig)) + configWithShortTimeout.timeout = testTimeoutMS + // Test this with all signers timing out to decrease possibility of race conditions + signer1 = startSigner(configWithShortTimeout, signerDB1, keyProvider1).listen(3001) + signer2 = startSigner(configWithShortTimeout, signerDB2, keyProvider2).listen(3002) + signer3 = startSigner(configWithShortTimeout, signerDB3, keyProvider3).listen(3003) + }) + it('Should fail to reach threshold of signers on valid request', async () => { + const res = await sendLegacyPnpSignRequest(req, authorization, app) + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: ErrorMessage.NOT_ENOUGH_PARTIAL_SIGNATURES, + }) + }) + }) + }) +}) diff --git a/packages/phone-number-privacy/combiner/test/integration/pnp.test.ts b/packages/phone-number-privacy/combiner/test/integration/pnp.test.ts new file mode 100644 index 00000000000..a0c8afd2b31 --- /dev/null +++ b/packages/phone-number-privacy/combiner/test/integration/pnp.test.ts @@ -0,0 +1,1190 @@ +import { newKit } from '@celo/contractkit' +import { + AuthenticationMethod, + CombinerEndpoint, + ErrorMessage, + genSessionID, + KEY_VERSION_HEADER, + PnpQuotaRequest, + PnpQuotaResponseFailure, + PnpQuotaResponseSuccess, + SignerEndpoint, + SignMessageRequest, + SignMessageResponseFailure, + SignMessageResponseSuccess, + TestUtils, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { + initDatabase as initSignerDatabase, + startSigner, + SupportedDatabase, + SupportedKeystore, +} from '@celo/phone-number-privacy-signer' +import { + DefaultKeyName, + KeyProvider, +} from '@celo/phone-number-privacy-signer/dist/common/key-management/key-provider-base' +import { MockKeyProvider } from '@celo/phone-number-privacy-signer/dist/common/key-management/mock-key-provider' +import { getVersion, SignerConfig } from '@celo/phone-number-privacy-signer/dist/config' +import BigNumber from 'bignumber.js' +import threshold_bls from 'blind-threshold-bls' +import { Server as HttpsServer } from 'https' +import { Knex } from 'knex' +import { Server } from 'net' +import request from 'supertest' +import config from '../../src/config' +import { startCombiner } from '../../src/server' + +const { + ContractRetrieval, + createMockContractKit, + createMockAccounts, + createMockOdisPayments, + createMockWeb3, + getPnpRequestAuthorization, + getBlindedPhoneNumber, +} = TestUtils.Utils +const { + PRIVATE_KEY1, + ACCOUNT_ADDRESS1, + mockAccount, + DEK_PRIVATE_KEY, + DEK_PUBLIC_KEY, + PNP_THRESHOLD_DEV_PK_SHARE_1_V1, + PNP_THRESHOLD_DEV_PK_SHARE_1_V2, + PNP_THRESHOLD_DEV_PK_SHARE_1_V3, + PNP_THRESHOLD_DEV_PK_SHARE_2_V1, + PNP_THRESHOLD_DEV_PK_SHARE_2_V2, + PNP_THRESHOLD_DEV_PK_SHARE_2_V3, + PNP_THRESHOLD_DEV_PK_SHARE_3_V1, + PNP_THRESHOLD_DEV_PK_SHARE_3_V2, + PNP_THRESHOLD_DEV_PK_SHARE_3_V3, + ACCOUNT_ADDRESS2, + BLINDING_FACTOR, +} = TestUtils.Values + +// create deep copy of config +const combinerConfig: typeof config = JSON.parse(JSON.stringify(config)) +combinerConfig.phoneNumberPrivacy.enabled = true + +const signerConfig: SignerConfig = { + serviceName: 'odis-signer', + server: { + port: undefined, + sslKeyPath: undefined, + sslCertPath: undefined, + }, + quota: { + unverifiedQueryMax: 10, + additionalVerifiedQueryMax: 30, + queryPerTransaction: 2, + // Min balance is .01 cUSD + minDollarBalance: new BigNumber(1e16), + // Min balance is .01 cEUR + minEuroBalance: new BigNumber(1e16), + // Min balance is .005 CELO + minCeloBalance: new BigNumber(5e15), + // Equivalent to 0.001 cUSD/query + queryPriceInCUSD: new BigNumber(0.001), + }, + api: { + domains: { + enabled: false, + }, + phoneNumberPrivacy: { + enabled: true, + shouldFailOpen: true, + }, + legacyPhoneNumberPrivacy: { + enabled: false, + shouldFailOpen: true, + }, + }, + attestations: { + numberAttestationsRequired: 3, + }, + blockchain: { + provider: 'https://alfajores-forno.celo-testnet.org', + apiKey: undefined, + }, + db: { + type: SupportedDatabase.Sqlite, + user: '', + password: '', + database: '', + host: 'http://localhost', + port: undefined, + ssl: true, + poolMaxSize: 50, + }, + keystore: { + type: SupportedKeystore.MOCK_SECRET_MANAGER, + keys: { + phoneNumberPrivacy: { + name: 'phoneNumberPrivacy', + latest: 2, + }, + domains: { + name: 'domains', + latest: 1, + }, + }, + azure: { + clientID: '', + clientSecret: '', + tenant: '', + vaultName: '', + secretName: '', + }, + google: { + projectId: '', + secretName: '', + secretVersion: 'latest', + }, + aws: { + region: '', + secretName: '', + secretKey: '', + }, + }, + timeout: 5000, + test_quota_bypass_percentage: 0, +} + +const testBlockNumber = 1000000 + +const mockOdisPaymentsTotalPaidCUSD = jest.fn() +const mockGetWalletAddress = jest.fn() +const mockGetDataEncryptionKey = jest.fn() + +const mockContractKit = createMockContractKit( + { + [ContractRetrieval.getAccounts]: createMockAccounts( + mockGetWalletAddress, + mockGetDataEncryptionKey + ), + [ContractRetrieval.getOdisPayments]: createMockOdisPayments(mockOdisPaymentsTotalPaidCUSD), + }, + createMockWeb3(5, testBlockNumber) +) + +// Mock newKit as opposed to the CK constructor +// Returns an object of type ContractKit that can be passed into the signers + combiner +jest.mock('@celo/contractkit', () => ({ + ...jest.requireActual('@celo/contractkit'), + newKit: jest.fn().mockImplementation(() => mockContractKit), +})) + +describe('pnpService', () => { + let keyProvider1: KeyProvider + let keyProvider2: KeyProvider + let keyProvider3: KeyProvider + let signerDB1: Knex + let signerDB2: Knex + let signerDB3: Knex + let signer1: Server | HttpsServer + let signer2: Server | HttpsServer + let signer3: Server | HttpsServer + let app: any + + // Used by PNP_SIGN tests for various configurations of signers + let userSeed: Uint8Array + let blindedMsgResult: threshold_bls.BlindedMessage + + const signerMigrationsPath = '../signer/src/common/database/migrations' + const expectedVersion = getVersion() + + const onChainPaymentsDefault = new BigNumber(1e18) + const expectedTotalQuota = 1000 + + const message = Buffer.from('test message', 'utf8') + + const expectedSignatures: string[] = [ + 'xgFMQtcgAMHJAEX/m9B4VFopYtxqPFSw0024sWzRYvQDvnmFqhXOPdnRDfa8WCEA', + 'wUuFV8yFBXGyEzKbyWjBChG6dER264nwjOsqErd/UZieVKE0oDMZcMDG+qObu4QB', + 'PJHqBGavcQG3NGFl3hiR8GymeDNumxbl1DnCJzWz+Ik5yCN2ZpAITBe24RTX0iMA', + ] + const expectedSignature = expectedSignatures[config.phoneNumberPrivacy.keys.currentVersion - 1] + + const expectedUnblindedSigs: string[] = [ + 'lOASnDJNbJBTMYfkbU4fMiK7FcNwSyqZo8iQSM95X8YK+/158be4S1A+jcQsCUYA', + 'QIT7HtHTe/d0Tq40Mf3rpHCT8qY20+8q7ZW9PXHFMWGvwSGhk7l3Pfwnx8YdXomB', + 'XW//DolLzaXYS/gk9WBHfeKy5HKrGjuF/OpCok/i6fprE4AGFH2PjE7zeKTfOQ+A', + ] + const expectedUnblindedSig = + expectedUnblindedSigs[config.phoneNumberPrivacy.keys.currentVersion - 1] + + // In current setup, the same mocked kit is used for the combiner and signers + const mockKit = newKit('dummyKit') + + beforeAll(async () => { + keyProvider1 = new MockKeyProvider( + new Map([ + [`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-1`, PNP_THRESHOLD_DEV_PK_SHARE_1_V1], + [`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-2`, PNP_THRESHOLD_DEV_PK_SHARE_1_V2], + [`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-3`, PNP_THRESHOLD_DEV_PK_SHARE_1_V3], + ]) + ) + keyProvider2 = new MockKeyProvider( + new Map([ + [`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-1`, PNP_THRESHOLD_DEV_PK_SHARE_2_V1], + [`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-2`, PNP_THRESHOLD_DEV_PK_SHARE_2_V2], + [`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-3`, PNP_THRESHOLD_DEV_PK_SHARE_2_V3], + ]) + ) + keyProvider3 = new MockKeyProvider( + new Map([ + [`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-1`, PNP_THRESHOLD_DEV_PK_SHARE_3_V1], + [`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-2`, PNP_THRESHOLD_DEV_PK_SHARE_3_V2], + [`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-3`, PNP_THRESHOLD_DEV_PK_SHARE_3_V3], + ]) + ) + app = startCombiner(combinerConfig, mockKit) + }) + + beforeEach(async () => { + signerDB1 = await initSignerDatabase(signerConfig, signerMigrationsPath) + signerDB2 = await initSignerDatabase(signerConfig, signerMigrationsPath) + signerDB3 = await initSignerDatabase(signerConfig, signerMigrationsPath) + + userSeed = new Uint8Array(32) + for (let i = 0; i < userSeed.length - 1; i++) { + userSeed[i] = i + } + + blindedMsgResult = threshold_bls.blind(message, userSeed) + + mockGetDataEncryptionKey.mockReset().mockReturnValue(DEK_PUBLIC_KEY) + mockGetWalletAddress.mockReset().mockReturnValue(mockAccount) + }) + + afterEach(async () => { + await signerDB1?.destroy() + await signerDB2?.destroy() + await signerDB3?.destroy() + signer1?.close() + signer2?.close() + signer3?.close() + }) + + const sendPnpSignRequest = async ( + req: SignMessageRequest, + authorization: string, + app: any, + keyVersionHeader?: string + ) => { + let reqWithHeaders = request(app) + .post(CombinerEndpoint.PNP_SIGN) + .set('Authorization', authorization) + + if (keyVersionHeader) { + reqWithHeaders = reqWithHeaders.set(KEY_VERSION_HEADER, keyVersionHeader) + } + return reqWithHeaders.send(req) + } + + const getSignRequest = (_blindedMsgResult: threshold_bls.BlindedMessage): SignMessageRequest => { + return { + account: ACCOUNT_ADDRESS1, + blindedQueryPhoneNumber: Buffer.from(_blindedMsgResult.message).toString('base64'), + sessionID: genSessionID(), + } + } + + const useQuery = async (performedQueryCount: number, signer: Server | HttpsServer) => { + for (let i = 0; i < performedQueryCount; i++) { + const phoneNumber = '+1' + Math.floor(Math.random() * 10 ** 10) + const blindedNumber = getBlindedPhoneNumber(phoneNumber, BLINDING_FACTOR) + const req = { + account: ACCOUNT_ADDRESS1, + blindedQueryPhoneNumber: blindedNumber, + } + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + await request(signer) + .post(SignerEndpoint.PNP_SIGN) + .set('Authorization', authorization) + .send(req) + } + } + + const getCombinerQuotaResponse = async ( + req: PnpQuotaRequest, + authorization: string, + _app: any = app + ) => { + const res = await request(_app) + .post(CombinerEndpoint.PNP_QUOTA) + .set('Authorization', authorization) + .send(req) + return res + } + + describe('when signers are operating correctly', () => { + beforeEach(async () => { + signer1 = startSigner(signerConfig, signerDB1, keyProvider1, mockKit).listen(3001) + signer2 = startSigner(signerConfig, signerDB2, keyProvider2, mockKit).listen(3002) + signer3 = startSigner(signerConfig, signerDB3, keyProvider3, mockKit).listen(3003) + }) + + describe(`${CombinerEndpoint.PNP_QUOTA}`, () => { + const totalQuota = 10 + const weiTocusd = new BigNumber(1e18) + beforeAll(async () => { + mockOdisPaymentsTotalPaidCUSD.mockReturnValue( + weiTocusd.multipliedBy(totalQuota).multipliedBy(signerConfig.quota.queryPriceInCUSD) + ) + }) + + const queryCountParams = [ + { signerQueries: [0, 0, 0], expectedQueryCount: 0, expectedWarnings: [] }, + { + signerQueries: [1, 0, 0], + expectedQueryCount: 0, + expectedWarnings: [WarningMessage.SIGNER_RESPONSE_DISCREPANCIES], + }, // does not reach threshold + { + signerQueries: [1, 1, 0], + expectedQueryCount: 1, + expectedWarnings: [WarningMessage.SIGNER_RESPONSE_DISCREPANCIES], + }, // threshold reached + { + signerQueries: [0, 1, 1], + expectedQueryCount: 1, + expectedWarnings: [WarningMessage.SIGNER_RESPONSE_DISCREPANCIES], + }, // order of signers shouldn't matter + { + signerQueries: [1, 4, 9], + expectedQueryCount: 4, + expectedWarnings: [ + WarningMessage.SIGNER_RESPONSE_DISCREPANCIES, + WarningMessage.INCONSISTENT_SIGNER_QUERY_MEASUREMENTS, + ], + }, + ] + queryCountParams.forEach(({ signerQueries, expectedQueryCount, expectedWarnings }) => { + it(`should get ${expectedQueryCount} performedQueryCount given signer responses of ${signerQueries}`, async () => { + await useQuery(signerQueries[0], signer1) + await useQuery(signerQueries[1], signer2) + await useQuery(signerQueries[2], signer3) + + const req = { + account: ACCOUNT_ADDRESS1, + } + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await getCombinerQuotaResponse(req, authorization) + + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + performedQueryCount: expectedQueryCount, + totalQuota, + blockNumber: testBlockNumber, + warnings: expectedWarnings, + }) + }) + }) + + it('Should respond with 200 on valid request', async () => { + const req = { + account: ACCOUNT_ADDRESS1, + } + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await getCombinerQuotaResponse(req, authorization) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + performedQueryCount: 0, + totalQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + + it('Should respond with 200 on repeated valid requests', async () => { + const req = { + account: ACCOUNT_ADDRESS1, + } + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res1 = await getCombinerQuotaResponse(req, authorization) + expect(res1.status).toBe(200) + expect(res1.body).toStrictEqual({ + success: true, + version: expectedVersion, + performedQueryCount: 0, + totalQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + const res2 = await getCombinerQuotaResponse(req, authorization) + expect(res2.status).toBe(200) + expect(res2.body).toStrictEqual(res1.body) + }) + + it('Should respond with 200 on extra request fields', async () => { + const req = { + account: ACCOUNT_ADDRESS1, + extraField: 'dummy', + } + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await getCombinerQuotaResponse(req, authorization) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + performedQueryCount: 0, + totalQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + + it('Should respond with 200 when authenticated with DEK', async () => { + const req = { + account: ACCOUNT_ADDRESS1, + authenticationMethod: AuthenticationMethod.ENCRYPTION_KEY, + } + const authorization = getPnpRequestAuthorization(req, DEK_PRIVATE_KEY) + const res = await getCombinerQuotaResponse(req, authorization) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + performedQueryCount: 0, + totalQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + + it('Should respond with a warning when there are slight discrepancies in total quota', async () => { + mockOdisPaymentsTotalPaidCUSD.mockReturnValueOnce( + weiTocusd.multipliedBy(totalQuota + 1).multipliedBy(signerConfig.quota.queryPriceInCUSD) + ) + const req = { + account: ACCOUNT_ADDRESS1, + } + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await getCombinerQuotaResponse(req, authorization) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + performedQueryCount: 0, + totalQuota, + blockNumber: testBlockNumber, + warnings: [ + WarningMessage.SIGNER_RESPONSE_DISCREPANCIES, + WarningMessage.INCONSISTENT_SIGNER_QUOTA_MEASUREMENTS + + ', using threshold signer as best guess', + ], + }) + }) + + it('Should respond with 500 when there are large discrepancies in total quota', async () => { + mockOdisPaymentsTotalPaidCUSD.mockReturnValueOnce(weiTocusd.multipliedBy(totalQuota + 15)) + const req = { + account: ACCOUNT_ADDRESS1, + } + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await getCombinerQuotaResponse(req, authorization) + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: ErrorMessage.THRESHOLD_PNP_QUOTA_STATUS_FAILURE, + }) + }) + + it('Should respond with 400 on missing request fields', async () => { + // @ts-ignore Intentionally missing required fields + const req: PnpQuotaRequest = {} + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await getCombinerQuotaResponse(req, authorization) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 with invalid address', async () => { + const req = { + account: 'not an address', + } + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await getCombinerQuotaResponse(req, authorization) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 401 on failed WALLET_KEY auth', async () => { + // Request from one account, signed by another account + const req = { + account: ACCOUNT_ADDRESS2, + } + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await getCombinerQuotaResponse(req, authorization) + + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + + it('Should respond with 401 on failed DEK auth', async () => { + const req = { + account: ACCOUNT_ADDRESS2, + AuthenticationMethod: AuthenticationMethod.ENCRYPTION_KEY, + } + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await getCombinerQuotaResponse(req, authorization) + + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + + it('Should respond with 502 when insufficient signer responses', async () => { + await signerDB1?.destroy() + await signerDB2?.destroy() + signer1?.close() + signer2?.close() + + const req = { + account: ACCOUNT_ADDRESS1, + } + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await getCombinerQuotaResponse(req, authorization) + + expect(res.status).toBe(502) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: ErrorMessage.THRESHOLD_PNP_QUOTA_STATUS_FAILURE, + }) + }) + + it('Should respond with 503 on disabled api', async () => { + const configWithApiDisabled: typeof combinerConfig = JSON.parse( + JSON.stringify(combinerConfig) + ) + configWithApiDisabled.phoneNumberPrivacy.enabled = false + const appWithApiDisabled = startCombiner(configWithApiDisabled) + const req = { + account: ACCOUNT_ADDRESS1, + } + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await request(appWithApiDisabled) + .post(CombinerEndpoint.PNP_QUOTA) + .set('Authorization', authorization) + .send(req) + expect(res.status).toBe(503) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.API_UNAVAILABLE, + }) + }) + + describe('functionality in case of errors', () => { + it('Should respond with 200 on failure to fetch DEK when shouldFailOpen is true', async () => { + mockGetDataEncryptionKey.mockReset().mockImplementation(() => { + throw new Error() + }) + + const req = { + account: ACCOUNT_ADDRESS1, + authenticationMethod: AuthenticationMethod.ENCRYPTION_KEY, + } + + // NOT the dek private key, so authentication would fail if getDataEncryptionKey succeeded + const differentPk = '0x00000000000000000000000000000000000000000000000000000000ddddbbbb' + const authorization = getPnpRequestAuthorization(req, differentPk) + + const combinerConfigWithFailOpenEnabled: typeof combinerConfig = JSON.parse( + JSON.stringify(combinerConfig) + ) + combinerConfigWithFailOpenEnabled.phoneNumberPrivacy.shouldFailOpen = true + const appWithFailOpenEnabled = startCombiner(combinerConfigWithFailOpenEnabled) + const res = await getCombinerQuotaResponse(req, authorization, appWithFailOpenEnabled) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + performedQueryCount: 0, + totalQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + }) + }) + + describe(`${CombinerEndpoint.PNP_SIGN}`, () => { + let req: SignMessageRequest + + beforeEach(async () => { + mockOdisPaymentsTotalPaidCUSD.mockReturnValue(onChainPaymentsDefault) + req = getSignRequest(blindedMsgResult) + }) + + it('Should respond with 200 on valid request', async () => { + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: expectedTotalQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + const unblindedSig = threshold_bls.unblind( + Buffer.from(res.body.signature, 'base64'), + blindedMsgResult.blindingFactor + ) + + expect(Buffer.from(unblindedSig).toString('base64')).toEqual(expectedUnblindedSig) + }) + + for (let i = 1; i <= 3; i++) { + it(`Should respond with 200 on valid request with key version header ${i}`, async () => { + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendPnpSignRequest(req, authorization, app, i.toString()) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + signature: expectedSignatures[i - 1], + performedQueryCount: 1, + totalQuota: expectedTotalQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + + const unblindedSig = threshold_bls.unblind( + Buffer.from(res.body.signature, 'base64'), + blindedMsgResult.blindingFactor + ) + + expect(Buffer.from(unblindedSig).toString('base64')).toEqual(expectedUnblindedSigs[i - 1]) + }) + } + + it('Should respond with 200 on repeated valid requests', async () => { + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res1 = await sendPnpSignRequest(req, authorization, app) + const expectedResponse: SignMessageResponseSuccess = { + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: expectedTotalQuota, + blockNumber: testBlockNumber, + warnings: [], + } + + expect(res1.status).toBe(200) + expect(res1.body).toStrictEqual(expectedResponse) + + const res2 = await sendPnpSignRequest(req, authorization, app) + expect(res2.status).toBe(200) + // Do not expect performedQueryCount to increase since this is a duplicate request + expect(res2.body).toStrictEqual(expectedResponse) + }) + + it('Should increment performedQueryCount on request from the same account with a new message', async () => { + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res1 = await sendPnpSignRequest(req, authorization, app) + const expectedResponse: SignMessageResponseSuccess = { + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: expectedTotalQuota, + blockNumber: testBlockNumber, + warnings: [], + } + + expect(res1.status).toBe(200) + expect(res1.body).toStrictEqual(expectedResponse) + + // Second request for the same account but with new message + const message2 = Buffer.from('second test message', 'utf8') + const blindedMsg2 = threshold_bls.blind(message2, userSeed) + const req2 = getSignRequest(blindedMsg2) + const authorization2 = getPnpRequestAuthorization(req2, PRIVATE_KEY1) + + // Expect performedQueryCount to increase + expectedResponse.performedQueryCount++ + expectedResponse.signature = + 'PWvuSYIA249x1dx+qzgl6PKSkoulXXE/P4WHJvGmtw77pCRilEWTn3xSp+6JS9+A' + const res2 = await sendPnpSignRequest(req2, authorization2, app) + expect(res2.status).toBe(200) + expect(res2.body).toStrictEqual(expectedResponse) + }) + + it('Should respond with 200 on extra request fields', async () => { + // @ts-ignore Intentionally adding an extra field to the request type + req.extraField = 'dummyString' + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: expectedTotalQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + + it('Should respond with 200 when authenticated with DEK', async () => { + req.authenticationMethod = AuthenticationMethod.ENCRYPTION_KEY + const authorization = getPnpRequestAuthorization(req, DEK_PRIVATE_KEY) + const res = await sendPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: expectedTotalQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + + it('Should get the same unblinded signatures from the same message (different seed)', async () => { + const authorization1 = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res1 = await sendPnpSignRequest(req, authorization1, app) + + expect(res1.status).toBe(200) + expect(res1.body).toStrictEqual({ + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: expectedTotalQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + + const secondUserSeed = new Uint8Array(userSeed) + secondUserSeed[0]++ + // Ensure request is identical except for blinded message + const req2 = { ...req } + const blindedMsgResult2 = threshold_bls.blind(message, secondUserSeed) + req2.blindedQueryPhoneNumber = Buffer.from(blindedMsgResult2.message).toString('base64') + + // Sanity check + expect(req2.blindedQueryPhoneNumber).not.toEqual(req.blindedQueryPhoneNumber) + + const authorization2 = getPnpRequestAuthorization(req2, PRIVATE_KEY1) + const res2 = await sendPnpSignRequest(req2, authorization2, app) + expect(res2.status).toBe(200) + const unblindedSig1 = threshold_bls.unblind( + Buffer.from(res1.body.signature, 'base64'), + blindedMsgResult.blindingFactor + ) + const unblindedSig2 = threshold_bls.unblind( + Buffer.from(res2.body.signature, 'base64'), + blindedMsgResult2.blindingFactor + ) + expect(Buffer.from(unblindedSig1).toString('base64')).toEqual(expectedUnblindedSig) + expect(unblindedSig1).toEqual(unblindedSig2) + }) + + it('Should respond with 400 on missing request fields', async () => { + // @ts-ignore Intentionally deleting required field + delete req.account + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on invalid key version', async () => { + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendPnpSignRequest(req, authorization, app, 'a') + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.INVALID_KEY_VERSION_REQUEST, + }) + }) + + it('Should respond with 400 on unsupported key version', async () => { + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendPnpSignRequest(req, authorization, app, '4') + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.INVALID_KEY_VERSION_REQUEST, + }) + }) + + it('Should respond with 401 on failed WALLET_KEY auth', async () => { + req.account = mockAccount + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + + it('Should respond with 401 on failed DEK auth', async () => { + req.account = mockAccount + req.authenticationMethod = AuthenticationMethod.ENCRYPTION_KEY + const differentPk = '0x00000000000000000000000000000000000000000000000000000000ddddbbbb' + const authorization = getPnpRequestAuthorization(req, differentPk) + const res = await sendPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + + it('Should respond with 403 on out of quota', async () => { + mockOdisPaymentsTotalPaidCUSD.mockReturnValue(new BigNumber(0)) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(403) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.EXCEEDED_QUOTA, + }) + }) + + it('Should respond with 503 on disabled api', async () => { + const configWithApiDisabled: typeof combinerConfig = JSON.parse( + JSON.stringify(combinerConfig) + ) + configWithApiDisabled.phoneNumberPrivacy.enabled = false + const appWithApiDisabled = startCombiner(configWithApiDisabled) + + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendPnpSignRequest(req, authorization, appWithApiDisabled) + + expect(res.status).toBe(503) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.API_UNAVAILABLE, + }) + }) + + describe('functionality in case of errors', () => { + it('Should return 200 on failure to fetch DEK when shouldFailOpen is true', async () => { + mockGetDataEncryptionKey.mockImplementation(() => { + throw new Error() + }) + + req.authenticationMethod = AuthenticationMethod.ENCRYPTION_KEY + // NOT the dek private key, so authentication would fail if getDataEncryptionKey succeeded + const differentPk = '0x00000000000000000000000000000000000000000000000000000000ddddbbbb' + const authorization = getPnpRequestAuthorization(req, differentPk) + + const combinerConfigWithFailOpenEnabled: typeof combinerConfig = JSON.parse( + JSON.stringify(combinerConfig) + ) + combinerConfigWithFailOpenEnabled.phoneNumberPrivacy.shouldFailOpen = true + const appWithFailOpenEnabled = startCombiner(combinerConfigWithFailOpenEnabled) + const res = await sendPnpSignRequest(req, authorization, appWithFailOpenEnabled) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: expectedTotalQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + const unblindedSig = threshold_bls.unblind( + Buffer.from(res.body.signature, 'base64'), + blindedMsgResult.blindingFactor + ) + expect(Buffer.from(unblindedSig).toString('base64')).toEqual(expectedUnblindedSig) + }) + + it('Should return 401 on failure to fetch DEK when shouldFailOpen is false', async () => { + mockGetDataEncryptionKey.mockImplementation(() => { + throw new Error() + }) + + req.authenticationMethod = AuthenticationMethod.ENCRYPTION_KEY + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + + const combinerConfigWithFailOpenDisabled: typeof combinerConfig = JSON.parse( + JSON.stringify(combinerConfig) + ) + combinerConfigWithFailOpenDisabled.phoneNumberPrivacy.shouldFailOpen = false + const appWithFailOpenDisabled = startCombiner(combinerConfigWithFailOpenDisabled) + const res = await sendPnpSignRequest(req, authorization, appWithFailOpenDisabled) + + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + }) + }) + }) + + // For testing combiner code paths when signers do not behave as expected + describe('when signers are not operating correctly', () => { + beforeEach(() => { + mockOdisPaymentsTotalPaidCUSD.mockReturnValue(onChainPaymentsDefault) + }) + + describe('when 2/3 signers return correct signatures', () => { + beforeEach(async () => { + const badBlsShare1 = + '000000002e50aa714ef6b865b5de89c56969ef9f8f27b6b0a6d157c9cc01c574ac9df604' + + const badKeyProvider1 = new MockKeyProvider( + new Map([[`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-1`, badBlsShare1]]) + ) + signer1 = startSigner(signerConfig, signerDB1, badKeyProvider1, mockKit).listen(3001) + signer2 = startSigner(signerConfig, signerDB2, keyProvider2, mockKit).listen(3002) + signer3 = startSigner(signerConfig, signerDB3, keyProvider3, mockKit).listen(3003) + }) + + describe(`${CombinerEndpoint.PNP_SIGN}`, () => { + it('Should respond with 200 on valid request', async () => { + const req = getSignRequest(blindedMsgResult) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: expectedTotalQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + const unblindedSig = threshold_bls.unblind( + Buffer.from(res.body.signature, 'base64'), + blindedMsgResult.blindingFactor + ) + expect(Buffer.from(unblindedSig).toString('base64')).toEqual(expectedUnblindedSig) + }) + }) + }) + + describe('when 1/3 signers return correct signatures', () => { + beforeEach(async () => { + const badBlsShare1 = + '000000002e50aa714ef6b865b5de89c56969ef9f8f27b6b0a6d157c9cc01c574ac9df604' + const badBlsShare2 = + '01000000b8f0ef841dcf8d7bd1da5e8025e47d729eb67f513335784183b8fa227a0b9a0b' + + const badKeyProvider1 = new MockKeyProvider( + new Map([[`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-1`, badBlsShare1]]) + ) + + const badKeyProvider2 = new MockKeyProvider( + new Map([[`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-1`, badBlsShare2]]) + ) + + signer1 = startSigner(signerConfig, signerDB1, keyProvider1, mockKit).listen(3001) + signer2 = startSigner(signerConfig, signerDB2, badKeyProvider1, mockKit).listen(3002) + signer3 = startSigner(signerConfig, signerDB3, badKeyProvider2, mockKit).listen(3003) + }) + + describe(`${CombinerEndpoint.PNP_SIGN}`, () => { + it('Should respond with 500 even if request is valid', async () => { + const req = getSignRequest(blindedMsgResult) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: ErrorMessage.NOT_ENOUGH_PARTIAL_SIGNATURES, + }) + }) + }) + }) + + describe('when 2/3 of signers are disabled', () => { + beforeEach(async () => { + const configWithApiDisabled: SignerConfig = JSON.parse(JSON.stringify(signerConfig)) + configWithApiDisabled.api.phoneNumberPrivacy.enabled = false + signer1 = startSigner(signerConfig, signerDB1, keyProvider1).listen(3001) + signer2 = startSigner(configWithApiDisabled, signerDB2, keyProvider2).listen(3002) + signer3 = startSigner(configWithApiDisabled, signerDB3, keyProvider3).listen(3003) + }) + + describe(`${CombinerEndpoint.PNP_QUOTA}`, () => { + it('Should fail to reach threshold of signers on valid request', async () => { + const req = { + account: ACCOUNT_ADDRESS1, + } + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await getCombinerQuotaResponse(req, authorization) + expect(res.status).toBe(503) // majority error code in this case + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: ErrorMessage.THRESHOLD_PNP_QUOTA_STATUS_FAILURE, + }) + }) + }) + + describe(`${CombinerEndpoint.PNP_SIGN}`, () => { + it('Should fail to reach threshold of signers on valid request', async () => { + const req = getSignRequest(blindedMsgResult) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendPnpSignRequest(req, authorization, app) + + expect(res.status).toBe(503) // majority error code in this case + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: ErrorMessage.NOT_ENOUGH_PARTIAL_SIGNATURES, + }) + }) + }) + }) + + describe('when 1/3 of signers are disabled', () => { + beforeEach(async () => { + const configWithApiDisabled: SignerConfig = JSON.parse(JSON.stringify(signerConfig)) + configWithApiDisabled.api.phoneNumberPrivacy.enabled = false + signer1 = startSigner(signerConfig, signerDB1, keyProvider1).listen(3001) + signer2 = startSigner(signerConfig, signerDB2, keyProvider2).listen(3002) + signer3 = startSigner(configWithApiDisabled, signerDB3, keyProvider3).listen(3003) + }) + + describe(`${CombinerEndpoint.PNP_QUOTA}`, () => { + it('Should respond with 200 on valid request', async () => { + const req = { + account: ACCOUNT_ADDRESS1, + } + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await getCombinerQuotaResponse(req, authorization) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + performedQueryCount: 0, + totalQuota: expectedTotalQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + }) + + describe(`${CombinerEndpoint.PNP_SIGN}`, () => { + it('Should respond with 200 on valid request', async () => { + const req = getSignRequest(blindedMsgResult) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendPnpSignRequest(req, authorization, app) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: expectedTotalQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + }) + }) + + describe('when signers timeout', () => { + beforeEach(async () => { + const testTimeoutMS = 0 + + const configWithShortTimeout: SignerConfig = JSON.parse(JSON.stringify(signerConfig)) + configWithShortTimeout.timeout = testTimeoutMS + // Test this with all signers timing out to decrease possibility of race conditions + signer1 = startSigner(configWithShortTimeout, signerDB1, keyProvider1).listen(3001) + signer2 = startSigner(configWithShortTimeout, signerDB2, keyProvider2).listen(3002) + signer3 = startSigner(configWithShortTimeout, signerDB3, keyProvider3).listen(3003) + }) + + describe(`${CombinerEndpoint.PNP_QUOTA}`, () => { + it('Should fail to reach threshold of signers on valid request', async () => { + const req = { + account: ACCOUNT_ADDRESS1, + } + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await getCombinerQuotaResponse(req, authorization) + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: ErrorMessage.THRESHOLD_PNP_QUOTA_STATUS_FAILURE, + }) + }) + }) + + describe(`${CombinerEndpoint.PNP_SIGN}`, () => { + it('Should fail to reach threshold of signers on valid request', async () => { + const req = getSignRequest(blindedMsgResult) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendPnpSignRequest(req, authorization, app) + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: ErrorMessage.NOT_ENOUGH_PARTIAL_SIGNATURES, + }) + }) + }) + }) + }) +}) diff --git a/packages/phone-number-privacy/combiner/test/signing/bls-signature.test.ts b/packages/phone-number-privacy/combiner/test/unit/bls-signature.test.ts similarity index 79% rename from packages/phone-number-privacy/combiner/test/signing/bls-signature.test.ts rename to packages/phone-number-privacy/combiner/test/unit/bls-signature.test.ts index 85f56f0250d..88a5cd3bb5d 100644 --- a/packages/phone-number-privacy/combiner/test/signing/bls-signature.test.ts +++ b/packages/phone-number-privacy/combiner/test/unit/bls-signature.test.ts @@ -1,8 +1,7 @@ +import { KeyVersionInfo, rootLogger } from '@celo/phone-number-privacy-common' import threshold_bls from 'blind-threshold-bls' -import { - BLSCryptographyClient, - ServicePartialSignature, -} from '../../src/bls/bls-cryptography-client' +import { BLSCryptographyClient } from '../../src/common/crypto-clients/bls-crypto-client' +import { ServicePartialSignature } from '../../src/common/crypto-clients/crypto-client' import config from '../../src/config' const PUBLIC_KEY = @@ -19,12 +18,18 @@ const COMBINED_SIGNATURE = '16RcENpbLgq5pIkcPWdgnMofeLqSyuUVin9h4jof9/I8GRsmt5iR const INVALID_SIGNATURE = 'MAAAAAAAAACanrA73tApLu+j569ICcXrEBRLi4czWJtInJPSUpoZUOVDc1667hvMq1ESncFzlgEHAAAA' -config.thresholdSignature = { +const keyVersionInfo: KeyVersionInfo = { + keyVersion: 1, threshold: 3, polynomial: PUBLIC_POLYNOMIAL, pubKey: PUBLIC_KEY, } +config.phoneNumberPrivacy.keys = { + currentVersion: keyVersionInfo.keyVersion, + versions: JSON.stringify([keyVersionInfo]), +} + describe(`BLS service computes signature`, () => { it('provides blinded signature', async () => { const signatures: ServicePartialSignature[] = [ @@ -55,9 +60,9 @@ describe(`BLS service computes signature`, () => { const blindedMsgResult = threshold_bls.blind(message, userSeed) const blindedMsg = Buffer.from(blindedMsgResult.message).toString('base64') - const blsCryptoClient = new BLSCryptographyClient() + const blsCryptoClient = new BLSCryptographyClient(keyVersionInfo) for (let i = 0; i < signatures.length; i++) { - await blsCryptoClient.addSignature(signatures[i]) + blsCryptoClient.addSignature(signatures[i]) if (i >= 2) { expect(blsCryptoClient.hasSufficientSignatures()).toBeTruthy() } else { @@ -65,7 +70,10 @@ describe(`BLS service computes signature`, () => { } } - const actual = await blsCryptoClient.combinePartialBlindedSignatures(blindedMsg) + const actual = blsCryptoClient.combineBlindedSignatureShares( + blindedMsg, + rootLogger(config.serviceName) + ) expect(actual).toEqual(COMBINED_SIGNATURE) const unblindedSignedMessage = threshold_bls.unblind( @@ -104,11 +112,14 @@ describe(`BLS service computes signature`, () => { const blindedMsgResult = threshold_bls.blind(message, userSeed) const blindedMsg = Buffer.from(blindedMsgResult.message).toString('base64') - const blsCryptoClient = new BLSCryptographyClient() - await signatures.forEach(async (signature) => { - await blsCryptoClient.addSignature(signature) + const blsCryptoClient = new BLSCryptographyClient(keyVersionInfo) + signatures.forEach(async (signature) => { + blsCryptoClient.addSignature(signature) }) - const actual = await blsCryptoClient.combinePartialBlindedSignatures(blindedMsg) + const actual = blsCryptoClient.combineBlindedSignatureShares( + blindedMsg, + rootLogger(config.serviceName) + ) expect(actual).toEqual(COMBINED_SIGNATURE) const unblindedSignedMessage = threshold_bls.unblind( @@ -147,12 +158,12 @@ describe(`BLS service computes signature`, () => { const blindedMsgResult = threshold_bls.blind(message, userSeed) const blindedMsg = Buffer.from(blindedMsgResult.message).toString('base64') - const blsCryptoClient = new BLSCryptographyClient() - await signatures.forEach(async (signature) => { - await blsCryptoClient.addSignature(signature) + const blsCryptoClient = new BLSCryptographyClient(keyVersionInfo) + signatures.forEach(async (signature) => { + blsCryptoClient.addSignature(signature) }) try { - await blsCryptoClient.combinePartialBlindedSignatures(blindedMsg) + blsCryptoClient.combineBlindedSignatureShares(blindedMsg, rootLogger(config.serviceName)) throw new Error('Expected failure with missing signatures') } catch (e: any) { expect(e.message.includes('Not enough partial signatures')).toBeTruthy() @@ -187,26 +198,29 @@ describe(`BLS service computes signature`, () => { const blindedMsgResult = threshold_bls.blind(message, userSeed) const blindedMsg = Buffer.from(blindedMsgResult.message).toString('base64') - const blsCryptoClient = new BLSCryptographyClient() + const blsCryptoClient = new BLSCryptographyClient(keyVersionInfo) // Add sigs one-by-one and verify intermediary states - await blsCryptoClient.addSignature(signatures[0]) + blsCryptoClient.addSignature(signatures[0]) expect(blsCryptoClient.hasSufficientSignatures()).toBeFalsy() - await blsCryptoClient.addSignature(signatures[1]) + blsCryptoClient.addSignature(signatures[1]) expect(blsCryptoClient.hasSufficientSignatures()).toBeFalsy() - await blsCryptoClient.addSignature(signatures[2]) + blsCryptoClient.addSignature(signatures[2]) expect(blsCryptoClient.hasSufficientSignatures()).toBeTruthy() // Should fail since 1/3 sigs are invalid try { - await blsCryptoClient.combinePartialBlindedSignatures(blindedMsg) + blsCryptoClient.combineBlindedSignatureShares(blindedMsg, rootLogger(config.serviceName)) } catch (e: any) { expect(e.message.includes('Not enough partial signatures')).toBeTruthy() } // Should be false, now that the invalid signature has been removed expect(blsCryptoClient.hasSufficientSignatures()).toBeFalsy() - await blsCryptoClient.addSignature(signatures[3]) + blsCryptoClient.addSignature(signatures[3]) expect(blsCryptoClient.hasSufficientSignatures()).toBeTruthy() - const actual = await blsCryptoClient.combinePartialBlindedSignatures(blindedMsg) + const actual = blsCryptoClient.combineBlindedSignatureShares( + blindedMsg, + rootLogger(config.serviceName) + ) expect(actual).toEqual(COMBINED_SIGNATURE) const unblindedSignedMessage = threshold_bls.unblind( @@ -245,17 +259,17 @@ describe(`BLS service computes signature`, () => { const blindedMsgResult = threshold_bls.blind(message, userSeed) const blindedMsg = Buffer.from(blindedMsgResult.message).toString('base64') - const blsCryptoClient = new BLSCryptographyClient() + const blsCryptoClient = new BLSCryptographyClient(keyVersionInfo) // Add sigs one-by-one and verify intermediary states - await blsCryptoClient.addSignature(signatures[0]) + blsCryptoClient.addSignature(signatures[0]) expect(blsCryptoClient.hasSufficientSignatures()).toBeFalsy() - await blsCryptoClient.addSignature(signatures[1]) + blsCryptoClient.addSignature(signatures[1]) expect(blsCryptoClient.hasSufficientSignatures()).toBeFalsy() - await blsCryptoClient.addSignature(signatures[2]) + blsCryptoClient.addSignature(signatures[2]) expect(blsCryptoClient.hasSufficientSignatures()).toBeTruthy() // Should fail since signature from url3 was generated with the wrong key version try { - await blsCryptoClient.combinePartialBlindedSignatures(blindedMsg) + blsCryptoClient.combineBlindedSignatureShares(blindedMsg, rootLogger(config.serviceName)) } catch (e: any) { expect(e.message.includes('Not enough partial signatures')).toBeTruthy() } @@ -263,9 +277,12 @@ describe(`BLS service computes signature`, () => { // Should be false, now that the invalid partial signature has been removed expect(blsCryptoClient.hasSufficientSignatures()).toBeFalsy() - await blsCryptoClient.addSignature(signatures[3]) + blsCryptoClient.addSignature(signatures[3]) expect(blsCryptoClient.hasSufficientSignatures()).toBeTruthy() - const actual = await blsCryptoClient.combinePartialBlindedSignatures(blindedMsg) + const actual = blsCryptoClient.combineBlindedSignatureShares( + blindedMsg, + rootLogger(config.serviceName) + ) expect(actual).toEqual(COMBINED_SIGNATURE) const unblindedSignedMessage = threshold_bls.unblind( diff --git a/packages/phone-number-privacy/combiner/test/unit/domain-response-logger.test.ts b/packages/phone-number-privacy/combiner/test/unit/domain-response-logger.test.ts new file mode 100644 index 00000000000..d2af0e1e978 --- /dev/null +++ b/packages/phone-number-privacy/combiner/test/unit/domain-response-logger.test.ts @@ -0,0 +1,350 @@ +import { + DisableDomainRequest, + DomainQuotaStatusRequest, + DomainRequest, + DomainRestrictedSignatureRequest, + KeyVersionInfo, + OdisResponse, + rootLogger, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { getVersion } from '@celo/phone-number-privacy-signer/src/config' +import { Request, Response } from 'express' +import { Session } from '../../src/common/session' +import config from '../../src/config' +import { DomainSignerResponseLogger } from '../../src/domain/services/log-responses' + +describe('domain response logger', () => { + const url = 'test signer url' + + const keyVersionInfo: KeyVersionInfo = { + keyVersion: 1, + threshold: 3, + polynomial: 'mock polynomial', + pubKey: 'mock pubKey', + } + + const getSession = (responses: OdisResponse[]) => { + const mockRequest = { + body: {}, + } as Request + const mockResponse = { + locals: { + logger: rootLogger(config.serviceName), + }, + } as Response + const session = new Session(mockRequest, mockResponse, keyVersionInfo) + responses.forEach((res) => { + session.responses.push({ url, res, status: 200 }) + }) + return session + } + + const version = getVersion() + const counter = 1 + const disabled = false + const timer = 10000 + const domainResponseLogger = new DomainSignerResponseLogger() + + const testCases: { + it: string + responses: OdisResponse< + DomainRestrictedSignatureRequest | DomainQuotaStatusRequest | DisableDomainRequest + >[] + expectedLogs: { + params: string | any[] + level: 'info' | 'debug' | 'warn' | 'error' + }[] + }[] = [ + { + it: 'should log correctly when no responses provided', + responses: [], + expectedLogs: [ + { + params: ['No successful signer responses found!'], + level: 'warn', + }, + ], + }, + { + it: 'should log correctly when all the responses are the same (except for now field)', + responses: [ + { success: true, version, status: { counter, timer, disabled, now: Date.now() } }, + { success: true, version, status: { counter, timer, disabled, now: Date.now() } }, + { success: true, version, status: { counter, timer, disabled, now: Date.now() } }, + ], + expectedLogs: [], + }, + { + it: 'should log correctly when there is a discrepency in version field', + responses: [ + { success: true, version, status: { counter, timer, disabled, now: Date.now() } }, + { success: true, version, status: { counter, timer, disabled, now: Date.now() } }, + { + success: true, + version: 'differentVersion', + status: { counter, timer, disabled, now: Date.now() }, + }, + ], + expectedLogs: [ + { + params: [ + { + parsedResponses: [ + { + signerUrl: url, + values: { + counter, + disabled, + timer, + version, + }, + }, + { + signerUrl: url, + values: { + counter, + disabled, + timer, + version, + }, + }, + { + signerUrl: url, + values: { + counter, + disabled, + timer, + version: 'differentVersion', + }, + }, + ], + }, + WarningMessage.SIGNER_RESPONSE_DISCREPANCIES, + ], + level: 'warn', + }, + ], + }, + { + it: 'should log correctly when there is a discrepency in counter field', + responses: [ + { success: true, version, status: { counter, timer, disabled, now: Date.now() } }, + { success: true, version, status: { counter, timer, disabled, now: Date.now() } }, + { + success: true, + version, + status: { counter: counter + 1, timer, disabled, now: Date.now() }, + }, + ], + expectedLogs: [ + { + params: [ + { + parsedResponses: [ + { + signerUrl: url, + values: { + counter, + disabled, + timer, + version, + }, + }, + { + signerUrl: url, + values: { + counter, + disabled, + timer, + version, + }, + }, + { + signerUrl: url, + values: { + counter: counter + 1, + disabled, + timer, + version, + }, + }, + ], + }, + WarningMessage.SIGNER_RESPONSE_DISCREPANCIES, + ], + level: 'warn', + }, + ], + }, + { + it: 'should log correctly when there is a discrepency in disabled field', + responses: [ + { success: true, version, status: { counter, timer, disabled, now: Date.now() } }, + { success: true, version, status: { counter, timer, disabled, now: Date.now() } }, + { success: true, version, status: { counter, timer, disabled: true, now: Date.now() } }, + ], + expectedLogs: [ + { + params: [ + { + parsedResponses: [ + { + signerUrl: url, + values: { + counter, + disabled, + timer, + version, + }, + }, + { + signerUrl: url, + values: { + counter, + disabled, + timer, + version, + }, + }, + { + signerUrl: url, + values: { + counter, + disabled: true, + timer, + version, + }, + }, + ], + }, + WarningMessage.SIGNER_RESPONSE_DISCREPANCIES, + ], + level: 'warn', + }, + { + params: [ + { + parsedResponses: [ + { + signerUrl: url, + values: { + counter, + disabled, + timer, + version, + }, + }, + { + signerUrl: url, + values: { + counter, + disabled, + timer, + version, + }, + }, + { + signerUrl: url, + values: { + counter, + disabled: true, + timer, + version, + }, + }, + ], + }, + WarningMessage.INCONSISTENT_SIGNER_DOMAIN_DISABLED_STATES, + ], + level: 'error', + }, + ], + }, + { + it: 'should log correctly when there is a discrepency in timer field', + responses: [ + { success: true, version, status: { counter, timer, disabled, now: Date.now() } }, + { success: true, version, status: { counter, timer, disabled, now: Date.now() } }, + { + success: true, + version, + status: { counter, timer: timer + 1, disabled, now: Date.now() }, + }, + ], + expectedLogs: [ + { + params: [ + { + parsedResponses: [ + { + signerUrl: url, + values: { + counter, + disabled, + timer, + version, + }, + }, + { + signerUrl: url, + values: { + counter, + disabled, + timer, + version, + }, + }, + { + signerUrl: url, + values: { + counter, + disabled, + timer: timer + 1, + version, + }, + }, + ], + }, + WarningMessage.SIGNER_RESPONSE_DISCREPANCIES, + ], + level: 'warn', + }, + ], + }, + ] + testCases.forEach((testCase) => { + it(testCase.it, () => { + const session = getSession(testCase.responses) + const logSpys = { + info: { + spy: jest.spyOn(session.logger, 'info'), + callCount: 0, + }, + debug: { + spy: jest.spyOn(session.logger, 'debug'), + callCount: 0, + }, + warn: { + spy: jest.spyOn(session.logger, 'warn'), + callCount: 0, + }, + error: { + spy: jest.spyOn(session.logger, 'error'), + callCount: 0, + }, + } + domainResponseLogger.logResponseDiscrepancies(session) + testCase.expectedLogs.forEach((log) => { + expect(logSpys[log.level].spy).toHaveBeenNthCalledWith( + ++logSpys[log.level].callCount, + ...log.params + ) + }) + Object.values(logSpys).forEach((level) => { + level.spy.mockClear() + level.spy.mockRestore() + }) + }) + }) +}) diff --git a/packages/phone-number-privacy/combiner/test/unit/domain-threshold-state.test.ts b/packages/phone-number-privacy/combiner/test/unit/domain-threshold-state.test.ts new file mode 100644 index 00000000000..ab0a688caf8 --- /dev/null +++ b/packages/phone-number-privacy/combiner/test/unit/domain-threshold-state.test.ts @@ -0,0 +1,175 @@ +import { + DomainQuotaStatusResponseSuccess, + DomainRestrictedSignatureResponseSuccess, + KeyVersionInfo, + SequentialDelayDomainState, +} from '@celo/phone-number-privacy-common' +import { getVersion } from '@celo/phone-number-privacy-signer/src/config' +import Logger from 'bunyan' +import { Request, Response } from 'express' +import { Session } from '../../src/common/session' +import config from '../../src/config' +import { DomainThresholdStateService } from '../../src/domain/services/threshold-state' + +describe('domain threshold state', () => { + // TODO add tests with failed signer responses, depending on + // result of https://github.com/celo-org/celo-monorepo/issues/9826 + + const keyVersionInfo: KeyVersionInfo = { + keyVersion: 1, + threshold: 3, + polynomial: 'mock polynomial', + pubKey: 'mock pubKey', + } + + const getSession = (domainStates: SequentialDelayDomainState[]) => { + const mockRequest = { + body: {}, + } as Request + const mockResponse = { + locals: { + logger: new Logger({ name: 'logger' }), + }, + } as Response + const session = new Session(mockRequest, mockResponse, keyVersionInfo) + domainStates.forEach((status) => { + const res: DomainRestrictedSignatureResponseSuccess | DomainQuotaStatusResponseSuccess = { + success: true, + version: expectedVersion, + status, + } + session.responses.push({ url: 'random url', res, status: 200 }) + }) + return session + } + + const domainConfig = config.domains + domainConfig.keys.currentVersion = keyVersionInfo.keyVersion + domainConfig.keys.versions = JSON.stringify([keyVersionInfo]) + domainConfig.odisServices.signers = JSON.stringify([ + { url: 'http://localhost:3001', fallbackUrl: 'http://localhost:3001/fallback' }, + { url: 'http://localhost:3002', fallbackUrl: 'http://localhost:3002/fallback' }, + { url: 'http://localhost:3003', fallbackUrl: 'http://localhost:3003/fallback' }, + { url: 'http://localhost:4004', fallbackUrl: 'http://localhost:4004/fallback' }, + ]) + + const domainThresholdStateService = new DomainThresholdStateService(domainConfig) + + const expectedVersion = getVersion() + const now = Date.now() + const timer = now - 1 + const counter = 2 + + const varyingDomainStates = [ + { + statuses: [ + { timer, counter: 2, disabled: false, now }, + { timer, counter: 2, disabled: false, now }, + { timer, counter: 2, disabled: false, now }, + { timer, counter: 2, disabled: false, now }, + ], + expectedCounter: 2, + expectedTimer: timer, + }, + { + statuses: [ + { timer, counter: 1, disabled: false, now }, + { timer, counter: 2, disabled: false, now }, + { timer, counter: 2, disabled: false, now }, + { timer, counter: 2, disabled: false, now }, + ], + expectedCounter: 2, + expectedTimer: timer, + }, + { + statuses: [ + { timer, counter: 0, disabled: false, now }, + { timer, counter: 1, disabled: false, now }, + { timer, counter: 2, disabled: false, now }, + { timer, counter: 3, disabled: false, now }, + ], + expectedCounter: 2, + expectedTimer: timer, + }, + { + statuses: [ + { timer, counter: 0, disabled: true, now }, + { timer, counter: 1, disabled: false, now }, + { timer, counter: 2, disabled: false, now }, + { timer, counter: 3, disabled: false, now }, + ], + expectedCounter: 3, + expectedTimer: timer, + }, + { + statuses: [ + { timer: timer - 1, counter, disabled: false, now }, + { timer, counter, disabled: false, now }, + { timer, counter, disabled: false, now }, + { timer, counter, disabled: false, now }, + ], + expectedCounter: counter, + expectedTimer: timer, + }, + { + statuses: [ + { timer: timer - 1, counter, disabled: false, now }, + { timer: timer - 1, counter, disabled: false, now }, + { timer: timer - 1, counter, disabled: false, now }, + { timer, counter, disabled: false, now }, + ], + expectedCounter: counter, + expectedTimer: timer - 1, + }, + { + statuses: [ + { timer: timer - 1, counter: 1, disabled: false, now }, + { timer, counter: 1, disabled: false, now }, + { timer, counter: 2, disabled: false, now }, + { timer, counter: 3, disabled: false, now }, + ], + expectedCounter: 2, + expectedTimer: timer, + }, + ] + + varyingDomainStates.forEach(({ statuses, expectedCounter, expectedTimer }) => { + it(`should return counter:${expectedCounter} and timer:${expectedTimer} given the domain states: ${statuses}`, () => { + const session = getSession(statuses) + const thresholdResult = domainThresholdStateService.findThresholdDomainState(session) + + expect(thresholdResult).toStrictEqual({ + timer: expectedTimer, + counter: expectedCounter, + disabled: false, + now, + }) + }) + }) + + it('should return 0 values when too many disabled responses', () => { + const statuses = [ + { timer, counter: 0, disabled: true, now }, + { timer, counter: 1, disabled: true, now }, + { timer, counter: 2, disabled: false, now }, + { timer, counter: 2, disabled: false, now }, + ] + const session = getSession(statuses) + const thresholdResult = domainThresholdStateService.findThresholdDomainState(session) + + expect(thresholdResult).toStrictEqual({ timer: 0, counter: 0, disabled: true, now: 0 }) + }) + + it('should throw an error if not enough signer responses', () => { + const statuses = [ + { timer, counter: 1, disabled: true, now }, + { timer, counter: 2, disabled: false, now }, + { timer, counter: 2, disabled: false, now }, + ] + const session = getSession(statuses) + + expect(() => domainThresholdStateService.findThresholdDomainState(session)).toThrow( + 'Insufficient number of signer responses. Domain may be disabled' + ) + }) +}) diff --git a/packages/phone-number-privacy/combiner/test/unit/pnp-response-logger.test.ts b/packages/phone-number-privacy/combiner/test/unit/pnp-response-logger.test.ts new file mode 100644 index 00000000000..f5c09b99733 --- /dev/null +++ b/packages/phone-number-privacy/combiner/test/unit/pnp-response-logger.test.ts @@ -0,0 +1,713 @@ +import { + ErrorMessage, + KeyVersionInfo, + OdisResponse, + PnpQuotaRequest, + rootLogger, + SignMessageRequest, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { getVersion } from '@celo/phone-number-privacy-signer/src/config' +import { Request, Response } from 'express' +import { Session } from '../../src/common/session' +import config, { + MAX_BLOCK_DISCREPANCY_THRESHOLD, + MAX_QUERY_COUNT_DISCREPANCY_THRESHOLD, + MAX_TOTAL_QUOTA_DISCREPANCY_THRESHOLD, +} from '../../src/config' +import { PnpSignerResponseLogger } from '../../src/pnp/services/log-responses' + +describe('pnp response logger', () => { + const url = 'test signer url' + + const keyVersionInfo: KeyVersionInfo = { + keyVersion: 1, + threshold: 3, + polynomial: 'mock polynomial', + pubKey: 'mock pubKey', + } + + const getSession = (responses: OdisResponse[]) => { + const mockRequest = { + body: {}, + } as Request + const mockResponse = { + locals: { + logger: rootLogger(config.serviceName), + }, + } as Response + const session = new Session( + mockRequest, + mockResponse, + keyVersionInfo + ) + responses.forEach((res) => { + session.responses.push({ url, res, status: 200 }) + }) + return session + } + + const pnpConfig = config.phoneNumberPrivacy + pnpConfig.keys.currentVersion = keyVersionInfo.keyVersion + pnpConfig.keys.versions = JSON.stringify([keyVersionInfo]) + const pnpResponseLogger = new PnpSignerResponseLogger() + + const version = getVersion() + const blockNumber = 1000000 + const totalQuota = 10 + const performedQueryCount = 5 + const warnings = ['warning'] + + const testCases: { + it: string + responses: OdisResponse[] + expectedLogs: { + params: string | any[] + level: 'info' | 'debug' | 'warn' | 'error' + }[] + }[] = [ + { + it: 'should log correctly when no responses provided', + responses: [], + expectedLogs: [ + { + params: ['No successful signer responses found!'], + level: 'warn', + }, + ], + }, + { + it: 'should log correctly when all the responses are the same', + responses: [ + { success: true, performedQueryCount, totalQuota, version, blockNumber, warnings }, + { success: true, performedQueryCount, totalQuota, version, blockNumber, warnings }, + { success: true, performedQueryCount, totalQuota, version, blockNumber, warnings }, + ], + expectedLogs: [], + }, + { + it: 'should log correctly when there is a discrepency in version field', + responses: [ + { + success: true, + performedQueryCount, + totalQuota, + version: 'differentVersion', + blockNumber, + warnings, + }, + { success: true, performedQueryCount, totalQuota, version, blockNumber, warnings }, + { success: true, performedQueryCount, totalQuota, version, blockNumber, warnings }, + ], + expectedLogs: [ + { + params: [ + { + parsedResponses: [ + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version: 'differentVersion', + warnings, + }, + }, + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings, + }, + }, + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings, + }, + }, + ], + }, + WarningMessage.SIGNER_RESPONSE_DISCREPANCIES, + ], + level: 'warn', + }, + ], + }, + { + it: 'should log correctly when there is a discrepency in performedQueryCount field', + responses: [ + { success: true, performedQueryCount: 1, totalQuota, version, blockNumber, warnings }, + { success: true, performedQueryCount, totalQuota, version, blockNumber, warnings }, + { success: true, performedQueryCount, totalQuota, version, blockNumber, warnings }, + ], + expectedLogs: [ + { + params: [ + { + parsedResponses: [ + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount: 1, + totalQuota, + version, + warnings, + }, + }, + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings, + }, + }, + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings, + }, + }, + ], + }, + WarningMessage.SIGNER_RESPONSE_DISCREPANCIES, + ], + level: 'warn', + }, + ], + }, + { + it: 'should log correctly when there is a large discrepency in performedQueryCount field', + responses: [ + { + success: true, + performedQueryCount: performedQueryCount + MAX_QUERY_COUNT_DISCREPANCY_THRESHOLD, + totalQuota, + version, + blockNumber, + warnings, + }, + { success: true, performedQueryCount, totalQuota, version, blockNumber, warnings }, + { success: true, performedQueryCount, totalQuota, version, blockNumber, warnings }, + ], + expectedLogs: [ + { + params: [ + { + parsedResponses: [ + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings, + }, + }, + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings, + }, + }, + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount: + performedQueryCount + MAX_QUERY_COUNT_DISCREPANCY_THRESHOLD, + totalQuota, + version, + warnings, + }, + }, + ], + }, + WarningMessage.SIGNER_RESPONSE_DISCREPANCIES, + ], + level: 'warn', + }, + { + params: [ + { + sortedByQueryCount: [ + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings, + }, + }, + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings, + }, + }, + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount: + performedQueryCount + MAX_QUERY_COUNT_DISCREPANCY_THRESHOLD, + totalQuota, + version, + warnings, + }, + }, + ], + }, + WarningMessage.INCONSISTENT_SIGNER_QUERY_MEASUREMENTS, + ], + level: 'error', + }, + ], + }, + { + it: 'should log correctly when there is a discrepency in totalQuota field', + responses: [ + { success: true, performedQueryCount, totalQuota, version, blockNumber, warnings }, + { success: true, performedQueryCount, totalQuota, version, blockNumber, warnings }, + { success: true, performedQueryCount, totalQuota: 1, version, blockNumber, warnings }, + ], + expectedLogs: [ + { + params: [ + { + parsedResponses: [ + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota: 1, + version, + warnings, + }, + }, + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings, + }, + }, + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings, + }, + }, + ], + }, + WarningMessage.SIGNER_RESPONSE_DISCREPANCIES, + ], + level: 'warn', + }, + ], + }, + { + it: 'should log correctly when there is a large discrepency in totalQuota field', + responses: [ + { success: true, performedQueryCount, totalQuota, version, blockNumber, warnings }, + { success: true, performedQueryCount, totalQuota, version, blockNumber, warnings }, + { + success: true, + performedQueryCount, + totalQuota: totalQuota + MAX_TOTAL_QUOTA_DISCREPANCY_THRESHOLD, + version, + blockNumber, + warnings, + }, + ], + expectedLogs: [ + { + params: [ + { + sortedByTotalQuota: [ + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings, + }, + }, + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings, + }, + }, + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota: totalQuota + MAX_TOTAL_QUOTA_DISCREPANCY_THRESHOLD, + version, + warnings, + }, + }, + ], + }, + WarningMessage.INCONSISTENT_SIGNER_QUOTA_MEASUREMENTS, + ], + level: 'error', + }, + ], + }, + { + it: 'should log correctly when one signer returns an undefined blockNumber', + responses: [ + { success: true, performedQueryCount, totalQuota, version, blockNumber, warnings }, + { success: true, performedQueryCount, totalQuota, version, blockNumber, warnings }, + { + success: true, + performedQueryCount, + totalQuota, + version, + blockNumber: undefined, + warnings, + }, + ], + expectedLogs: [ + { + params: [ + { + parsedResponses: [ + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings, + }, + }, + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings, + }, + }, + { + signerUrl: url, + values: { + blockNumber: undefined, + performedQueryCount, + totalQuota, + version, + warnings, + }, + }, + ], + }, + WarningMessage.SIGNER_RESPONSE_DISCREPANCIES, + ], + level: 'warn', + }, + { + params: [{ signerUrl: url }, 'Signer responded with undefined blockNumber'], + level: 'warn', + }, + ], + }, + { + it: 'should log correctly when there is a large discrepency in blockNumber field', + responses: [ + { success: true, performedQueryCount, totalQuota, version, blockNumber, warnings }, + { success: true, performedQueryCount, totalQuota, version, blockNumber, warnings }, + { + success: true, + performedQueryCount, + totalQuota, + version, + blockNumber: blockNumber + MAX_BLOCK_DISCREPANCY_THRESHOLD, + warnings, + }, + ], + expectedLogs: [ + { + params: [ + { + sortedByBlockNumber: [ + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings, + }, + }, + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings, + }, + }, + { + signerUrl: url, + values: { + blockNumber: blockNumber + MAX_BLOCK_DISCREPANCY_THRESHOLD, + performedQueryCount, + totalQuota, + version, + warnings, + }, + }, + ], + }, + WarningMessage.INCONSISTENT_SIGNER_BLOCK_NUMBERS, + ], + level: 'error', + }, + ], + }, + { + it: 'should log correctly when there is a discrepency in warnings field', + responses: [ + { success: true, performedQueryCount, totalQuota, version, blockNumber, warnings }, + { success: true, performedQueryCount, totalQuota, version, blockNumber, warnings }, + { + success: true, + performedQueryCount, + totalQuota, + version, + blockNumber, + warnings: ['differentWarning'], + }, + ], + expectedLogs: [ + { + params: [ + { + parsedResponses: [ + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings, + }, + }, + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings, + }, + }, + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings: ['differentWarning'], + }, + }, + ], + }, + WarningMessage.SIGNER_RESPONSE_DISCREPANCIES, + ], + level: 'warn', + }, + ], + }, + { + it: 'should log correctly when signers respond with fail-open warnings', + responses: [ + { + success: true, + performedQueryCount, + totalQuota, + version, + blockNumber, + warnings: [ErrorMessage.FAILING_OPEN], + }, + { + success: true, + performedQueryCount, + totalQuota, + version, + blockNumber, + warnings: [ErrorMessage.FAILURE_TO_GET_TOTAL_QUOTA], + }, + { + success: true, + performedQueryCount, + totalQuota, + version, + blockNumber, + warnings: [ErrorMessage.FAILURE_TO_GET_DEK], + }, + ], + expectedLogs: [ + { + params: [ + { + parsedResponses: [ + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings: [ErrorMessage.FAILING_OPEN], + }, + }, + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings: [ErrorMessage.FAILURE_TO_GET_TOTAL_QUOTA], + }, + }, + { + signerUrl: url, + values: { + blockNumber, + performedQueryCount, + totalQuota, + version, + warnings: [ErrorMessage.FAILURE_TO_GET_DEK], + }, + }, + ], + }, + WarningMessage.SIGNER_RESPONSE_DISCREPANCIES, + ], + level: 'warn', + }, + { + params: [ + { + warning: ErrorMessage.FAILING_OPEN, + service: url, + }, + ErrorMessage.FAILING_OPEN, + ], + level: 'error', + }, + { + params: [ + { + warning: ErrorMessage.FAILURE_TO_GET_TOTAL_QUOTA, + service: url, + }, + ErrorMessage.FAILING_OPEN, + ], + level: 'error', + }, + { + params: [ + { + warning: ErrorMessage.FAILURE_TO_GET_DEK, + service: url, + }, + ErrorMessage.FAILING_OPEN, + ], + level: 'error', + }, + ], + }, + ] + testCases.forEach((testCase) => { + it(testCase.it, () => { + const session = getSession(testCase.responses) + const logSpys = { + info: { + spy: jest.spyOn(session.logger, 'info'), + callCount: 0, + }, + debug: { + spy: jest.spyOn(session.logger, 'debug'), + callCount: 0, + }, + warn: { + spy: jest.spyOn(session.logger, 'warn'), + callCount: 0, + }, + error: { + spy: jest.spyOn(session.logger, 'error'), + callCount: 0, + }, + } + pnpResponseLogger.logResponseDiscrepancies(session) + pnpResponseLogger.logFailOpenResponses(session) + testCase.expectedLogs.forEach((log) => { + expect(logSpys[log.level].spy).toHaveBeenNthCalledWith( + ++logSpys[log.level].callCount, + ...log.params + ) + }) + Object.values(logSpys).forEach((level) => { + level.spy.mockClear() + level.spy.mockRestore() + }) + }) + }) +}) diff --git a/packages/phone-number-privacy/combiner/test/unit/pnp-threshold-state.test.ts b/packages/phone-number-privacy/combiner/test/unit/pnp-threshold-state.test.ts new file mode 100644 index 00000000000..c9849aa5a79 --- /dev/null +++ b/packages/phone-number-privacy/combiner/test/unit/pnp-threshold-state.test.ts @@ -0,0 +1,225 @@ +import { + KeyVersionInfo, + PnpQuotaRequest, + PnpQuotaResponseSuccess, + rootLogger, + SignMessageRequest, + SignMessageResponseSuccess, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { getVersion } from '@celo/phone-number-privacy-signer/src/config' +import { Request, Response } from 'express' +import { Session } from '../../src/common/session' +import config from '../../src/config' +import { PnpThresholdStateService } from '../../src/pnp/services/threshold-state' + +describe('pnp threshold state', () => { + // TODO add tests with failed signer responses, depending on + // result of https://github.com/celo-org/celo-monorepo/issues/9826 + + const keyVersionInfo: KeyVersionInfo = { + keyVersion: 1, + threshold: 3, + polynomial: 'mock polynomial', + pubKey: 'mock pubKey', + } + + const getSession = (quotaData: { totalQuota: number; performedQueryCount: number }[]) => { + const mockRequest = { + body: {}, + } as Request + const mockResponse = { + locals: { + logger: rootLogger, + }, + } as Response + const session = new Session( + mockRequest, + mockResponse, + keyVersionInfo + ) + quotaData.forEach((q) => { + const res: PnpQuotaResponseSuccess | SignMessageResponseSuccess = { + success: true, + version: expectedVersion, + ...q, + blockNumber: testBlockNumber, + } + session.responses.push({ url: 'random url', res, status: 200 }) + }) + return session + } + + const pnpConfig = config.phoneNumberPrivacy + pnpConfig.keys.currentVersion = keyVersionInfo.keyVersion + pnpConfig.keys.versions = JSON.stringify([keyVersionInfo]) + const pnpThresholdStateService = new PnpThresholdStateService() + + const expectedVersion = getVersion() + const testBlockNumber = 1000000 + const totalQuota = 10 + const performedQueryCount = 5 + + const varyingQueryCount = [ + { + signerRes: [ + { performedQueryCount: 0, totalQuota }, + { performedQueryCount: 0, totalQuota }, + { performedQueryCount: 0, totalQuota }, + { performedQueryCount: 0, totalQuota }, + ], + expectedQueryCount: 0, + }, + { + signerRes: [ + { performedQueryCount: 0, totalQuota }, + { performedQueryCount: 0, totalQuota }, + { performedQueryCount: 0, totalQuota }, + { performedQueryCount: 1, totalQuota }, + ], + expectedQueryCount: 0, + }, // does not reach threshold + { + signerRes: [ + { performedQueryCount: 1, totalQuota }, + { performedQueryCount: 1, totalQuota }, + { performedQueryCount: 0, totalQuota }, + { performedQueryCount: 1, totalQuota }, + ], + expectedQueryCount: 1, + }, // threshold reached + { + signerRes: [ + { performedQueryCount: 0, totalQuota }, + { performedQueryCount: 1, totalQuota }, + { performedQueryCount: 1, totalQuota }, + { performedQueryCount: 1, totalQuota }, + ], + expectedQueryCount: 1, + }, // order of signers shouldn't matter + { + signerRes: [ + { performedQueryCount: 1, totalQuota }, + { performedQueryCount: 4, totalQuota }, + { performedQueryCount: 9, totalQuota }, + { performedQueryCount: 11, totalQuota }, + ], + expectedQueryCount: 9, + }, + ] + varyingQueryCount.forEach(({ signerRes, expectedQueryCount }) => { + it(`should return ${expectedQueryCount} performedQueryCount given signer responses of ${signerRes}`, () => { + const session = getSession(signerRes) + const thresholdResult = pnpThresholdStateService.findCombinerQuotaState(session) + expect(thresholdResult).toStrictEqual({ + performedQueryCount: expectedQueryCount, + totalQuota, + blockNumber: testBlockNumber, + }) + }) + }) + + const varyingTotalQuota = [ + { + signerRes: [ + { performedQueryCount, totalQuota }, + { performedQueryCount, totalQuota }, + { performedQueryCount, totalQuota }, + { performedQueryCount, totalQuota }, + ], + expectedTotalQuota: totalQuota, + warning: false, + }, + { + signerRes: [ + { performedQueryCount, totalQuota: 7 }, + { performedQueryCount, totalQuota: 8 }, + { performedQueryCount, totalQuota: 9 }, + { performedQueryCount, totalQuota: 10 }, + ], + expectedTotalQuota: 8, + warning: true, + }, + { + signerRes: [ + { performedQueryCount, totalQuota: 8 }, + { performedQueryCount, totalQuota: 9 }, + { performedQueryCount, totalQuota: 10 }, + { performedQueryCount, totalQuota: 7 }, + ], + expectedTotalQuota: 8, + warning: true, + }, + ] + varyingTotalQuota.forEach(({ signerRes, expectedTotalQuota, warning }) => { + it(`should return ${expectedTotalQuota} totalQuota given signer responses of ${signerRes}`, () => { + const session = getSession(signerRes) + const thresholdResult = pnpThresholdStateService.findCombinerQuotaState(session) + expect(thresholdResult).toStrictEqual({ + performedQueryCount, + totalQuota: expectedTotalQuota, + blockNumber: testBlockNumber, + }) + if (warning) { + expect(session.warnings).toContain( + WarningMessage.INCONSISTENT_SIGNER_QUOTA_MEASUREMENTS + + ', using threshold signer as best guess' + ) + } + }) + }) + + const varyingQuotaAndQuery = [ + { + signerRes: [ + { performedQueryCount: 1, totalQuota: 10 }, + { performedQueryCount: 2, totalQuota: 9 }, + { performedQueryCount: 3, totalQuota: 8 }, + { performedQueryCount: 4, totalQuota: 7 }, + ], + expectedQueryCount: 3, + expectedTotalQuota: 8, + warning: true, + }, + { + signerRes: [ + { performedQueryCount: 1, totalQuota: 7 }, + { performedQueryCount: 2, totalQuota: 8 }, + { performedQueryCount: 5, totalQuota: 9 }, + { performedQueryCount: 6, totalQuota: 10 }, + ], + expectedQueryCount: 5, + expectedTotalQuota: 9, + warning: true, + }, + ] + varyingQuotaAndQuery.forEach(({ signerRes, expectedQueryCount, expectedTotalQuota, warning }) => { + it(`should return ${expectedTotalQuota} totalQuota and ${expectedQueryCount} performedQueryCount given signer responses of ${signerRes}`, () => { + const session = getSession(signerRes) + const thresholdResult = pnpThresholdStateService.findCombinerQuotaState(session) + expect(thresholdResult).toStrictEqual({ + performedQueryCount: expectedQueryCount, + totalQuota: expectedTotalQuota, + blockNumber: testBlockNumber, + }) + if (warning) { + expect(session.warnings).toContain( + WarningMessage.INCONSISTENT_SIGNER_QUOTA_MEASUREMENTS + + ', using threshold signer as best guess' + ) + } + }) + }) + + it('should throw an error if the total quota varies too much between signers', () => { + const session = getSession([ + { performedQueryCount, totalQuota: 1 }, + { performedQueryCount, totalQuota: 9 }, + { performedQueryCount, totalQuota: 15 }, + { performedQueryCount, totalQuota: 14 }, + ]) + expect(() => pnpThresholdStateService.findCombinerQuotaState(session)).toThrow( + WarningMessage.INCONSISTENT_SIGNER_QUOTA_MEASUREMENTS + ) + }) +}) diff --git a/packages/phone-number-privacy/combiner/tsconfig.json b/packages/phone-number-privacy/combiner/tsconfig.json index 5a050f2a779..c9ee1249ba9 100644 --- a/packages/phone-number-privacy/combiner/tsconfig.json +++ b/packages/phone-number-privacy/combiner/tsconfig.json @@ -22,6 +22,6 @@ "preserveConstEnums": true, "composite": true }, - "include": ["src", "index.d.ts"], + "include": ["src"], "compileOnSave": true } diff --git a/packages/phone-number-privacy/common/jest.config.js b/packages/phone-number-privacy/common/jest.config.js index 650c513d7e6..ac1faeacbb9 100644 --- a/packages/phone-number-privacy/common/jest.config.js +++ b/packages/phone-number-privacy/common/jest.config.js @@ -1,3 +1,10 @@ module.exports = { preset: 'ts-jest', + coverageReporters: [['lcov', { projectRoot: '../../../' }], 'text'], + collectCoverageFrom: ['./src/**'], + coverageThreshold: { + global: { + lines: 80, + }, + }, } diff --git a/packages/phone-number-privacy/common/package.json b/packages/phone-number-privacy/common/package.json index f436a28271e..48dc3ccffc1 100644 --- a/packages/phone-number-privacy/common/package.json +++ b/packages/phone-number-privacy/common/package.json @@ -1,6 +1,6 @@ { "name": "@celo/phone-number-privacy-common", - "version": "1.0.39", + "version": "1.0.42-dev", "description": "Common library for the combiner and signer libraries", "author": "Celo", "license": "Apache-2.0", @@ -11,7 +11,7 @@ "build": "tsc -b .", "clean": "tsc -b . --clean", "test": "jest --testPathIgnorePatterns test/end-to-end", - "test:coverage": "jest --testPathIgnorePatterns test/end-to-end --coverage", + "test:coverage": "yarn test --coverage", "lint": "tslint -c tslint.json --project ." }, "files": [ @@ -33,7 +33,7 @@ "is-base64": "^1.1.0" }, "devDependencies": { - "@celo/poprf": "^0.1.6", + "@celo/poprf": "^0.1.9", "@celo/wallet-local": "2.3.1-dev", "@types/btoa": "^1.2.3", "@types/bunyan": "1.8.4", @@ -45,4 +45,4 @@ "engines": { "node": ">=10" } -} \ No newline at end of file +} diff --git a/packages/phone-number-privacy/common/src/domains/sequential-delay.ts b/packages/phone-number-privacy/common/src/domains/sequential-delay.ts index 833e8edb4ca..91de2c00644 100644 --- a/packages/phone-number-privacy/common/src/domains/sequential-delay.ts +++ b/packages/phone-number-privacy/common/src/domains/sequential-delay.ts @@ -69,12 +69,24 @@ export type SequentialDelayDomainOptions = { } export interface SequentialDelayDomainState { - /** Timestamp in seconds since the Unix Epoch determining when a new request will be accepted. */ + /** + * Timestamp in seconds since the Unix Epoch to which the next delay should be applied + * to calculate when a new request will be accepted. + */ timer: number /** Number of queries that have been accepted for the SequentialDelayDomain instance. */ counter: number - /** Whether or not the domain has been disabled. If disabled, no more queries will be served */ + /** Whether or not the domain has been disabled. If disabled, no more queries will be served. */ disabled: boolean + /** Server timestamp in seconds since the Unix Epoch. */ + now: number +} + +export const INITIAL_SEQUENTIAL_DELAY_DOMAIN_STATE: SequentialDelayDomainState = { + timer: 0, + counter: 0, + disabled: false, + now: 0, } /** io-ts schema for encoding and decoding SequentialDelayStage structs */ @@ -105,6 +117,7 @@ export const SequentialDelayDomainStateSchema: t.Type @@ -146,18 +159,27 @@ export const sequentialDelayDomainOptionsEIP712Types: EIP712TypesWithPrimary = { } /** Result values of the sequential delay domain rate limiting function */ -export interface SequentialDelayResult { +export interface SequentialDelayResultAccepted { /** Whether or not a request will be accepted at the given time */ - accepted: boolean + accepted: true + /** State after applying an additional query against the quota */ + state: SequentialDelayDomainState +} + +export interface SequentialDelayResultRejected { + /** Whether or not a request will be accepted at the given time */ + accepted: false + /** State after rejecting the request. Should be unchanged. */ + state: SequentialDelayDomainState /** * Earliest time a request will be accepted at the current stage. - * Provided on rejected requests. Undefined if a request will never be accepted. + * Undefined if a request will never be accepted. */ notBefore?: number - /** State after applying adding a query to the quota. Unchnaged is accepted is false */ - state: SequentialDelayDomainState | undefined } +export type SequentialDelayResult = SequentialDelayResultAccepted | SequentialDelayResultRejected + interface IndexedSequentialDelayStage extends SequentialDelayStage { // The attempt number at which the stage begins start: number @@ -166,59 +188,69 @@ interface IndexedSequentialDelayStage extends SequentialDelayStage { /** * Rate limiting predicate for the sequential delay domain * - * @param domain SequentialDelayDomain instance against which the rate limit is being calculated. - * The domain instance supplied the rate limiting parameters. + * @param domain SequentialDelayDomain instance against which the rate limit is being calculated, + * and which supplied the rate limiting parameters. * @param attemptTime The Unix timestamp in seconds when the request was received. - * @param state The current state of the domain, endoing the used quota and timeer value. + * @param state The current state of the domain, including the used quota counter and timer values. + * Defaults to initial state if no state is available (i.e. for first request against the domain). */ export const checkSequentialDelayRateLimit = ( domain: SequentialDelayDomain, attemptTime: number, - state?: SequentialDelayDomainState + state: SequentialDelayDomainState = INITIAL_SEQUENTIAL_DELAY_DOMAIN_STATE ): SequentialDelayResult => { // If the domain has been disabled, all queries are to be rejected. - if (state?.disabled ?? false) { - return { accepted: false, state } + if (state.disabled) { + return { accepted: false, state: { ...state, now: attemptTime } } } - // If no state is available (i.e. this is the first request against the domain) use the initial state. - const counter = state?.counter ?? 0 - const timer = state?.timer ?? 0 - const stage = getIndexedStage(domain, counter) + const stage = getIndexedStage(domain, state.counter) // If the counter is past the last stage (i.e. the domain is permanently out of quota) return early. if (!stage) { - return { accepted: false, state } + return { accepted: false, state: { ...state, now: attemptTime } } } const resetTimer = stage.resetTimer.defined ? stage.resetTimer.value : true - const delay = getDelay(stage, counter) - const notBefore = timer + delay + const delay = getDelay(stage, state.counter) + const notBefore = state.timer + delay if (attemptTime < notBefore) { - return { accepted: false, notBefore, state } + return { accepted: false, notBefore, state: { ...state, now: attemptTime } } } // Request is accepted. Update the state. return { accepted: true, state: { - counter: counter + 1, + counter: state.counter + 1, timer: resetTimer ? attemptTime : notBefore, - disabled: state?.disabled ?? false, + disabled: state.disabled, + now: attemptTime, }, } } +/** + * Finds the current stage of the SequentialDelayDomain rate limit for a given attempt number + * + * @param domain SequentialDelayDomain instance against which the rate limit is being calculated, + * and which supplied the rate limiting parameters. + * @param counter The current attempt number + */ const getIndexedStage = ( domain: SequentialDelayDomain, counter: number ): IndexedSequentialDelayStage | undefined => { - let attemptsInStage = 0 - let index = 0 + // The attempt index marking the beginning of the current stage let start = 0 + // The index of the current stage in domain.stages[] + let index = 0 + // The number of attempts in the current stage + let attemptsInStage = 0 while (start <= counter) { if (index >= domain.stages.length) { + // Counter is past the last stage (i.e. the domain is permanently out of quota) return undefined } const stage = domain.stages[index] @@ -235,6 +267,14 @@ const getIndexedStage = ( return { ...domain.stages[index], start } } +/** + * Finds the delay to enforce for an attempt given its counter (attempt number) and + * the corresponding stage in the SequentialDelayDomain rate limit. + * + * @param stage IndexedSequentialDelayStage The given stage of the SequentialDelayDomain rate limit, + * extended to include the index of the first attempt in that stage. + * @param counter The current attempt number + */ const getDelay = (stage: IndexedSequentialDelayStage, counter: number): number => { const batchSize = stage.batchSize.defined ? stage.batchSize.value : 1 if ((counter - stage.start) % batchSize === 0) { diff --git a/packages/phone-number-privacy/common/src/index.ts b/packages/phone-number-privacy/common/src/index.ts index 1e6fc023a6c..8e216f2d5cf 100644 --- a/packages/phone-number-privacy/common/src/index.ts +++ b/packages/phone-number-privacy/common/src/index.ts @@ -1,6 +1,6 @@ export * from './domains' export * from './interfaces' -export { ErrorMessage, WarningMessage } from './interfaces/error-utils' +export { ErrorMessage, WarningMessage } from './interfaces/errors' export { PoprfClient, PoprfCombiner, @@ -8,14 +8,12 @@ export { ThresholdPoprfClient, ThresholdPoprfServer, } from './poprf' -export { - SignMessageResponse, - SignMessageResponseFailure, - SignMessageResponseSuccess, -} from './interfaces/responses' export { TestUtils } from './test/index' export * from './utils/authentication' -export { fetchEnv, fetchEnvOrDefault, toBool, toNum } from './utils/config-utils' +export { fetchEnv, fetchEnvOrDefault, toBool, toNum } from './utils/config.utils' export * from './utils/constants' +export { BlockchainConfig, getContractKit } from './utils/contracts' export * from './utils/input-validation' +export * from './utils/key-version' export { genSessionID, loggerMiddleware, rootLogger } from './utils/logger' +export * from './utils/responses.utils' diff --git a/packages/phone-number-privacy/common/src/interfaces/endpoints.ts b/packages/phone-number-privacy/common/src/interfaces/endpoints.ts new file mode 100644 index 00000000000..d7c7d998e0d --- /dev/null +++ b/packages/phone-number-privacy/common/src/interfaces/endpoints.ts @@ -0,0 +1,79 @@ +export enum SignerEndpointCommon { + METRICS = '/metrics', + STATUS = '/status', +} + +export enum SignerEndpointPNP { + LEGACY_PNP_SIGN = '/getBlindedMessagePartialSig', + LEGACY_PNP_QUOTA = '/getQuota', + PNP_QUOTA = '/quotaStatus', + PNP_SIGN = '/sign', +} + +export enum CombinerEndpointCommon { + STATUS = '/status', +} + +export enum CombinerEndpointPNP { + LEGACY_PNP_SIGN = '/getBlindedMessageSig', + PNP_QUOTA = '/quotaStatus', + PNP_SIGN = '/sign', + STATUS = '/status', +} + +export enum DomainEndpoint { + DOMAIN_SIGN = '/domain/sign', + DISABLE_DOMAIN = '/domain/disable', + DOMAIN_QUOTA_STATUS = '/domain/quotaStatus', +} + +export type SignerEndpoint = SignerEndpointCommon | SignerEndpointPNP | DomainEndpoint +export const SignerEndpoint = { ...SignerEndpointCommon, ...SignerEndpointPNP, ...DomainEndpoint } + +export type CombinerEndpoint = CombinerEndpointCommon | CombinerEndpointPNP | DomainEndpoint +export const CombinerEndpoint = { + ...CombinerEndpointCommon, + ...CombinerEndpointPNP, + ...DomainEndpoint, +} + +export type Endpoint = SignerEndpoint | CombinerEndpoint +export const Endpoint = { ...SignerEndpoint, ...CombinerEndpoint } + +export function getSignerEndpoint(endpoint: CombinerEndpoint): SignerEndpoint { + switch (endpoint) { + case CombinerEndpoint.DISABLE_DOMAIN: + return SignerEndpoint.DISABLE_DOMAIN + case CombinerEndpoint.DOMAIN_QUOTA_STATUS: + return SignerEndpoint.DOMAIN_QUOTA_STATUS + case CombinerEndpoint.DOMAIN_SIGN: + return SignerEndpoint.DOMAIN_SIGN + case CombinerEndpoint.PNP_QUOTA: + return SignerEndpoint.PNP_QUOTA + case CombinerEndpoint.PNP_SIGN: + return SignerEndpoint.PNP_SIGN + case CombinerEndpoint.LEGACY_PNP_SIGN: + return SignerEndpoint.LEGACY_PNP_SIGN + default: + throw new Error(`No corresponding signer endpoint exists for combiner endpoint ${endpoint}`) + } +} + +export function getCombinerEndpoint(endpoint: SignerEndpoint): CombinerEndpoint { + switch (endpoint) { + case SignerEndpoint.DISABLE_DOMAIN: + return CombinerEndpoint.DISABLE_DOMAIN + case SignerEndpoint.DOMAIN_QUOTA_STATUS: + return CombinerEndpoint.DOMAIN_QUOTA_STATUS + case SignerEndpoint.DOMAIN_SIGN: + return CombinerEndpoint.DOMAIN_SIGN + case SignerEndpoint.PNP_QUOTA: + return CombinerEndpoint.PNP_QUOTA + case SignerEndpoint.PNP_SIGN: + return CombinerEndpoint.PNP_SIGN + case SignerEndpoint.LEGACY_PNP_SIGN: + return CombinerEndpoint.LEGACY_PNP_SIGN + default: + throw new Error(`No corresponding combiner endpoint exists for signer endpoint ${endpoint}`) + } +} diff --git a/packages/phone-number-privacy/common/src/interfaces/error-utils.ts b/packages/phone-number-privacy/common/src/interfaces/error-utils.ts deleted file mode 100644 index 22e75c2af5b..00000000000 --- a/packages/phone-number-privacy/common/src/interfaces/error-utils.ts +++ /dev/null @@ -1,34 +0,0 @@ -export enum ErrorMessage { - UNKNOWN_ERROR = `CELO_ODIS_ERR_00 Something went wrong`, - DATABASE_UPDATE_FAILURE = `CELO_ODIS_ERR_01 DB_ERR Failed to update database entry`, - DATABASE_INSERT_FAILURE = `CELO_ODIS_ERR_02 DB_ERR Failed to insert database entry`, - DATABASE_GET_FAILURE = `CELO_ODIS_ERR_03 DB_ERR Failed to get database entry`, - KEY_FETCH_ERROR = `CELO_ODIS_ERR_04 INIT_ERR Failed to retrieve key from keystore`, - SIGNATURE_COMPUTATION_FAILURE = `CELO_ODIS_ERR_05 SIG_ERR Failed to compute BLS signature`, - VERIFY_PARITAL_SIGNATURE_ERROR = `CELO_ODIS_ERR_06 SIG_ERR BLS partial signature verification Failure`, - NOT_ENOUGH_PARTIAL_SIGNATURES = `CELO_ODIS_ERR_07 SIG_ERR Not enough partial signatures`, - INCONSISTENT_SIGNER_RESPONSES = `CELO_ODIS_ERR_08 SIG_ERR Inconsistent responses from signers`, - ERROR_REQUESTING_SIGNATURE = `CELO_ODIS_ERR_09 SIG_ERR Failed to request signature from signer`, - TIMEOUT_FROM_SIGNER = `CELO_ODIS_ERR_10 SIG_ERR Timeout from signer`, - CONTRACT_GET_FAILURE = `CELO_ODIS_ERR_11 SIG_ERR Failed to read contract state`, - FAILURE_TO_STORE_REQUEST = `CELO_ODIS_ERR_12 DB_ERR Failed to store partial sig request`, - FAILURE_TO_INCREMENT_QUERY_COUNT = `CELO_ODIS_ERR_13 DB_ERR Failed to increment user query count`, - DOMAIN_ALREADY_DISABLED_FAILURE = `CELO_ODIS_ERR_14 DB_ERR Domain is already disabled`, - UNSUPPORTED_DOMAIN = `CELO_ODIS_ERR_15 SIG_ERR Domain type is not supported`, -} - -export enum WarningMessage { - INVALID_INPUT = `CELO_ODIS_WARN_01 BAD_INPUT Invalid input parameters`, - UNAUTHENTICATED_USER = `CELO_ODIS_WARN_02 BAD_INPUT Missing or invalid authentication header`, - EXCEEDED_QUOTA = `CELO_ODIS_WARN_03 QUOTA Requester exceeded service query quota`, - UNVERIFIED_USER_ATTEMPT_TO_MATCHMAKE = `CELO_ODIS_WARN_04 QUOTA Unverified user attempting to matchmake`, - DUPLICATE_REQUEST_TO_MATCHMAKE = `CELO_ODIS_WARN_05 QUOTA Attempt to request >1 matchmaking`, - DUPLICATE_REQUEST_TO_GET_PARTIAL_SIG = `CELO_ODIS_WARN_06 BAD_INPUT Attempt to replay partial signature request`, - INCONSISTENT_SIGNER_BLOCK_NUMBERS = `CELO_ODIS_WARN_07 SIGNER Discrepancy found in signers' latest block number that exceeds threshold`, - INCONSISTENT_SIGNER_QUOTA_MEASUREMENTS = `CELO_ODIS_WARN_08 SIGNER Discrepancy found in signers' quota measurements`, - MISSING_SESSION_ID = `CELO_ODIS_WARN_09 BAD_INPUT Client did not provide sessionID in request`, - CANCELLED_REQUEST_TO_SIGNER = 'CELO_ODIS_WARN_09 SIGNER Cancelled request to signer', - INVALID_USER_PHONE_NUMBER_SIGNATURE = 'CELO_ODIS_WARN_10 BAD_INPUT User phone number signature is invalid', - UNKNOWN_DOMAIN = 'CELO_ODIS_WARN_11 BAD_INPUT Provided domain name and version is not recognized', - INVALID_AUTH_SIGNATURE = 'CELO_ODIS_WARN_12 BAD_INPUT Authorization signature was incorrectly generated. Request will be rejected in a future version.', -} diff --git a/packages/phone-number-privacy/common/src/interfaces/errors.ts b/packages/phone-number-privacy/common/src/interfaces/errors.ts new file mode 100644 index 00000000000..616921795eb --- /dev/null +++ b/packages/phone-number-privacy/common/src/interfaces/errors.ts @@ -0,0 +1,58 @@ +export enum ErrorMessage { + UNKNOWN_ERROR = `CELO_ODIS_ERR_00 Something went wrong`, + DATABASE_UPDATE_FAILURE = `CELO_ODIS_ERR_01 DB_ERR Failed to update database entry`, + DATABASE_INSERT_FAILURE = `CELO_ODIS_ERR_02 DB_ERR Failed to insert database entry`, + DATABASE_GET_FAILURE = `CELO_ODIS_ERR_03 DB_ERR Failed to get database entry`, + KEY_FETCH_ERROR = `CELO_ODIS_ERR_04 INIT_ERR Failed to retrieve key from keystore`, + SIGNATURE_COMPUTATION_FAILURE = `CELO_ODIS_ERR_05 SIG_ERR Failed to compute BLS signature`, + VERIFY_PARITAL_SIGNATURE_ERROR = `CELO_ODIS_ERR_06 SIG_ERR BLS partial signature verification Failure`, + NOT_ENOUGH_PARTIAL_SIGNATURES = `CELO_ODIS_ERR_07 SIG_ERR Not enough partial signatures`, + INCONSISTENT_SIGNER_RESPONSES = `CELO_ODIS_ERR_08 SIG_ERR Inconsistent responses from signers`, + SIGNER_REQUEST_ERROR = `CELO_ODIS_ERR_09 SIG_ERR Failure in signer request`, + TIMEOUT_FROM_SIGNER = `CELO_ODIS_ERR_10 SIG_ERR Timeout from signer`, + FULL_NODE_ERROR = `CELO_ODIS_ERR_11 NODE_ERR Failed to read on-chain state`, + FAILURE_TO_STORE_REQUEST = `CELO_ODIS_ERR_12 DB_ERR Failed to store partial sig request`, + FAILURE_TO_INCREMENT_QUERY_COUNT = `CELO_ODIS_ERR_13 DB_ERR Failed to increment user query count`, + DOMAIN_ALREADY_DISABLED_FAILURE = `CELO_ODIS_ERR_14 DB_ERR Domain is already disabled`, + UNSUPPORTED_DOMAIN = `CELO_ODIS_ERR_15 DOMAIN Domain type is not supported`, + SIGNER_DISABLE_DOMAIN_FAILURE = `CELO_ODIS_ERR_16 DOMAIN Failed to disable domain on a signer`, + THRESHOLD_DISABLE_DOMAIN_FAILURE = `CELO_ODIS_ERR_17 DOMAIN Failed to disable domain on a threshold of signers`, + SIGNER_DOMAIN_QUOTA_STATUS_FAILURE = `CELO_ODIS_ERR_18 DOMAIN Failed to get domain status from signer`, + THRESHOLD_DOMAIN_QUOTA_STATUS_FAILURE = `CELO_ODIS_ERR_19 DOMAIN Failed to get domain quota status from a threshold of signers`, + INVALID_KEY_VERSION_RESPONSE = `CELO_ODIS_ERR_20 SIG_ERR Signer response key version header is invalid`, + INVALID_SIGNER_RESPONSE = `CELO_ODIS_ERR_21 SIG_ERR Signer response body is invalid`, + SIGNER_RESPONSE_FAILED_WITH_OK_STATUS = `CELO_ODIS_ERR_22 SIG_ERR Signer response failed with 200 status`, + THRESHOLD_PNP_QUOTA_STATUS_FAILURE = `CELO_ODIS_ERR_23 SIG_ERR Failed to get PNP quota status from a threshold of signers`, + FAILURE_TO_GET_PERFORMED_QUERY_COUNT = `CELO_ODIS_ERR_24 DB_ERR Failed to read performedQueryCount from signer db`, + FAILURE_TO_GET_TOTAL_QUOTA = `CELO_ODIS_ERR_25 NODE_ERR Failed to read on-chain state to calculate total quota`, + FAILURE_TO_GET_BLOCK_NUMBER = `CELO_ODIS_ERR_26 NODE_ERR Failed to read block number from full node`, + FAILURE_TO_GET_DEK = `CELO_ODIS_ERR_26 NODE_ERR Failed to read user's DEK from full-node`, + FAILING_OPEN = `CELO_ODIS_ERR_27 NODE_ERR Failing open on full-node error`, + FAILING_CLOSED = `CELO_ODIS_ERR_28 NODE_ERR Failing closed on full-node error`, + CAUGHT_ERROR_IN_ENDPOINT_HANDLER = `CELO_ODIS_ERR_29 Caught error in outer endpoint handler`, + ERROR_AFTER_RESPONSE_SENT = `CELO_ODIS_ERR_30 Error in endpoint thrown after response was already sent`, + SIGNATURE_AGGREGATION_FAILURE = 'CELO_ODIS_ERR_31 SIG_ERR Failed to blind aggregate signature shares', +} + +export enum WarningMessage { + INVALID_INPUT = `CELO_ODIS_WARN_01 BAD_INPUT Invalid input parameters`, + UNAUTHENTICATED_USER = `CELO_ODIS_WARN_02 BAD_INPUT Missing or invalid authentication`, + EXCEEDED_QUOTA = `CELO_ODIS_WARN_03 QUOTA Requester exceeded service query quota`, + DUPLICATE_REQUEST_TO_GET_PARTIAL_SIG = `CELO_ODIS_WARN_06 BAD_INPUT Attempt to replay partial signature request`, + INCONSISTENT_SIGNER_BLOCK_NUMBERS = `CELO_ODIS_WARN_07 SIGNER Discrepancy found in signers latest block number that exceeds threshold`, + INCONSISTENT_SIGNER_QUOTA_MEASUREMENTS = `CELO_ODIS_WARN_08 SIGNER Discrepancy found in signers quota measurements`, + MISSING_SESSION_ID = `CELO_ODIS_WARN_09 BAD_INPUT Client did not provide sessionID in request`, + CANCELLED_REQUEST_TO_SIGNER = `CELO_ODIS_WARN_09 SIGNER Cancelled request to signer`, + INVALID_USER_PHONE_NUMBER_SIGNATURE = `CELO_ODIS_WARN_10 BAD_INPUT User phone number signature is invalid`, + UNKNOWN_DOMAIN = `CELO_ODIS_WARN_11 BAD_INPUT Provided domain name and version is not recognized`, + DISABLED_DOMAIN = `CELO_ODIS_WARN_12 BAD_INPUT Provided domain is disabled`, + INVALID_KEY_VERSION_REQUEST = `CELO_ODIS_WARN_13 BAD_INPUT Request key version header is invalid`, + API_UNAVAILABLE = `CELO_ODIS_WARN_14 BAD_INPUT API is unavailable`, + INCONSISTENT_SIGNER_DOMAIN_DISABLED_STATES = `CELO_ODIS_WARN_15 SIGNER Discrepency found in signer domain disabled states`, + INVALID_AUTH_SIGNATURE = `CELO_ODIS_WARN_12 BAD_INPUT Authorization signature was incorrectly generated. Request will be rejected in a future version.`, + INVALID_NONCE = `CELO_ODIS_WARN_13 BAD_INPUT SequentialDelayDomain nonce check failed on Signer request`, + SIGNER_RESPONSE_DISCREPANCIES = `CELO_ODIS_WARN_14 SIGNER Discrepancies detected in signer responses`, + INCONSISTENT_SIGNER_QUERY_MEASUREMENTS = `CELO_ODIS_WARN_15 SIGNER Discrepancy found in signers performed query count measurements`, +} + +export type ErrorType = ErrorMessage | WarningMessage diff --git a/packages/phone-number-privacy/common/src/interfaces/index.ts b/packages/phone-number-privacy/common/src/interfaces/index.ts index 66431635de3..4a68aa366a9 100644 --- a/packages/phone-number-privacy/common/src/interfaces/index.ts +++ b/packages/phone-number-privacy/common/src/interfaces/index.ts @@ -1,3 +1,4 @@ -export * from './error-utils' +export * from './endpoints' +export * from './errors' export * from './requests' export * from './responses' diff --git a/packages/phone-number-privacy/common/src/interfaces/requests.ts b/packages/phone-number-privacy/common/src/interfaces/requests.ts index 62e75aeb887..daf98bb3fb4 100644 --- a/packages/phone-number-privacy/common/src/interfaces/requests.ts +++ b/packages/phone-number-privacy/common/src/interfaces/requests.ts @@ -9,6 +9,7 @@ import { verifyEIP712TypedDataSigner } from '@celo/utils/lib/signatureUtils' import { chain, isRight } from 'fp-ts/lib/Either' import { pipe } from 'fp-ts/lib/pipeable' import * as t from 'io-ts' +import { KEY_VERSION_HEADER } from '..' import { Domain, domainEIP712Types, @@ -23,87 +24,106 @@ import { // of interface. Otherwise the compiler complains about a missing index signature. // tslint:disable:interface-over-type-literal -export enum PhoneNumberPrivacyEndpoint { - STATUS = '/status', - METRICS = '/metrics', - GET_BLINDED_MESSAGE_PARTIAL_SIG = '/getBlindedMessagePartialSig', - GET_QUOTA = '/getQuota', -} - -export enum DomainEndpoint { - DISABLE_DOMAIN = '/domain/disable', - DOMAIN_SIGN = '/domain/sign/', - DOMAIN_QUOTA_STATUS = '/domain/quotaStatus', -} - -export type SignerEndpoint = PhoneNumberPrivacyEndpoint | DomainEndpoint -export const SignerEndpoint = { ...PhoneNumberPrivacyEndpoint, ...DomainEndpoint } - -export enum CombinerEndpoint { - SIGN_MESSAGE = '/getBlindedMessageSig', - MATCHMAKING = '/getContactMatches', -} - -export type Endpoint = SignerEndpoint | CombinerEndpoint -export const Endpoint = { ...SignerEndpoint, ...CombinerEndpoint } - export enum AuthenticationMethod { WALLET_KEY = 'wallet_key', ENCRYPTION_KEY = 'encryption_key', } -export interface GetBlindedMessageSigRequest { +export interface SignMessageRequest { /** Celo account address. Query is charged against this account's quota. */ account: string - /** Authentication method to use for verifying the signature in the Authorization header */ - authenticationMethod?: AuthenticationMethod /** Query message. A blinded elliptic curve point encoded in base64. */ blindedQueryPhoneNumber: string - /** Optional on-chain identifier. Unlocks additional quota if the account is verified as an owner of the identifier. */ - hashedPhoneNumber?: string + /** Authentication method to use for verifying the signature in the Authorization header */ + authenticationMethod?: string /** Client-specified session ID for the request. */ sessionID?: string /** Client-specified version string */ version?: string } -export interface GetContactMatchesRequest { - account: string - /** Authentication method to use for verifying the signature in the Authorization header */ - authenticationMethod?: AuthenticationMethod - userPhoneNumber: string // obfuscated with deterministic salt - contactPhoneNumbers: string[] // obfuscated with deterministic salt - hashedPhoneNumber: string // on-chain identifier - signedUserPhoneNumber?: string // signed with DEK - sessionID?: string - /** Client-specified version string */ - version?: string +/** previously known as GetBlindedMessageSigRequest */ +export interface LegacySignMessageRequest extends SignMessageRequest { + /** Optional on-chain identifier. Unlocks additional quota if the account is verified as an owner of the identifier. */ + hashedPhoneNumber?: string } -export interface GetQuotaRequest { +export const SignMessageRequestSchema: t.Type = t.intersection([ + t.type({ + account: t.string, + blindedQueryPhoneNumber: t.string, + }), + t.partial({ + authenticationMethod: t.union([t.string, t.undefined]), + sessionID: t.union([t.string, t.undefined]), + version: t.union([t.string, t.undefined]), + }), +]) + +export const LegacySignMessageRequestSchema: t.Type = t.intersection([ + SignMessageRequestSchema, + t.partial({ + hashedPhoneNumber: t.union([t.string, t.undefined]), + }), +]) + +export interface PnpQuotaRequest { account: string /** Authentication method to use for verifying the signature in the Authorization header */ - authenticationMethod?: AuthenticationMethod - hashedPhoneNumber?: string // on-chain identifier + authenticationMethod?: string + /** Client-specified session ID for the request. */ sessionID?: string /** Client-specified version string */ version?: string } +export interface LegacyPnpQuotaRequest extends PnpQuotaRequest { + /** User's ODIS generated on-chain identifier */ + hashedPhoneNumber?: string +} + +// Backwards compatibility +export declare type GetQuotaRequest = LegacyPnpQuotaRequest + +export const PnpQuotaRequestSchema: t.Type = t.intersection([ + t.type({ + account: t.string, + }), + t.partial({ + authenticationMethod: t.union([t.string, t.undefined]), + sessionID: t.union([t.string, t.undefined]), + version: t.union([t.string, t.undefined]), + }), +]) + +export const LegacyPnpQuotaRequestSchema: t.Type = t.intersection([ + PnpQuotaRequestSchema, + t.partial({ + hashedPhoneNumber: t.union([t.string, t.undefined]), + }), +]) export type PhoneNumberPrivacyRequest = - | GetBlindedMessageSigRequest - | GetContactMatchesRequest - | GetQuotaRequest + | SignMessageRequest + | LegacySignMessageRequest + | PnpQuotaRequest + | LegacyPnpQuotaRequest + +export enum DomainRequestTypeTag { + SIGN = 'DomainRestrictedSignatureRequest', + QUOTA = 'DomainQuotaStatusRequest', + DISABLE = 'DisableDomainRequest', +} /** * Domain restricted signature request to get a pOPRF evaluation on the given message in a given * domain, as specified by CIP-40. * - * @remarks Concrete request types are created by specifying the type parameters for Domain and - * DomainOptions. If the specified Domain has associated options, then the options field is - * required. If not, it must not be provided. + * @remarks Concrete request types are created by specifying the type parameter for Domain. If a + * domain has no options, an empty struct should be used. */ export type DomainRestrictedSignatureRequest = { + /** Request type tag to ensure this type can be distinguished from other request objects. */ + type: DomainRequestTypeTag.SIGN /** Domain specification. Selects the PRF domain and rate limiting rules. */ domain: D /** @@ -125,11 +145,12 @@ export type DomainRestrictedSignatureRequest = { * Options may be provided for authentication in case the quota state is non-public information. * E.g. Quota state may reveal whether or not a user has attempted to recover a given account. * - * @remarks Concrete request types are created by specifying the type parameters for Domain and - * DomainOptions. If the specified Domain has associated options, then the options field is - * required. If not, it must not be provided. + * @remarks Concrete request types are created by specifying the type parameter for Domain. If a + * domain has no options, an empty struct should be used. */ export type DomainQuotaStatusRequest = { + /** Request type tag to ensure this type can be distinguished from other request objects. */ + type: DomainRequestTypeTag.QUOTA /** Domain specification. Selects the PRF domain and rate limiting rules. */ domain: D /** Domain-specific options. */ @@ -145,11 +166,12 @@ export type DomainQuotaStatusRequest = { * * Options may be provided for authentication to prevent unintended parties from disabling a domain. * - * @remarks Concrete request types are created by specifying the type parameters for Domain and - * DomainOptions. If the specified Domain has associated options, then the options field is - * required. If not, it must not be provided. + * @remarks Concrete request types are created by specifying the type parameter for Domain. If a + * domain has no options, an empty struct should be used. */ export type DisableDomainRequest = { + /** Request type tag to ensure this type can be distinguished from other request objects. */ + type: DomainRequestTypeTag.DISABLE /** Domain specification. Selects the PRF domain and rate limiting rules. */ domain: D /** Domain-specific options. */ @@ -164,8 +186,10 @@ export type DomainRequest = | DomainQuotaStatusRequest | DisableDomainRequest +export type OdisRequest = DomainRequest | PhoneNumberPrivacyRequest + // NOTE: Next three functions are a bit repetitive. An attempt was made to combine them, but the -// type signature got quite complicated. Feel free to attempt it if you are motivated. TODO(Alec) +// type signature got quite complicated. Feel free to attempt it if you are motivated. /** Parameterized schema for checking unknown input against DomainRestrictedSignatureRequest */ export function domainRestrictedSignatureRequestSchema( @@ -175,6 +199,7 @@ export function domainRestrictedSignatureRequestSchema( // domain and options fields. We wrap the schema below to add a consistency check. const schema = t.strict({ domain, + type: t.literal(DomainRequestTypeTag.QUOTA), options: t.unknown, sessionID: eip712OptionalSchema(t.string), }) @@ -261,6 +287,7 @@ export function disableDomainRequestSchema( // domain and options fields. We wrap the schema below to add a consistency check. const schema = t.strict({ domain, + type: t.literal(DomainRequestTypeTag.DISABLE), options: t.unknown, sessionID: eip712OptionalSchema(t.string), }) @@ -303,9 +330,9 @@ export function domainRestrictedSignatureRequestEIP712( return { types: { DomainRestrictedSignatureRequest: [ + { name: 'type', type: 'string' }, { name: 'blindedMessage', type: 'string' }, { name: 'domain', type: domainTypes.primaryType }, - // Only include the `options` field in the EIP-712 type if there are options. { name: 'options', type: optionsTypes.primaryType }, { name: 'sessionID', type: 'Optional' }, ], @@ -335,8 +362,8 @@ export function domainQuotaStatusRequestEIP712( return { types: { DomainQuotaStatusRequest: [ + { name: 'type', type: 'string' }, { name: 'domain', type: domainTypes.primaryType }, - // Only include the `options` field in the EIP-712 type if there are options. { name: 'options', type: optionsTypes.primaryType }, { name: 'sessionID', type: 'Optional' }, ], @@ -366,8 +393,8 @@ export function disableDomainRequestEIP712( return { types: { DisableDomainRequest: [ + { name: 'type', type: 'string' }, { name: 'domain', type: domainTypes.primaryType }, - // Only include the `options` field in the EIP-712 type if there are options. { name: 'options', type: optionsTypes.primaryType }, { name: 'sessionID', type: 'Optional' }, ], @@ -432,43 +459,83 @@ function verifyRequestSignature>( } /** - * Verifies the signature over a signature request for authenticated domains. + * Verifies the authentication (e.g. client signature) over a domain signature request. * If the domain is unauthenticated, this function returns false. * - * @remarks As specified in CIP-40, the signed message is the full request interpretted as EIP-712 + * @remarks As specified in CIP-40, the signed message is the full request interpreted as EIP-712 * typed data with the signature field in the domain options set to its zero value (i.e. It is set * to the undefined value for type EIP712Optional). */ -export function verifyDomainRestrictedSignatureRequestSignature( +export function verifyDomainRestrictedSignatureRequestAuthenticity( request: DomainRestrictedSignatureRequest ): boolean { return verifyRequestSignature(domainRestrictedSignatureRequestEIP712, request) } /** - * Verifies the signature over a domain quota status request for authenticated domains. + * Verifies the authentication (e.g. client signature) over a domain status request. * If the domain is unauthenticated, this function returns false. * - * @remarks As specified in CIP-40, the signed message is the full request interpretted as EIP-712 + * @remarks As specified in CIP-40, the signed message is the full request interpreted as EIP-712 * typed data with the signature field in the domain options set to its zero value (i.e. It is set * to the undefined value for type EIP712Optional). */ -export function verifyDomainQuotaStatusRequestSignature( +export function verifyDomainQuotaStatusRequestAuthenticity( request: DomainQuotaStatusRequest ): boolean { return verifyRequestSignature(domainQuotaStatusRequestEIP712, request) } /** - * Verifies the signature over a disable domain request for authenticated domains. + * Verifies the authentication (e.g. client signature) over a disable domain request. * If the domain is unauthenticated, this function returns false. * - * @remarks As specified in CIP-40, the signed message is the full request interpretted as EIP-712 + * @remarks As specified in CIP-40, the signed message is the full request interpreted as EIP-712 * typed data with the signature field in the domain options set to its zero value (i.e. It is set * to the undefined value for type EIP712Optional). */ -export function verifyDisableDomainRequestSignature( +export function verifyDisableDomainRequestAuthenticity( request: DisableDomainRequest ): boolean { return verifyRequestSignature(disableDomainRequestEIP712, request) } + +interface PnpAuthHeader { + Authorization: string +} + +interface KeyVersionHeader { + [KEY_VERSION_HEADER]?: string +} + +export type DomainRestrictedSignatureRequestHeader = KeyVersionHeader +export type DisableDomainRequestHeader = undefined +export type DomainQuotaStatusRequestHeader = undefined + +export type DomainRequestHeader< + R extends DomainRequest +> = R extends DomainRestrictedSignatureRequest + ? DomainRestrictedSignatureRequestHeader + : never | R extends DisableDomainRequest + ? DisableDomainRequestHeader + : never | R extends DomainQuotaStatusRequest + ? DomainQuotaStatusRequestHeader + : never + +export type SignMessageRequestHeader = KeyVersionHeader & PnpAuthHeader + +export type PnpQuotaRequestHeader = PnpAuthHeader + +export type PhoneNumberPrivacyRequestHeader = R extends + | SignMessageRequest + | LegacySignMessageRequest + ? SignMessageRequestHeader + : never | R extends PnpQuotaRequest + ? PnpQuotaRequestHeader + : never + +export type OdisRequestHeader = R extends DomainRequest + ? DomainRequestHeader + : never | R extends PhoneNumberPrivacyRequest + ? PhoneNumberPrivacyRequestHeader + : never diff --git a/packages/phone-number-privacy/common/src/interfaces/responses.ts b/packages/phone-number-privacy/common/src/interfaces/responses.ts index 8d5d0e36727..70e60ad867d 100644 --- a/packages/phone-number-privacy/common/src/interfaces/responses.ts +++ b/packages/phone-number-privacy/common/src/interfaces/responses.ts @@ -1,60 +1,143 @@ import * as t from 'io-ts' -import { Domain, DomainState } from '../domains' import { DisableDomainRequest, DomainQuotaStatusRequest, DomainRequest, DomainRestrictedSignatureRequest, -} from './requests' + LegacyPnpQuotaRequest, + LegacySignMessageRequest, + OdisRequest, + PhoneNumberPrivacyRequest, + PnpQuotaRequest, + SignMessageRequest, +} from '.' +import { Domain, DomainState } from '../domains' -export interface SignMessageResponse { - success: boolean - version?: string - signature?: string - performedQueryCount?: number - totalQuota?: number +// Phone Number Privacy +export interface PnpQuotaStatus { + performedQueryCount: number + // all time total quota + totalQuota: number blockNumber?: number } -export interface SignMessageResponseFailure extends SignMessageResponse { +const PnpQuotaStatusSchema: t.Type = t.intersection([ + t.type({ + performedQueryCount: t.number, + totalQuota: t.number, + }), + t.partial({ + blockNumber: t.union([t.number, t.undefined]), + }), +]) + +export interface SignMessageResponseSuccess extends PnpQuotaStatus { + success: true + version: string + signature: string + warnings?: string[] +} + +export interface SignMessageResponseFailure { success: false + version: string error: string + // These fields are occasionally provided by the signer but not the combiner + // because the combiner separates failure/success responses before processing states. + // => If the signer response fails, then it's irrelevant if that signer returned quota values, + // since these won't be used in the quota calculation anyways. + // Changing this is more involved; TODO(future) https://github.com/celo-org/celo-monorepo/issues/9826 + performedQueryCount?: number + totalQuota?: number + blockNumber?: number } -export interface SignMessageResponseSuccess extends SignMessageResponse { - success: true -} +export type SignMessageResponse = SignMessageResponseSuccess | SignMessageResponseFailure -export interface GetQuotaResponse { - success: boolean +export const SignMessageResponseSchema: t.Type = t.union([ + t.intersection([ + t.type({ + success: t.literal(true), + version: t.string, + signature: t.string, + }), + t.partial({ + warnings: t.union([t.array(t.string), t.undefined]), + }), + PnpQuotaStatusSchema, + ]), + t.intersection([ + t.type({ + success: t.literal(false), + version: t.string, + error: t.string, + }), + t.partial({ + performedQueryCount: t.union([t.number, t.undefined]), + totalQuota: t.union([t.number, t.undefined]), + blockNumber: t.union([t.number, t.undefined]), + }), + ]), +]) + +export interface PnpQuotaResponseSuccess extends PnpQuotaStatus { + success: true version: string - performedQueryCount: number - totalQuota: number + warnings?: string[] } -export interface GetContactMatchesResponse { - success: boolean - matchedContacts: Array<{ - phoneNumber: string - }> +export interface PnpQuotaResponseFailure { + success: false version: string + error: string } -export interface DomainRestrictedSignatureResponseSuccess { +export type PnpQuotaResponse = PnpQuotaResponseSuccess | PnpQuotaResponseFailure + +export const PnpQuotaResponseSchema: t.Type = t.union([ + t.intersection([ + t.type({ + success: t.literal(true), + version: t.string, + }), + t.partial({ + warnings: t.union([t.array(t.string), t.undefined]), + }), + PnpQuotaStatusSchema, + ]), + t.type({ + success: t.literal(false), + version: t.string, + error: t.string, + }), +]) + +// prettier-ignore +export type PhoneNumberPrivacyResponse< + R extends PhoneNumberPrivacyRequest = PhoneNumberPrivacyRequest +> = + | R extends SignMessageRequest | LegacySignMessageRequest ? SignMessageResponse : never + | R extends PnpQuotaRequest | LegacyPnpQuotaRequest ? PnpQuotaResponse : never + +// Domains + +export interface DomainRestrictedSignatureResponseSuccess { success: true version: string signature: string + status: DomainState } -export interface DomainRestrictedSignatureResponseFailure { +export interface DomainRestrictedSignatureResponseFailure { success: false version: string error: string + status?: DomainState } -export type DomainRestrictedSignatureResponse = - | DomainRestrictedSignatureResponseSuccess - | DomainRestrictedSignatureResponseFailure +export type DomainRestrictedSignatureResponse = + | DomainRestrictedSignatureResponseSuccess + | DomainRestrictedSignatureResponseFailure export interface DomainQuotaStatusResponseSuccess { success: true @@ -72,9 +155,10 @@ export type DomainQuotaStatusResponse = | DomainQuotaStatusResponseSuccess | DomainQuotaStatusResponseFailure -export interface DisableDomainResponseSuccess { +export interface DisableDomainResponseSuccess { success: true version: string + status: DomainState } export interface DisableDomainResponseFailure { @@ -83,36 +167,61 @@ export interface DisableDomainResponseFailure { error: string } -export type DisableDomainResponse = DisableDomainResponseSuccess | DisableDomainResponseFailure +export type DisableDomainResponse = + | DisableDomainResponseSuccess + | DisableDomainResponseFailure +// prettier-ignore export type DomainResponse< R extends DomainRequest = DomainRequest -> = R extends DomainRestrictedSignatureRequest - ? DomainRestrictedSignatureResponse - : never | R extends DomainQuotaStatusRequest - ? DomainQuotaStatusResponse - : never | R extends DisableDomainRequest - ? DisableDomainResponse - : never - -export const DomainRestrictedSignatureResponseSchema: t.Type = t.union( - [ +> = + | R extends DomainRestrictedSignatureRequest ? DomainRestrictedSignatureResponse : never + | R extends DomainQuotaStatusRequest ? DomainQuotaStatusResponse : never + | R extends DisableDomainRequest ? DisableDomainResponse : never + +export function domainRestrictedSignatureResponseSchema( + state: t.Type> +): t.Type> { + return t.union([ t.type({ success: t.literal(true), version: t.string, signature: t.string, + status: state, + }), + t.intersection([ + t.type({ + success: t.literal(false), + version: t.string, + error: t.string, + }), + t.partial({ + status: t.union([state, t.undefined]), + }), + ]), + ]) +} + +export function domainQuotaStatusResponseSchema( + state: t.Type> +): t.Type> { + return t.union([ + t.type({ + success: t.literal(true), + version: t.string, + status: state, }), t.type({ success: t.literal(false), version: t.string, error: t.string, }), - ] -) + ]) +} -export function domainQuotaStatusResponseSchema( +export function disableDomainResponseSchema( state: t.Type> -): t.Type> { +): t.Type> { return t.union([ t.type({ success: t.literal(true), @@ -127,14 +236,16 @@ export function domainQuotaStatusResponseSchema( ]) } -export const DisableDomainResponseSchema: t.Type = t.union([ - t.type({ - success: t.literal(true), - version: t.string, - }), - t.type({ - success: t.literal(false), - version: t.string, - error: t.string, - }), -]) +// General + +// prettier-ignore +export type OdisResponse = + | R extends DomainRequest ? DomainResponse : never + | R extends PhoneNumberPrivacyRequest ? PhoneNumberPrivacyResponse : never + +export type SuccessResponse = OdisResponse & { + success: true +} +export type FailureResponse = OdisResponse & { + success: false +} diff --git a/packages/phone-number-privacy/common/src/poprf.ts b/packages/phone-number-privacy/common/src/poprf.ts index 60bfbea52c8..633b469af51 100644 --- a/packages/phone-number-privacy/common/src/poprf.ts +++ b/packages/phone-number-privacy/common/src/poprf.ts @@ -1,9 +1,8 @@ -import { randomBytes } from 'crypto' - // Note that this import is only ever used for its type information. As a result, it will not be // included in the compiled JavaScript or result in an import at runtime. // https://www.typescriptlang.org/docs/handbook/modules.html#optional-module-loading-and-other-advanced-loading-scenarios import * as POPRF from '@celo/poprf' +import { randomBytes } from 'crypto' /** * @module @@ -35,7 +34,7 @@ let _poprf: typeof POPRF | undefined * functionality, it should add @celo/poprf to its dependencies (i.e. package.json). */ function poprf(): typeof POPRF { - // TODO(victor): This will only initially work in Node environments. If we want to have this work in + // TODO: This will only initially work in Node environments. If we want to have this work in // ReactNative and browser environments, some work will need to be done in @celo/poprf or here. if (_poprf === undefined) { try { @@ -140,7 +139,7 @@ export class PoprfCombiner { /** * If there are enough responses provided, aggregates the collection of partial evaluations - * to a single PORF evaluation. + * to a single POPRF evaluation. * * @param response An array of partial evaluation responses. * @returns A buffer with a POPRF evaluation, or undefined if there are less than the threshold @@ -159,7 +158,7 @@ export class PoprfCombiner { * Client for interacting with a threshold implementation of the POPRF service without a combiner. * * @privateRemarks - * TODO(victor) Combine this class with the functionality from the combiner to create a POPRF client + * TODO Combine this class with the functionality from the combiner to create a POPRF client * that can handle expunging bad partial evaluations from a set of responses. */ export class ThresholdPoprfClient extends PoprfClient { diff --git a/packages/phone-number-privacy/common/src/test/utils.ts b/packages/phone-number-privacy/common/src/test/utils.ts index b0d267d5485..0d6c2505e2b 100644 --- a/packages/phone-number-privacy/common/src/test/utils.ts +++ b/packages/phone-number-privacy/common/src/test/utils.ts @@ -1,29 +1,53 @@ -import { Signature } from '@celo/utils/lib/signatureUtils' +import { AttestationsStatus } from '@celo/base' +import { privateKeyToAddress } from '@celo/utils/lib/address' +import { serializeSignature, Signature, signMessage } from '@celo/utils/lib/signatureUtils' import BigNumber from 'bignumber.js' import * as threshold from 'blind-threshold-bls' import btoa from 'btoa' import Web3 from 'web3' +import { + AuthenticationMethod, + LegacyPnpQuotaRequest, + LegacySignMessageRequest, + PhoneNumberPrivacyRequest, + PnpQuotaRequest, + SignMessageRequest, +} from '../interfaces' +import { signWithRawKey } from '../utils/authentication' +import { genSessionID } from '../utils/logger' -export function createMockAttestation(completed: number, total: number) { +export function createMockAttestation(getVerifiedStatus: jest.Mock) { return { - getVerifiedStatus: jest.fn(() => ({ completed, total })), + getVerifiedStatus, } } -export function createMockToken(balance: BigNumber) { +export function createMockToken(balanceOf: jest.Mock) { return { - balanceOf: jest.fn(() => balance), + balanceOf, } } -export function createMockAccounts(walletAddress: string) { +export function createMockAccounts( + getWalletAddress: jest.Mock, + getDataEncryptionKey: jest.Mock +) { + return { + getWalletAddress, + getDataEncryptionKey, + } +} + +// Take in jest.Mock to enable individual tests to spy on function calls +// and more easily set return values +export function createMockOdisPayments(totalPaidCUSDFunc: jest.Mock) { return { - getWalletAddress: jest.fn(() => walletAddress), + totalPaidCUSD: totalPaidCUSDFunc, } } export function createMockContractKit( - c: { [contractName in ContractRetrieval]: any }, + c: { [contractName in ContractRetrieval]?: any }, mockWeb3?: any ) { const contracts: any = {} @@ -45,6 +69,9 @@ export function createMockConnection(mockWeb3?: any) { return { web3: mockWeb3, getTransactionCount: jest.fn(() => mockWeb3.eth.getTransactionCount()), + getBlockNumber: jest.fn(() => { + return mockWeb3.eth.getBlockNumber() + }), } } @@ -53,12 +80,14 @@ export enum ContractRetrieval { getStableToken = 'getStableToken', getGoldToken = 'getGoldToken', getAccounts = 'getAccounts', + getOdisPayments = 'getOdisPayments', } -export function createMockWeb3(txCount: number) { +export function createMockWeb3(txCount: number, blockNumber: number) { return { eth: { getTransactionCount: jest.fn(() => txCount), + getBlockNumber: jest.fn(() => blockNumber), }, } } @@ -98,3 +127,63 @@ export async function registerWalletAddress( .setWalletAddress(walletAddress, pop as Signature) .sendAndWaitForReceipt({ from: accountAddress } as any) } + +export function getPnpQuotaRequest( + account: string, + authenticationMethod?: string +): PnpQuotaRequest { + return { + account, + authenticationMethod, + sessionID: genSessionID(), + } +} +export function getLegacyPnpQuotaRequest( + account: string, + authenticationMethod?: string, + hashedPhoneNumber?: string +): LegacyPnpQuotaRequest { + return { + account, + authenticationMethod, + hashedPhoneNumber, + sessionID: genSessionID(), + } +} + +export function getLegacyPnpSignRequest( + account: string, + blindedQueryPhoneNumber: string, + authenticationMethod?: string, + hashedPhoneNumber?: string +): LegacySignMessageRequest { + return { + account, + blindedQueryPhoneNumber, + authenticationMethod, + hashedPhoneNumber, + sessionID: genSessionID(), + } +} + +export function getPnpSignRequest( + account: string, + blindedQueryPhoneNumber: string, + authenticationMethod?: string +): SignMessageRequest { + return { + account, + blindedQueryPhoneNumber, + authenticationMethod, + sessionID: genSessionID(), + } +} + +export function getPnpRequestAuthorization(req: PhoneNumberPrivacyRequest, pk: string) { + const msg = JSON.stringify(req) + if (req.authenticationMethod === AuthenticationMethod.ENCRYPTION_KEY) { + return signWithRawKey(JSON.stringify(req), pk) + } + const account = privateKeyToAddress(pk) + return serializeSignature(signMessage(msg, pk, account)) +} diff --git a/packages/phone-number-privacy/common/src/test/values.ts b/packages/phone-number-privacy/common/src/test/values.ts index dc51da2b34f..ab028ece0d8 100644 --- a/packages/phone-number-privacy/common/src/test/values.ts +++ b/packages/phone-number-privacy/common/src/test/values.ts @@ -16,3 +16,129 @@ export const PHONE_NUMBER = '+15555555555' export const IDENTIFIER = PhoneNumberUtils.getPhoneHash(PHONE_NUMBER) export const BLINDING_FACTOR = Buffer.from('0IsBvRfkBrkKCIW6HV0/T1zrzjQSe8wRyU3PKojCnww=', 'base64') export const BLINDED_PHONE_NUMBER = getBlindedPhoneNumber(PHONE_NUMBER, BLINDING_FACTOR) +export const DEK_PUBLIC_KEY = '0x026063780c81991c032fb4fa7485c6607b7542e048ef85d08516fe5c4482360e4b' +export const DEK_PRIVATE_KEY = '0xc2bbdabb440141efed205497a41d5fb6114e0435fd541e368dc628a8e086bfee' + +// Public keys are expected to be in base64 +export const PNP_DEV_ODIS_PUBLIC_KEY = + 'HzMTasAppwLrBBCWvZ7wncnDaN3lKpcoZr3q/wiW+FlrdKt639cxi7o4UnWZdoQA30S8q2a884Q8F6LOg4vNWouhY0wYMU/wVlp8dpkFuKj7onqGv0xssi34nhut/iuB' +export const PNP_DEV_SIGNER_PRIVATE_KEY = + '00000000dd0005bf4de5f2f052174f5cf58dae1af1d556c7f7f85d6fb3656e1d0f10720f' +export const PNP_DEV_ODIS_POLYNOMIAL = + '01000000000000001f33136ac029a702eb041096bd9ef09dc9c368dde52a972866bdeaff0896f8596b74ab7adfd7318bba38527599768400df44bcab66bcf3843c17a2ce838bcd5a8ba1634c18314ff0565a7c769905b8a8fba27a86bf4c6cb22df89e1badfe2b81' + +// Public keys are expected to be in base64 +export const DOMAINS_DEV_ODIS_PUBLIC_KEY = + 'CyJK6fkM0ZRILiW0h85LFev4BbMcLH1RBX5I9BNDgwX5jM74kv8+FjFZuJ1C4P0ADU1fuPGXXQg+wAGCclUD+BCza6ItIxSYmwsZ4ie1Iw1/pdTcwPJJlXwYwcDo+LKA' +export const DOMAINS_DEV_SIGNER_PRIVATE_KEY = + '01000000f0c2d6231c9ed833da9478cbfd6e4970fcd893e156973862f6d286e7e1f6d904' +export const DOMAINS_DEV_ODIS_POLYNOMIAL = + '01000000000000000b224ae9f90cd194482e25b487ce4b15ebf805b31c2c7d51057e48f413438305f98ccef892ff3e163159b89d42e0fd000d4d5fb8f1975d083ec00182725503f810b36ba22d2314989b0b19e227b5230d7fa5d4dcc0f249957c18c1c0e8f8b280' + +// Generated with 2/3 ratio + +export const PNP_THRESHOLD_DEV_PUBKEY_V1 = + '61aeuHAdgxoKn/5d8yXu0qx/VpPHWMAqrVgEAJ/MpC7Oc/f1YLPiN7YKaw9eDWUBUWs4sPn6IN2UTGbt95jP6nO8IymD4IhbBONjLcElsq1jwTZ2cjuTHV9obSyDFl2B' +export const PNP_THRESHOLD_DEV_PK_SHARE_1_V1 = + '000000000e7e1a2fad3b54deb2b1b32cf4c7b084842d50bbb5c6143b9d9577d16e050f03' +export const PNP_THRESHOLD_DEV_PK_SHARE_2_V1 = + '01000000e43f10f7778e238e1ed58d5fad9363d7439d2b5a8eeda6073d68ba87c0b10011' +export const PNP_THRESHOLD_DEV_PK_SHARE_3_V1 = + '02000000b90106bf4261e13389f867c267e86bd0015dcf9c48c784738695d0a3b3f8460c' +export const PNP_THRESHOLD_DEV_POLYNOMIAL_V1 = + '0200000000000000eb569eb8701d831a0a9ffe5df325eed2ac7f5693c758c02aad5804009fcca42ece73f7f560b3e237b60a6b0f5e0d6501516b38b0f9fa20dd944c66edf798cfea73bc232983e0885b04e3632dc125b2ad63c13676723b931d5f686d2c83165d817aaff1f84d0b008ad218eff19db698f343168cf931ba8347640123a2f826f62b66ff084273f494d4647758e9a9f889009d573705824a0e74e1f49ed234462058e53bbb4fef370b55f78da89df070c661782a84239b8c7623d09e34b9f91f7781' + +// Note: The pubkey doesn't change with a resharing, so normally the different key versions would have the same pubkey. +// We generated these key versions independently (not through resharing), since that is sufficient to test the key rotation logic + +export const PNP_THRESHOLD_DEV_PUBKEY_V2 = + '2ckOWP3qphyao1R4s8VHbVRdenGcFsgskQh5eCMqAwAziJzQAZ6Wo9CFD30YhhoA6B91QFIQaqfDvdblNeOtMDsmIKTDFtxZjg+cZZtQzrCTLU2owWEEb8RPJc8F3ekA' +export const PNP_THRESHOLD_DEV_PK_SHARE_1_V2 = + '0000000087c722e1338395b942d8332328795a46c718baeb8fef9e5c63111d495469c50e' +export const PNP_THRESHOLD_DEV_PK_SHARE_2_V2 = + '01000000e4efa9b60743f8188a68d35663d877143ad1726931eaa9af168fc86472eafd0d' +export const PNP_THRESHOLD_DEV_PK_SHARE_3_V2 = + '020000004118318cdb025b78d1f8728a9e3795e2ac892be7d2e4b402ca0c7480906b360d' +export const PNP_THRESHOLD_DEV_POLYNOMIAL_V2 = + '0200000000000000d9c90e58fdeaa61c9aa35478b3c5476d545d7a719c16c82c91087978232a030033889cd0019e96a3d0850f7d18861a00e81f754052106aa7c3bdd6e535e3ad303b2620a4c316dc598e0f9c659b50ceb0932d4da8c161046fc44f25cf05dde900ebf6f83c5cb94288347ebf437e99fbb7a7eaf0c9873467352c1a9113f5fc0974d96cbf25462def50c39224da757ed300ce12e0fa8c6e73387cb43c69764bed41d0a0c55981642650b07fad1107a27b27fc8c552da3edd64494e8acc4de9a2600' + +export const PNP_THRESHOLD_DEV_PUBKEY_V3 = + '5o9Y516dvzZLy7E/SfOSm2kVh02t1rU1tkJrk55/HjhRSZtyHRgAOnbnvKJvQjAA1OE70LsYlrKK8PGNVOp7cVdrFbm9xbkew+BU6hdO473qierDOF4SjKQNToyh5UOB' +export const PNP_THRESHOLD_DEV_PK_SHARE_1_V3 = + '000000005b2c8089ead28a08233b6b16b2341542453523445950cfbd9bd2f1d09c8eee0c' +export const PNP_THRESHOLD_DEV_PK_SHARE_2_V3 = + '01000000f6c10aa979a0c33a3af5b03c37ffdf1d4a8517a5f9e6058e1d863337eeb59904' +export const PNP_THRESHOLD_DEV_PK_SHARE_3_V3 = + '02000000925795c808ee0d7752aff632bb40555350854362b8caf0bef5dea1379e42f00e' +export const PNP_THRESHOLD_DEV_POLYNOMIAL_V3 = + '0200000000000000e68f58e75e9dbf364bcbb13f49f3929b6915874dadd6b535b6426b939e7f1e3851499b721d18003a76e7bca26f423000d4e13bd0bb1896b28af0f18d54ea7b71576b15b9bdc5b91ec3e054ea174ee3bdea89eac3385e128ca40d4e8ca1e543813fae8439a057f8c17d4538afecf038624e552a8c226c9f82bfb7a072cff28fb7d26ab45801b67db270cec8037b8d7e016b1b78f7997160bd4ed1b54ab5d6be7663935992cd9c59ceb17010eccd708a9762df616c1fe45a220be634e21ba87581' + +export const PNP_THRESHOLD_DEV_POLYNOMIALS = [ + PNP_THRESHOLD_DEV_POLYNOMIAL_V1, + PNP_THRESHOLD_DEV_POLYNOMIAL_V2, + PNP_THRESHOLD_DEV_POLYNOMIAL_V3, +] + +export const PNP_THRESHOLD_DEV_PUBKEYS = [ + PNP_THRESHOLD_DEV_PUBKEY_V1, + PNP_THRESHOLD_DEV_PUBKEY_V2, + PNP_THRESHOLD_DEV_PUBKEY_V3, +] + +export const DOMAINS_THRESHOLD_DEV_PUBKEY_V1 = + 'zaetF6aXkBAkVwoUosuyQ8xiK2tKM9/zKrTPKbxoDoO7p6DSwbetk5uEICK+PjcAG4pGGY81jaUPSsPqlwIDfOy+RxJ2O+5ZPDM4I+b70MSYZYrsZ6qPxg+xtqLb9AOA' +export const DOMAINS_THRESHOLD_DEV_PK_SHARE_1_V1 = + '010000000c63d9615b2ff0746562c0b438286544f029698a4205cd8b8f93afaa5b793211' +export const DOMAINS_THRESHOLD_DEV_PK_SHARE_2_V1 = + '020000001b63b2c531070b176f56042da68923c5859b9f82181559646b58445976de5f08' +export const DOMAINS_THRESHOLD_DEV_PK_SHARE_3_V1 = + '030000002b638b29085f37c3794a487512628c9f1cbd0dd70c72999d9dc205a2efa83812' +export const DOMAINS_THRESHOLD_DEV_POLYNOMIAL_V1 = + '0200000000000000cda7ad17a697901024570a14a2cbb243cc622b6b4a33dff32ab4cf29bc680e83bba7a0d2c1b7ad939b842022be3e37001b8a46198f358da50f4ac3ea9702037cecbe4712763bee593c333823e6fbd0c498658aec67aa8fc60fb1b6a2dbf403804e71a1bb1f51f2186f579048cb224f8993295e699ea14552506418df6fcf019ffe6f89253d6122cc97b8f8c5785674006c821ca2d596e4c0d75aba2b03e8ba082e002d24ebe5c48956ef96b8ac85f96c9c7929e8facac50b74b3aac792ad5d00' + +export const DOMAINS_THRESHOLD_DEV_PUBKEY_V2 = + 'rc9WQhFQn64w9FzlbVgyZi8Cd/bep+l3MtzPOWMInRQ3XoJMDSJ15SzBgE6M6JEAr58f9m2zZi6TMEcogbg3hHp37MUoybowzbGeed9jWqCWGQ0VBMFMaJLR8exNdtkA' +export const DOMAINS_THRESHOLD_DEV_PK_SHARE_1_V2 = + '01000000a8070976747d9bb1fe56d822a57252ce3ddd5a8acef7e3ed94aeb52a16da4d04' +export const DOMAINS_THRESHOLD_DEV_PK_SHARE_2_V2 = + '020000003d8495ff96deb0109216d575bc0f6ff364466e830ecb4d0e860d869ef8b82e0b' +export const DOMAINS_THRESHOLD_DEV_PK_SHARE_3_V2 = + '03000000d2002289b93fc66f25d6d1c8d3ac8b188caf817c4e9eb72e776c5612db970f12' +export const DOMAINS_THRESHOLD_DEV_POLYNOMIAL_V2 = + '0200000000000000adcf564211509fae30f45ce56d5832662f0277f6dea7e97732dccf3963089d14375e824c0d2275e52cc1804e8ce89100af9f1ff66db3662e9330472881b837847a77ecc528c9ba30cdb19e79df635aa096190d1504c14c6892d1f1ec4d76d900c32eadf29b938d0466e566b527c798434931c6c2afd84fdd34aa5d620b15d19b6b1d59f9fa0c81150bf62d316a1b8f000708a46bd4c807cab0a60e9692e1efe74084ae1503172377e39600b8fd88b4885ee55adae7bb21993909da127d3c0c81' + +export const DOMAINS_THRESHOLD_DEV_PUBKEY_V3 = + 'OGHVPM0uXSduGBKQNyyGBr7IHXZQbnG9WopBhw5m0nddsmcoQP30/IBGCB0JGOsAemcw/mP43ueJxw7PPo/m+7JhFyu8cX7F61ULbmHAFd84wneZJf42U42rWSoC+IeB' +export const DOMAINS_THRESHOLD_DEV_PK_SHARE_1_V3 = + '0100000030c5c6ae0959c96ccc3a31c73b1b603b9b448ccfc80dba2e496d31295caee40e' +export const DOMAINS_THRESHOLD_DEV_PK_SHARE_2_V3 = + '02000000768eb3c42a126abaa80eb8cae14006f2a98cdd175d40984ad84e881c7bf7da0d' +export const DOMAINS_THRESHOLD_DEV_PK_SHARE_3_V3 = + '03000000bc57a0da4bcb0a0885e23ece8766aca8b8d42e60f17276666730df0f9a40d10c' +export const DOMAINS_THRESHOLD_DEV_POLYNOMIAL_V3 = + '02000000000000003861d53ccd2e5d276e181290372c8606bec81d76506e71bd5a8a41870e66d2775db2672840fdf4fc8046081d0918eb007a6730fe63f8dee789c70ecf3e8fe6fbb261172bbc717ec5eb550b6e61c015df38c2779925fe36538dab592a02f887817f62a8f350751888132fca7dd7ff06731102483340145ff1571229884b06bfbfb25636e4bc6ad5dc294a09e45f7171012d042d5be90537c3f0eb70d51c7f7a6c09cee7af8c4af1750afde124a47a98330073af8c9011ab8a1571bc8ee958e200' + +export const DOMAINS_THRESHOLD_DEV_PUBKEY_V4 = + 'iRyLg54DDNq2c1TUAbsnc2VB5BwjBjBjJCysj6NO/Fmuki3LHjaSOscbNTQtZkIBTjBTALBDPzJAr1hDFebQTFHfg7oNaFUiEKC7P7Mhd0X9BJWNV8MEm+ZG4DymrAgA' +export const DOMAINS_THRESHOLD_DEV_PK_SHARE_1_V4 = + '0100000044d1155eb821064919ef3b35625aa3595e2f0285d23181997836c6b18c661901' +export const DOMAINS_THRESHOLD_DEV_PK_SHARE_2_V4 = + '02000000b02c15e2769896f6c8b9bd2e3be1ddcac753683e2b991ac7c12531334122ee00' +export const DOMAINS_THRESHOLD_DEV_PK_SHARE_3_V4 = + '030000001c881466350f27a478843f281468183c3178cef78300b4f40a159cb4f5ddc200' +export const DOMAINS_THRESHOLD_DEV_POLYNOMIAL_V4 = + '0200000000000000891c8b839e030cdab67354d401bb27736541e41c23063063242cac8fa34efc59ae922dcb1e36923ac71b35342d6642014e305300b0433f3240af584315e6d04c51df83ba0d68552210a0bb3fb3217745fd04958d57c3049be646e03ca6ac08004a8cd44dd5f648a0f3cba05024829e25c79e603193fd7cdedce1cf400bf828bea0aee6b6b792c8efb6771713e6a30e01c8f8f981445a4455ee425a676133f8a095850245d32ce4765d83fc672a87c7116295c4b4927c51aec38b944260ea0200' + +export const DOMAINS_THRESHOLD_DEV_POLYNOMIALS = [ + DOMAINS_THRESHOLD_DEV_POLYNOMIAL_V1, + DOMAINS_THRESHOLD_DEV_POLYNOMIAL_V2, + DOMAINS_THRESHOLD_DEV_POLYNOMIAL_V3, + DOMAINS_THRESHOLD_DEV_POLYNOMIAL_V4, +] + +export const DOMAINS_THRESHOLD_DEV_PUBKEYS = [ + DOMAINS_THRESHOLD_DEV_PUBKEY_V1, + DOMAINS_THRESHOLD_DEV_PUBKEY_V2, + DOMAINS_THRESHOLD_DEV_PUBKEY_V3, + DOMAINS_THRESHOLD_DEV_PUBKEY_V4, +] diff --git a/packages/phone-number-privacy/common/src/utils/authentication.ts b/packages/phone-number-privacy/common/src/utils/authentication.ts index b12d29113b6..08f99145e69 100644 --- a/packages/phone-number-privacy/common/src/utils/authentication.ts +++ b/packages/phone-number-privacy/common/src/utils/authentication.ts @@ -1,4 +1,4 @@ -import { retryAsyncWithBackOffAndTimeout } from '@celo/base' +import { hexToBuffer, retryAsyncWithBackOffAndTimeout } from '@celo/base' import { ContractKit } from '@celo/contractkit' import { AccountsWrapper } from '@celo/contractkit/lib/wrappers/Accounts' import { AttestationsWrapper } from '@celo/contractkit/lib/wrappers/Attestations' @@ -7,18 +7,26 @@ import { verifySignature } from '@celo/utils/lib/signatureUtils' import Logger from 'bunyan' import crypto from 'crypto' import { Request } from 'express' -import { rootLogger } from '..' -import { AuthenticationMethod, ErrorMessage, WarningMessage } from '../interfaces' +import { fetchEnv, rootLogger } from '..' +import { + AuthenticationMethod, + ErrorMessage, + ErrorType, + PhoneNumberPrivacyRequest, + WarningMessage, +} from '../interfaces' import { FULL_NODE_TIMEOUT_IN_MS, RETRY_COUNT, RETRY_DELAY_IN_MS } from './constants' /* * Confirms that user is who they say they are and throws error on failure to confirm. * Authorization header should contain the EC signed body */ -export async function authenticateUser( - request: Request, +export async function authenticateUser( + request: Request<{}, {}, R>, contractKit: ContractKit, - logger: Logger + logger: Logger, + shouldFailOpen: boolean = false, + warnings: ErrorType[] = [] ): Promise { logger.debug('Authenticating user') @@ -36,9 +44,18 @@ export async function authenticateUser( let registeredEncryptionKey try { registeredEncryptionKey = await getDataEncryptionKey(signer, contractKit, logger) - } catch (error) { - logger.warn('Assuming request is authenticated') - return true + } catch (err) { + // getDataEncryptionKey should only throw if there is a full-node connection issue. + // That is, it does not throw if the DEK is undefined or invalid + logger.error( + { err, warning: ErrorMessage.FAILURE_TO_GET_DEK }, + shouldFailOpen ? ErrorMessage.FAILING_OPEN : ErrorMessage.FAILING_CLOSED + ) + warnings.push( + ErrorMessage.FAILURE_TO_GET_DEK, + shouldFailOpen ? ErrorMessage.FAILING_OPEN : ErrorMessage.FAILING_CLOSED + ) + return shouldFailOpen } if (!registeredEncryptionKey) { logger.warn({ account: signer }, 'Account does not have registered encryption key') @@ -57,12 +74,34 @@ export async function authenticateUser( // Fallback to previous signing pattern logger.info( - { account: signer }, + { account: signer, message, messageSignature }, 'Message was not authenticated with DEK, attempting to authenticate using wallet key' ) + // TODO This uses signature utils, why doesn't DEK authentication? + // (https://github.com/celo-org/celo-monorepo/issues/9803) return verifySignature(message, messageSignature, signer) } +export function getMessageDigest(message: string) { + // NOTE: Elliptic will truncate the raw msg to 64 bytes before signing, + // so make sure to always pass the hex encoded msgDigest instead. + return crypto.createHash('sha256').update(JSON.stringify(message)).digest('hex') +} + +// Used primarily for signing requests with a DEK, counterpart of verifyDEKSignature +// For general signing, use SignatureUtils in @celo/utils +export function signWithRawKey(msg: string, rawKey: string) { + // NOTE: elliptic is disabled elsewhere in this library to prevent + // accidental signing of truncated messages. + // tslint:disable-next-line:import-blacklist + const EC = require('elliptic').ec + const ec = new EC('secp256k1') + + // Sign + const key = ec.keyFromPrivate(hexToBuffer(rawKey)) + return JSON.stringify(key.sign(getMessageDigest(msg)).toDER()) +} + export function verifyDEKSignature( message: string, messageSignature: string, @@ -72,10 +111,8 @@ export function verifyDEKSignature( insecureAllowIncorrectlyGeneratedSignature: false, } ) { - logger = logger ?? rootLogger() + logger = logger ?? rootLogger(fetchEnv('SERVICE_NAME')) try { - const msgDigest = crypto.createHash('sha256').update(JSON.stringify(message)).digest('hex') - // NOTE: elliptic is disabled elsewhere in this library to prevent // accidental signing of truncated messages. // tslint:disable-next-line:import-blacklist @@ -83,14 +120,17 @@ export function verifyDEKSignature( const ec = new EC('secp256k1') const key = ec.keyFromPublic(trimLeading0x(registeredEncryptionKey), 'hex') const parsedSig = JSON.parse(messageSignature) - if (key.verify(msgDigest, parsedSig)) { + // TODO why do we use a different signing method instead of SignatureUtils? + // (https://github.com/celo-org/celo-monorepo/issues/9803) + if (key.verify(getMessageDigest(message), parsedSig)) { return true } - // TODO: Remove this once clients upgrade to @celo/identity v1.5.3 + // TODO(2.0.0, deployment): Remove this once clients upgrade to @celo/identity v1.5.3 // Due to an error in the original implementation of the sign and verify functions // used here, older clients may generate signatures over the truncated message, // instead of its hash. These signatures represent a risk to the signer as they do // not protect against modifications of the message past the first 64 characters of the message. + // (https://github.com/celo-org/celo-monorepo/issues/9802) if (insecureAllowIncorrectlyGeneratedSignature && key.verify(message, parsedSig)) { logger.warn(WarningMessage.INVALID_AUTH_SIGNATURE) return true @@ -123,7 +163,7 @@ export async function getDataEncryptionKey( return res } catch (error) { logger.error('Failed to retrieve DEK: ' + error) - logger.error(ErrorMessage.CONTRACT_GET_FAILURE) + logger.error(ErrorMessage.FULL_NODE_ERROR) throw error } } @@ -163,7 +203,7 @@ export async function isVerified( return res } catch (error) { logger.error('Failed to get verification status: ' + error) - logger.error(ErrorMessage.CONTRACT_GET_FAILURE) + logger.error(ErrorMessage.FULL_NODE_ERROR) logger.warn('Assuming user is verified') return true } diff --git a/packages/phone-number-privacy/common/src/utils/config-utils.ts b/packages/phone-number-privacy/common/src/utils/config.utils.ts similarity index 100% rename from packages/phone-number-privacy/common/src/utils/config-utils.ts rename to packages/phone-number-privacy/common/src/utils/config.utils.ts diff --git a/packages/phone-number-privacy/common/src/utils/constants.ts b/packages/phone-number-privacy/common/src/utils/constants.ts index e3a67639c13..c50f375e84d 100644 --- a/packages/phone-number-privacy/common/src/utils/constants.ts +++ b/packages/phone-number-privacy/common/src/utils/constants.ts @@ -1,8 +1,6 @@ -// a getContactMatches request with 300 phone numbers still fits under -// this limit. export const REASONABLE_BODY_CHAR_LIMIT: number = 16_000 export const DB_TIMEOUT = 1000 export const FULL_NODE_TIMEOUT_IN_MS = 1000 export const RETRY_COUNT = 5 export const RETRY_DELAY_IN_MS = 100 -export const MAX_BLOCK_DISCREPANCY_THRESHOLD = 3 +export const KEY_VERSION_HEADER = 'odis-key-version' // headers must be all lower case diff --git a/packages/phone-number-privacy/common/src/utils/contracts.ts b/packages/phone-number-privacy/common/src/utils/contracts.ts new file mode 100644 index 00000000000..f4952231f1f --- /dev/null +++ b/packages/phone-number-privacy/common/src/utils/contracts.ts @@ -0,0 +1,10 @@ +import { ContractKit, newKit, newKitWithApiKey } from '@celo/contractkit' + +export interface BlockchainConfig { + provider: string + apiKey?: string +} + +export function getContractKit(config: BlockchainConfig): ContractKit { + return config.apiKey ? newKitWithApiKey(config.provider, config.apiKey) : newKit(config.provider) +} diff --git a/packages/phone-number-privacy/common/src/utils/input-validation.ts b/packages/phone-number-privacy/common/src/utils/input-validation.ts index 3e2cb7b1e92..6ce052d0304 100644 --- a/packages/phone-number-privacy/common/src/utils/input-validation.ts +++ b/packages/phone-number-privacy/common/src/utils/input-validation.ts @@ -1,9 +1,10 @@ import { isValidAddress, trimLeading0x } from '@celo/utils/lib/address' import isBase64 from 'is-base64' import { - GetBlindedMessageSigRequest, - GetContactMatchesRequest, GetQuotaRequest, + LegacySignMessageRequest, + PnpQuotaRequest, + SignMessageRequest, } from '../interfaces' import { REASONABLE_BODY_CHAR_LIMIT } from './constants' @@ -11,25 +12,12 @@ export function hasValidAccountParam(requestBody: { account: string }): boolean return !!requestBody.account && isValidAddress(requestBody.account) } -export function hasValidUserPhoneNumberParam(requestBody: GetContactMatchesRequest): boolean { - return !!requestBody.userPhoneNumber && isValidObfuscatedPhoneNumber(requestBody.userPhoneNumber) -} - -export function hasValidContactPhoneNumbersParam(requestBody: GetContactMatchesRequest): boolean { - return ( - Array.isArray(requestBody.contactPhoneNumbers) && - requestBody.contactPhoneNumbers.length > 0 && - requestBody.contactPhoneNumbers.every((contact) => isValidObfuscatedPhoneNumber(contact)) - ) -} - -export function isBodyReasonablySized( - requestBody: GetBlindedMessageSigRequest | GetQuotaRequest -): boolean { +// Legacy message signing & quota requests extend the new types +export function isBodyReasonablySized(requestBody: SignMessageRequest | PnpQuotaRequest): boolean { return JSON.stringify(requestBody).length <= REASONABLE_BODY_CHAR_LIMIT } -export function hasValidBlindedPhoneNumberParam(requestBody: GetBlindedMessageSigRequest): boolean { +export function hasValidBlindedPhoneNumberParam(requestBody: SignMessageRequest): boolean { return ( !!requestBody.blindedQueryPhoneNumber && requestBody.blindedQueryPhoneNumber.length === 64 && @@ -38,19 +26,11 @@ export function hasValidBlindedPhoneNumberParam(requestBody: GetBlindedMessageSi } export function identifierIsValidIfExists( - requestBody: GetQuotaRequest | GetBlindedMessageSigRequest + requestBody: GetQuotaRequest | LegacySignMessageRequest ): boolean { return !requestBody.hashedPhoneNumber || isByte32(requestBody.hashedPhoneNumber) } -export function hasValidIdentifier(requestBody: GetContactMatchesRequest): boolean { - return !!requestBody.hashedPhoneNumber && isByte32(requestBody.hashedPhoneNumber) -} - -function isValidObfuscatedPhoneNumber(phoneNumber: string) { - return isBase64(phoneNumber) && Buffer.from(phoneNumber, 'base64').length === 32 -} - const hexString = new RegExp(/[0-9A-Fa-f]{32}/, 'i') function isByte32(hashedData: string): boolean { diff --git a/packages/phone-number-privacy/common/src/utils/key-version.ts b/packages/phone-number-privacy/common/src/utils/key-version.ts new file mode 100644 index 00000000000..07657663793 --- /dev/null +++ b/packages/phone-number-privacy/common/src/utils/key-version.ts @@ -0,0 +1,105 @@ +import Logger from 'bunyan' +import { Request } from 'express' +import { Response as FetchResponse } from 'node-fetch' +import { ErrorMessage, KEY_VERSION_HEADER, OdisRequest, WarningMessage } from '..' + +export interface KeyVersionInfo { + keyVersion: number + threshold: number + polynomial: string + pubKey: string +} + +export function requestHasValidKeyVersion( + request: Request<{}, {}, OdisRequest>, + logger: Logger +): boolean { + try { + getRequestKeyVersion(request, logger) + return true + } catch (err) { + logger.debug('Error caught in requestHasValidKeyVersion') + logger.debug(err) + return false + } +} + +export function getRequestKeyVersion( + request: Request<{}, {}, OdisRequest>, + logger: Logger +): number | undefined { + const keyVersionHeader = request.headers[KEY_VERSION_HEADER] + const keyVersion = parseKeyVersionFromHeader(keyVersionHeader) + + if (keyVersion === undefined) { + return undefined + } + if (!isValidKeyVersion(keyVersion)) { + logger.error({ keyVersionHeader }, WarningMessage.INVALID_KEY_VERSION_REQUEST) + throw new Error(WarningMessage.INVALID_KEY_VERSION_REQUEST) + } + + logger.info({ keyVersion }, 'Request has valid key version') + return keyVersion +} + +export function responseHasExpectedKeyVersion( + response: FetchResponse, + expectedKeyVersion: number, + logger: Logger +): boolean { + let responseKeyVersion + try { + responseKeyVersion = getResponseKeyVersion(response, logger) + } catch (err) { + logger.debug('Error caught in responseHasExpectedKeyVersion') + logger.debug(err) + return false + } + + if (responseKeyVersion !== expectedKeyVersion) { + logger.error( + { expectedKeyVersion, responseKeyVersion }, + ErrorMessage.INVALID_KEY_VERSION_RESPONSE + ) + return false + } + + return true +} + +export function getResponseKeyVersion(response: FetchResponse, logger: Logger): number | undefined { + const keyVersionHeader = response.headers.get(KEY_VERSION_HEADER) + const keyVersion = parseKeyVersionFromHeader(keyVersionHeader) + + if (keyVersion === undefined) { + return undefined + } + if (!isValidKeyVersion(keyVersion)) { + logger.error({ keyVersionHeader }, ErrorMessage.INVALID_KEY_VERSION_RESPONSE) + throw new Error(ErrorMessage.INVALID_KEY_VERSION_RESPONSE) + } + + logger.info({ keyVersion }, 'Response has valid key version') + return keyVersion +} + +function parseKeyVersionFromHeader( + keyVersionHeader: string | string[] | undefined | null +): number | undefined { + if (keyVersionHeader === undefined || keyVersionHeader === null) { + return undefined + } + + const keyVersionHeaderString = keyVersionHeader.toString().trim() + + if (!keyVersionHeaderString.length) { + return undefined + } + + return Number(keyVersionHeaderString) +} + +function isValidKeyVersion(keyVersion: number): boolean { + return Number.isInteger(keyVersion) && keyVersion >= 0 +} diff --git a/packages/phone-number-privacy/common/src/utils/logger.ts b/packages/phone-number-privacy/common/src/utils/logger.ts index 22464b7a7a9..0114aebb649 100644 --- a/packages/phone-number-privacy/common/src/utils/logger.ts +++ b/packages/phone-number-privacy/common/src/utils/logger.ts @@ -2,19 +2,18 @@ import Logger, { createLogger, levelFromName, LogLevelString, stdSerializers } f import bunyanDebugStream from 'bunyan-debug-stream' import { createStream } from 'bunyan-gke-stackdriver' import { NextFunction, Request, Response } from 'express' -import { WarningMessage } from '../interfaces/error-utils' -import { fetchEnv, fetchEnvOrDefault } from './config-utils' +import { WarningMessage } from '../interfaces/errors' +import { fetchEnvOrDefault } from './config.utils' let _rootLogger: Logger | undefined -export function rootLogger(): Logger { +export function rootLogger(serviceName: string): Logger { if (_rootLogger !== undefined) { return _rootLogger } const logLevel = fetchEnvOrDefault('LOG_LEVEL', 'info') as LogLevelString const logFormat = fetchEnvOrDefault('LOG_FORMAT', 'human') - const serviceName = fetchEnv('SERVICE_NAME') let stream: any switch (logFormat) { @@ -30,28 +29,32 @@ export function rootLogger(): Logger { } _rootLogger = createLogger({ - name: serviceName, + name: serviceName ?? '', serializers: stdSerializers, streams: [stream], }) return _rootLogger } -export function loggerMiddleware(req: Request, res: Response, next?: NextFunction): Logger { - const requestLogger = rootLogger().child({ - endpoint: req.path, - sessionID: req.body.sessionID, // May be undefined - }) - res.locals.logger = requestLogger - - if (!req.body.sessionID && req.path !== '/metrics' && req.path !== '/status') { - requestLogger.info(WarningMessage.MISSING_SESSION_ID) - } - - if (next) { - next() +export function loggerMiddleware( + serviceName: string +): (req: Request, res: Response, next?: NextFunction) => Logger { + return (req, res, next) => { + const requestLogger = rootLogger(serviceName).child({ + endpoint: req.path, + sessionID: req.body.sessionID, // May be undefined + }) + res.locals.logger = requestLogger + + if (!req.body.sessionID && req.path !== '/metrics' && req.path !== '/status') { + requestLogger.info(WarningMessage.MISSING_SESSION_ID) + } + + if (next) { + next() + } + return requestLogger } - return requestLogger } export function genSessionID() { diff --git a/packages/phone-number-privacy/common/src/utils/responses.utils.ts b/packages/phone-number-privacy/common/src/utils/responses.utils.ts new file mode 100644 index 00000000000..467acb1b42c --- /dev/null +++ b/packages/phone-number-privacy/common/src/utils/responses.utils.ts @@ -0,0 +1,20 @@ +import Logger from 'bunyan' +import { Response } from 'express' +import { OdisRequest, OdisResponse, WarningMessage } from '..' + +export function send< + I extends OdisRequest = OdisRequest, + O extends OdisResponse = OdisResponse +>(response: Response, body: O, status: number, logger: Logger) { + if (!body.success) { + if (body.error in WarningMessage) { + logger.warn({ error: body.error, status, body }, 'Responding with warning') + } else { + logger.error({ error: body.error, status, body }, 'Responding with error') + } + } else { + logger.info({ status, body }, 'Responding with success') + } + response.status(status).json(body) + logger.info('Completed send') +} diff --git a/packages/phone-number-privacy/common/src/domains/domains.test.ts b/packages/phone-number-privacy/common/test/domains.test.ts similarity index 88% rename from packages/phone-number-privacy/common/src/domains/domains.test.ts rename to packages/phone-number-privacy/common/test/domains.test.ts index 08e70cfb216..29ce1affc08 100644 --- a/packages/phone-number-privacy/common/src/domains/domains.test.ts +++ b/packages/phone-number-privacy/common/test/domains.test.ts @@ -6,9 +6,9 @@ import { noNumber, noString, } from '@celo/utils/lib/sign-typed-data-utils' -import { DomainIdentifiers } from './constants' -import { Domain, domainEIP712, DomainOptions } from './domains' -import { SequentialDelayDomain } from './sequential-delay' +import { DomainIdentifiers } from '../src/domains/constants' +import { Domain, domainEIP712, DomainOptions } from '../src/domains/domains' +import { SequentialDelayDomain } from '../src/domains/sequential-delay' // Compile-time check that Domain can be cast to type EIP712Object export const TEST_DOMAIN_IS_EIP712: EIP712Object = ({} as unknown) as Domain diff --git a/packages/phone-number-privacy/common/test/interfaces/requests.test.ts b/packages/phone-number-privacy/common/test/interfaces/requests.test.ts index 32b27aacae4..cb7116ffdec 100644 --- a/packages/phone-number-privacy/common/test/interfaces/requests.test.ts +++ b/packages/phone-number-privacy/common/test/interfaces/requests.test.ts @@ -3,8 +3,8 @@ import { EIP712Object, generateTypedDataHash, noBool, - noString, noNumber, + noString, } from '@celo/utils/lib/sign-typed-data-utils' import { LocalWallet } from '@celo/wallet-local' import { @@ -14,18 +14,19 @@ import { SequentialDelayDomainSchema, } from '../../src/domains' import { - DomainRestrictedSignatureRequest, - domainRestrictedSignatureRequestEIP712, - domainRestrictedSignatureRequestSchema, - DomainQuotaStatusRequest, - domainQuotaStatusRequestEIP712, - domainQuotaStatusRequestSchema, DisableDomainRequest, disableDomainRequestEIP712, disableDomainRequestSchema, - verifyDisableDomainRequestSignature, - verifyDomainQuotaStatusRequestSignature, - verifyDomainRestrictedSignatureRequestSignature, + DomainQuotaStatusRequest, + domainQuotaStatusRequestEIP712, + domainQuotaStatusRequestSchema, + DomainRequestTypeTag, + DomainRestrictedSignatureRequest, + domainRestrictedSignatureRequestEIP712, + domainRestrictedSignatureRequestSchema, + verifyDisableDomainRequestAuthenticity, + verifyDomainQuotaStatusRequestAuthenticity, + verifyDomainRestrictedSignatureRequestAuthenticity, } from '../../src/interfaces/requests' // Compile-time check that DomainRestrictedSignatureRequest can be cast to type EIP712Object. @@ -46,6 +47,7 @@ TEST_DISABLE_DOMAIN_REQUEST_IS_EIP712 = ({} as unknown) as DisableDomainRequest< describe('domainRestrictedSignatureRequestEIP712()', () => { it('should generate the correct type data for request with SequentialDelayDomain', () => { const request: DomainRestrictedSignatureRequest = { + type: DomainRequestTypeTag.SIGN, domain: { name: DomainIdentifiers.SequentialDelay, version: '1', @@ -60,7 +62,7 @@ describe('domainRestrictedSignatureRequestEIP712()', () => { blindedMessage: '', sessionID: noString, } - const expectedHash = 'bc958fdbf83dfa7253b9ad1d9a8c5a803617f7acbed9684ff4fda669647956b5' + const expectedHash = '9914e6bc3bd0d63727eeae4008654920b9879654f7159b1d5ab33768e61f56df' const typedData = domainRestrictedSignatureRequestEIP712(request) // console.debug(JSON.stringify(typedData, null, 2)) expect(generateTypedDataHash(typedData).toString('hex')).toEqual(expectedHash) @@ -70,6 +72,7 @@ describe('domainRestrictedSignatureRequestEIP712()', () => { describe('domainQuotaStatusRequestEIP712()', () => { it('should generate the correct type data for request with SequentialDelayDomain', () => { const request: DomainQuotaStatusRequest = { + type: DomainRequestTypeTag.QUOTA, domain: { name: DomainIdentifiers.SequentialDelay, version: '1', @@ -83,7 +86,7 @@ describe('domainQuotaStatusRequestEIP712()', () => { }, sessionID: noString, } - const expectedHash = '7fcd55bc848bb89bb14cee5f5b08a4ae3224b26fbffb86385e2b64056862de62' + const expectedHash = '0c1545b83f28d8d0f24886fa0d21ac540af706dd6f9ee6d045bac17780a2656e' const typedData = domainQuotaStatusRequestEIP712(request) //console.debug(JSON.stringify(typedData, null, 2)) expect(generateTypedDataHash(typedData).toString('hex')).toEqual(expectedHash) @@ -93,6 +96,7 @@ describe('domainQuotaStatusRequestEIP712()', () => { describe('disableDomainRequestEIP712()', () => { it('should generate the correct type data for request with SequentialDelayDomain', () => { const request: DisableDomainRequest = { + type: DomainRequestTypeTag.DISABLE, domain: { name: DomainIdentifiers.SequentialDelay, version: '1', @@ -106,7 +110,7 @@ describe('disableDomainRequestEIP712()', () => { }, sessionID: noString, } - const expectedHash = '150d96add3ad0c9ec4f72638fd1e452fb477c7aedde09bc3c67fa2611cbdc581' + const expectedHash = 'd30be7d1b1bb3a9a0b2b2148d9ea3fcae7775dc31ce984d658f90295887a323a' const typedData = disableDomainRequestEIP712(request) console.debug(JSON.stringify(typedData, null, 2)) expect(generateTypedDataHash(typedData).toString('hex')).toEqual(expectedHash) @@ -144,6 +148,7 @@ const manipulatedDomain: SequentialDelayDomain = { } const signatureRequest: DomainRestrictedSignatureRequest = { + type: DomainRequestTypeTag.SIGN, domain: authenticatedDomain, options: { signature: noString, @@ -154,6 +159,7 @@ const signatureRequest: DomainRestrictedSignatureRequest } const quotaRequest: DomainQuotaStatusRequest = { + type: DomainRequestTypeTag.QUOTA, domain: authenticatedDomain, options: { signature: noString, @@ -163,6 +169,7 @@ const quotaRequest: DomainQuotaStatusRequest = { } const disableRequest: DisableDomainRequest = { + type: DomainRequestTypeTag.DISABLE, domain: authenticatedDomain, options: { signature: noString, @@ -175,20 +182,20 @@ const verifyCases = [ { request: signatureRequest, typedDataBuilder: domainRestrictedSignatureRequestEIP712, - verifier: verifyDomainRestrictedSignatureRequestSignature, - name: 'verifyDomainRestrictedSignatureRequestSignature()', + verifier: verifyDomainRestrictedSignatureRequestAuthenticity, + name: 'verifyDomainRestrictedSignatureRequestAuthenticity()', }, { request: quotaRequest, typedDataBuilder: domainQuotaStatusRequestEIP712, - verifier: verifyDomainQuotaStatusRequestSignature, - name: 'verifyDomainQuotaStatusRequestSignature()', + verifier: verifyDomainQuotaStatusRequestAuthenticity, + name: 'verifyDomainQuotaStatusRequestAuthenticity()', }, { request: disableRequest, typedDataBuilder: disableDomainRequestEIP712, - verifier: verifyDisableDomainRequestSignature, - name: 'verifyDisableDomainRequestSignature()', + verifier: verifyDisableDomainRequestAuthenticity, + name: 'verifyDisableDomainRequestAuthenticity()', }, ] diff --git a/packages/phone-number-privacy/common/test/poprf.test.ts b/packages/phone-number-privacy/common/test/poprf.test.ts index f5e6379be2e..474d324c8fa 100644 --- a/packages/phone-number-privacy/common/test/poprf.test.ts +++ b/packages/phone-number-privacy/common/test/poprf.test.ts @@ -1,11 +1,11 @@ +import * as poprf from '@celo/poprf' import { - PoprfCombiner, PoprfClient, + PoprfCombiner, PoprfServer, ThresholdPoprfClient, ThresholdPoprfServer, } from '../src/poprf' -import * as poprf from '@celo/poprf' const TEST_POPRF_KEYPAIR = poprf.keygen(Buffer.from('TEST POPRF KEYPAIR SEED')) const TEST_THRESHOLD_N = 3 @@ -92,7 +92,11 @@ describe('end-to-end', () => { (i) => new ThresholdPoprfServer(TEST_THRESHOLD_POPRF_KEYS.getShare(i)) ) const combiner = new PoprfCombiner(TEST_THRESHOLD_T) - const client = new PoprfClient(TEST_POPRF_KEYPAIR.publicKey, TEST_TAG_A, TEST_MESSAGE_A) + const client = new PoprfClient( + TEST_THRESHOLD_POPRF_KEYS.thresholdPublicKey, + TEST_TAG_A, + TEST_MESSAGE_A + ) const blindedPartials = servers.map((s) => s.blindPartialEval(client.tag, client.blindedMessage) @@ -106,7 +110,7 @@ describe('end-to-end', () => { // POPRF hashed outputs should be 32 bytes. expect(evaluation.length).toEqual(32) - expect(evaluation.toString('base64')).toEqual('5xHueBbMK1wfm7VyrPYJJAhOrV8X0rP0hz7gRxTLEcA=') + expect(evaluation.toString('base64')).toEqual('C1jKGStMWC3lNpYDV61D+3waetY0bHlD4ElYzV+Isqc=') }) it('successfully completes client-server exchange with threshold client and server', () => { @@ -139,6 +143,6 @@ describe('end-to-end', () => { // POPRF hashed outputs should be 32 bytes. expect(evaluation.length).toEqual(32) - expect(evaluation.toString('base64')).toEqual('5xHueBbMK1wfm7VyrPYJJAhOrV8X0rP0hz7gRxTLEcA=') + expect(evaluation.toString('base64')).toEqual('C1jKGStMWC3lNpYDV61D+3waetY0bHlD4ElYzV+Isqc=') }) }) diff --git a/packages/phone-number-privacy/common/test/utils/authentication.test.ts b/packages/phone-number-privacy/common/test/utils/authentication.test.ts index 431d244c7f5..a90001ba1bf 100644 --- a/packages/phone-number-privacy/common/test/utils/authentication.test.ts +++ b/packages/phone-number-privacy/common/test/utils/authentication.test.ts @@ -2,7 +2,7 @@ import { hexToBuffer } from '@celo/base' import { ContractKit } from '@celo/contractkit' import Logger from 'bunyan' import { Request } from 'express' -import { signWithRawKey } from '../../../../sdk/identity/src/odis/query' +import { ErrorMessage, ErrorType } from '../../lib' import { AuthenticationMethod } from '../../src/interfaces/requests' import * as auth from '../../src/utils/authentication' @@ -22,9 +22,18 @@ describe('Authentication test suite', () => { } as Request const mockContractKit = {} as ContractKit - const result = await auth.authenticateUser(sampleRequest, mockContractKit, logger) + const warnings: ErrorType[] = [] - expect(result).toBe(false) + const success = await auth.authenticateUser( + sampleRequest, + mockContractKit, + logger, + true, + warnings + ) + + expect(success).toBe(false) + expect(warnings).toEqual([]) }) it('Should fail authentication with missing signer', async () => { @@ -34,12 +43,21 @@ describe('Authentication test suite', () => { } as Request const mockContractKit = {} as ContractKit - const result = await auth.authenticateUser(sampleRequest, mockContractKit, logger) + const warnings: ErrorType[] = [] - expect(result).toBe(false) + const success = await auth.authenticateUser( + sampleRequest, + mockContractKit, + logger, + true, + warnings + ) + + expect(success).toBe(false) + expect(warnings).toEqual([]) }) - it('Should succeed authentication with error in getDataEncryptionKey', async () => { + it('Should succeed authentication with error in getDataEncryptionKey when shouldFailOpen is true', async () => { const sampleRequest: Request = { get: (name: string) => (name === 'Authorization' ? 'Test' : ''), body: { @@ -49,9 +67,42 @@ describe('Authentication test suite', () => { } as Request const mockContractKit = {} as ContractKit - const result = await auth.authenticateUser(sampleRequest, mockContractKit, logger) + const warnings: ErrorType[] = [] - expect(result).toBe(true) + const success = await auth.authenticateUser( + sampleRequest, + mockContractKit, + logger, + true, + warnings + ) + + expect(success).toBe(true) + expect(warnings).toEqual([ErrorMessage.FAILURE_TO_GET_DEK, ErrorMessage.FAILING_OPEN]) + }) + + it('Should fail authentication with error in getDataEncryptionKey when shouldFailOpen is false', async () => { + const sampleRequest: Request = { + get: (name: string) => (name === 'Authorization' ? 'Test' : ''), + body: { + account: '0xc1912fee45d61c87cc5ea59dae31190fffff232d', + authenticationMethod: AuthenticationMethod.ENCRYPTION_KEY, + }, + } as Request + const mockContractKit = {} as ContractKit + + const warnings: ErrorType[] = [] + + const success = await auth.authenticateUser( + sampleRequest, + mockContractKit, + logger, + false, + warnings + ) + + expect(success).toBe(false) + expect(warnings).toEqual([ErrorMessage.FAILURE_TO_GET_DEK, ErrorMessage.FAILING_CLOSED]) }) it('Should fail authentication when key is not registered', async () => { @@ -74,9 +125,18 @@ describe('Authentication test suite', () => { }, } as ContractKit - const result = await auth.authenticateUser(sampleRequest, mockContractKit, logger) + const warnings: ErrorType[] = [] - expect(result).toBe(false) + const success = await auth.authenticateUser( + sampleRequest, + mockContractKit, + logger, + true, + warnings + ) + + expect(success).toBe(false) + expect(warnings).toEqual([]) }) it('Should fail authentication when key is registered but not valid', async () => { @@ -99,9 +159,12 @@ describe('Authentication test suite', () => { }, } as ContractKit - const result = await auth.authenticateUser(sampleRequest, mockContractKit, logger) + const warnings: ErrorType[] = [] - expect(result).toBe(false) + const success = await auth.authenticateUser(sampleRequest, mockContractKit, logger) + + expect(success).toBe(false) + expect(warnings).toEqual([]) }) it('Should succeed authentication when key is registered and valid', async () => { @@ -110,7 +173,7 @@ describe('Authentication test suite', () => { account: '0xc1912fee45d61c87cc5ea59dae31190fffff232d', authenticationMethod: AuthenticationMethod.ENCRYPTION_KEY, } - const sig = signWithRawKey(JSON.stringify(body), rawKey) + const sig = auth.signWithRawKey(JSON.stringify(body), rawKey) const sampleRequest: Request = { get: (name: string) => (name === 'Authorization' ? sig : ''), body, @@ -133,9 +196,18 @@ describe('Authentication test suite', () => { }, } as ContractKit - const result = await auth.authenticateUser(sampleRequest, mockContractKit, logger) + const warnings: ErrorType[] = [] - expect(result).toBe(true) + const success = await auth.authenticateUser( + sampleRequest, + mockContractKit, + logger, + true, + warnings + ) + + expect(success).toBe(true) + expect(warnings).toEqual([]) }) it('Should fail authentication when the message is manipulated', async () => { @@ -152,7 +224,7 @@ describe('Authentication test suite', () => { message.slice(0, i) + String.fromCharCode(message.charCodeAt(i) + 1) + message.slice(i + 1) - const sig = signWithRawKey(modified, rawKey) + const sig = auth.signWithRawKey(modified, rawKey) const sampleRequest: Request = { get: (name: string) => (name === 'Authorization' ? sig : ''), body, @@ -175,9 +247,18 @@ describe('Authentication test suite', () => { }, } as ContractKit - const result = await auth.authenticateUser(sampleRequest, mockContractKit, logger) + const warnings: ErrorType[] = [] + + const success = await auth.authenticateUser( + sampleRequest, + mockContractKit, + logger, + true, + warnings + ) - expect(result).toBe(false) + expect(success).toBe(false) + expect(warnings).toEqual([]) } }) @@ -187,7 +268,7 @@ describe('Authentication test suite', () => { account: '0xc1912fee45d61c87cc5ea59dae31190fffff232d', authenticationMethod: AuthenticationMethod.ENCRYPTION_KEY, } - const sig = signWithRawKey(JSON.stringify(body), rawKey) + const sig = auth.signWithRawKey(JSON.stringify(body), rawKey) const sampleRequest: Request = { get: (name: string) => (name === 'Authorization' ? sig : ''), body, @@ -212,9 +293,18 @@ describe('Authentication test suite', () => { }, } as ContractKit - const result = await auth.authenticateUser(sampleRequest, mockContractKit, logger) + const warnings: ErrorType[] = [] - expect(result).toBe(false) + const success = await auth.authenticateUser( + sampleRequest, + mockContractKit, + logger, + true, + warnings + ) + + expect(success).toBe(false) + expect(warnings).toEqual([]) }) it('Should fail authentication when the sigature is modified', async () => { @@ -224,7 +314,7 @@ describe('Authentication test suite', () => { authenticationMethod: AuthenticationMethod.ENCRYPTION_KEY, } // Manipulate the signature. - const sig = signWithRawKey(JSON.stringify(body), rawKey) + const sig = auth.signWithRawKey(JSON.stringify(body), rawKey) const modified = JSON.stringify([0] + JSON.parse(sig)) const sampleRequest: Request = { get: (name: string) => (name === 'Authorization' ? modified : ''), @@ -250,13 +340,23 @@ describe('Authentication test suite', () => { }, } as ContractKit - const result = await auth.authenticateUser(sampleRequest, mockContractKit, logger) + const warnings: ErrorType[] = [] - expect(result).toBe(false) + const success = await auth.authenticateUser( + sampleRequest, + mockContractKit, + logger, + true, + warnings + ) + + expect(success).toBe(false) + expect(warnings).toEqual([]) }) // Backwards compatibility check - // TODO: Remove this once clients upgrade to @celo/identity v1.5.3 + // TODO(2.0.0, deployment): Remove this once clients upgrade to @celo/identity v1.5.3 + // (https://github.com/celo-org/celo-monorepo/issues/9802) it('Should succeed authentication when key is registered and valid and signature is incorrectly generated', async () => { const rawKey = '41e8e8593108eeedcbded883b8af34d2f028710355c57f4c10a056b72486aa04' const body = { @@ -287,9 +387,18 @@ describe('Authentication test suite', () => { }, } as ContractKit - const result = await auth.authenticateUser(sampleRequest, mockContractKit, logger) + const warnings: ErrorType[] = [] - expect(result).toBe(true) + const success = await auth.authenticateUser( + sampleRequest, + mockContractKit, + logger, + true, + warnings + ) + + expect(success).toBe(true) + expect(warnings).toEqual([]) }) }) diff --git a/packages/phone-number-privacy/common/test/utils/input-validation.test.ts b/packages/phone-number-privacy/common/test/utils/input-validation.test.ts index 4102d31a033..1ec3ba1fd53 100644 --- a/packages/phone-number-privacy/common/test/utils/input-validation.test.ts +++ b/packages/phone-number-privacy/common/test/utils/input-validation.test.ts @@ -1,81 +1,8 @@ -import * as utils from '../../src/utils/input-validation' -import { - GetBlindedMessageSigRequest, - GetContactMatchesRequest, - GetQuotaRequest, -} from '../../src/interfaces' +import { GetQuotaRequest, LegacySignMessageRequest } from '../../src/interfaces' import { REASONABLE_BODY_CHAR_LIMIT } from '../../src/utils/constants' +import * as utils from '../../src/utils/input-validation' describe('Input Validation test suite', () => { - describe('hasValidIdentifier utility', () => { - it('Should return false with empty phone number', () => { - const sampleData: GetContactMatchesRequest = { - account: 'account', - contactPhoneNumbers: [], - userPhoneNumber: 'number', - hashedPhoneNumber: '', - } - - const result = utils.hasValidIdentifier(sampleData) - - expect(result).toBeFalsy() - }) - - it('Should return false with non-hex phone number', () => { - const sampleData: GetContactMatchesRequest = { - account: 'account', - contactPhoneNumbers: [], - userPhoneNumber: 'number', - hashedPhoneNumber: '0xTESTTESTTESTTESTTESTTESTTESTTESTTESTTESTTESTTESTTESTTESTTESTTEST', - } - - const result = utils.hasValidIdentifier(sampleData) - - expect(result).toBeFalsy() - }) - - it('Should return true with hex phone number', () => { - const sampleData: GetContactMatchesRequest = { - account: 'account', - contactPhoneNumbers: [], - userPhoneNumber: 'number', - hashedPhoneNumber: '0x0000123400001234000012340000123400001234000012340000123400001234', - } - - const result = utils.hasValidIdentifier(sampleData) - - expect(result).toBeTruthy() - }) - }) - - describe('identifierIsValidIfExists utility', () => { - it('Should return true with empty phone number', () => { - const sampleData: GetContactMatchesRequest = { - account: 'account', - contactPhoneNumbers: [], - userPhoneNumber: 'number', - hashedPhoneNumber: '', - } - - const result = utils.identifierIsValidIfExists(sampleData) - - expect(result).toBeTruthy() - }) - - it('Should return true with valid phone number', () => { - const sampleData: GetContactMatchesRequest = { - account: 'account', - contactPhoneNumbers: [], - userPhoneNumber: 'number', - hashedPhoneNumber: '0x0000123400001234000012340000123400001234000012340000123400001234', - } - - const result = utils.identifierIsValidIfExists(sampleData) - - expect(result).toBeTruthy() - }) - }) - describe('isBodyReasonablySized utility', () => { it('Should return true with small body', () => { const sampleData: GetQuotaRequest = { @@ -132,78 +59,9 @@ describe('Input Validation test suite', () => { }) }) - describe('hasValidUserPhoneNumberParam utility', () => { - it('Should return true for proper phone number', () => { - const sampleData: GetContactMatchesRequest = { - userPhoneNumber: Buffer.from('1912fee45d61c87cc5ea59dae31190ff').toString('base64'), - hashedPhoneNumber: 'hash', - contactPhoneNumbers: [], - account: '', - } - - const result = utils.hasValidUserPhoneNumberParam(sampleData) - - expect(result).toBeTruthy() - }) - - it('Should return false with wrong phone number', () => { - const sampleData: GetContactMatchesRequest = { - userPhoneNumber: Buffer.from('z').toString('base64'), - hashedPhoneNumber: 'hash', - contactPhoneNumbers: [], - account: '', - } - - const result = utils.hasValidUserPhoneNumberParam(sampleData) - - expect(result).toBeFalsy() - }) - }) - - describe('hasValidContactPhoneNumbersParam utility', () => { - it('Should return true for proper contact phone numbers', () => { - const sampleData: GetContactMatchesRequest = { - userPhoneNumber: 'phone', - hashedPhoneNumber: 'hash', - contactPhoneNumbers: [Buffer.from('1912fee45d61c87cc5ea59dae31190ff').toString('base64')], - account: '', - } - - const result = utils.hasValidContactPhoneNumbersParam(sampleData) - - expect(result).toBeTruthy() - }) - - it('Should return false for wrong contact phone number', () => { - const sampleData: GetContactMatchesRequest = { - userPhoneNumber: 'phone', - hashedPhoneNumber: 'hash', - contactPhoneNumbers: [Buffer.from('zz').toString('base64')], - account: '', - } - - const result = utils.hasValidContactPhoneNumbersParam(sampleData) - - expect(result).toBeFalsy() - }) - - it('Should return false for missing contact phone number', () => { - const sampleData: GetContactMatchesRequest = { - userPhoneNumber: 'phone', - hashedPhoneNumber: 'hash', - contactPhoneNumbers: [], - account: '', - } - - const result = utils.hasValidContactPhoneNumbersParam(sampleData) - - expect(result).toBeFalsy() - }) - }) - describe('hasValidBlindedPhoneNumberParam utility', () => { it('Should return true for blinded query', () => { - const sampleData: GetBlindedMessageSigRequest = { + const sampleData: LegacySignMessageRequest = { blindedQueryPhoneNumber: Buffer.from( '1912fee45d61c87cc5ea59dae31190ff1912fee45d61c8' ).toString('base64'), @@ -216,7 +74,7 @@ describe('Input Validation test suite', () => { }) it('Should return false for not base64 query', () => { - const sampleData: GetBlindedMessageSigRequest = { + const sampleData: LegacySignMessageRequest = { blindedQueryPhoneNumber: Buffer.from( 'JanAdamMickiewicz1234!@JanAdamMickiewicz1234!@123412345678901234' ).toString('utf-8'), @@ -229,7 +87,7 @@ describe('Input Validation test suite', () => { }) it('Should return false for too short blinded query', () => { - const sampleData: GetBlindedMessageSigRequest = { + const sampleData: LegacySignMessageRequest = { blindedQueryPhoneNumber: Buffer.from('1912fee45d61c87cc5e').toString('base64'), account: 'acc', } @@ -240,7 +98,7 @@ describe('Input Validation test suite', () => { }) it('Should return false for missing param in query', () => { - const sampleData: GetBlindedMessageSigRequest = { + const sampleData: LegacySignMessageRequest = { blindedQueryPhoneNumber: '', account: 'acc', } diff --git a/packages/phone-number-privacy/common/test/utils/key-version.test.ts b/packages/phone-number-privacy/common/test/utils/key-version.test.ts new file mode 100644 index 00000000000..46db29d71e4 --- /dev/null +++ b/packages/phone-number-privacy/common/test/utils/key-version.test.ts @@ -0,0 +1,228 @@ +import { Request } from 'express' +import { Response as FetchResponse } from 'node-fetch' +import { + ErrorMessage, + getRequestKeyVersion, + getResponseKeyVersion, + KEY_VERSION_HEADER, + requestHasValidKeyVersion, + responseHasExpectedKeyVersion, + rootLogger, + WarningMessage, +} from '../../src' + +describe('key version test suite', () => { + const logger = rootLogger('key version test suite') + + const request = { + headers: {}, + } as Request + + let response: FetchResponse + + const invalidKeyVersionHeaders: (string | string[])[] = [ + 'a', + '-1', + '1.5', + '1a', + 'blah', + 'one', + '-', + '+', + ['1', '2', '3'], + ' . ', + ] + + beforeEach(() => { + delete request.headers[KEY_VERSION_HEADER] + response = new FetchResponse() + }) + + describe(getRequestKeyVersion, () => { + it(`Should return undefined if key version header has not been set`, () => { + const res = getRequestKeyVersion(request, logger) + expect(res).toBe(undefined) + }) + + it(`Should return undefined if key version header is undefined`, () => { + request.headers[KEY_VERSION_HEADER] = undefined + const res = getRequestKeyVersion(request, logger) + expect(res).toBe(undefined) + }) + + it(`Should return undefined if key version header is empty`, () => { + request.headers[KEY_VERSION_HEADER] = '' + const res = getRequestKeyVersion(request, logger) + expect(res).toBe(undefined) + }) + + it(`Should return undefined if key version header is whitespace`, () => { + request.headers[KEY_VERSION_HEADER] = ' ' + const res = getRequestKeyVersion(request, logger) + expect(res).toBe(undefined) + }) + + for (let kv = 0; kv <= 10; kv++) { + it(`Should return valid key version header ${kv}`, () => { + request.headers[KEY_VERSION_HEADER] = kv.toString() + const res = getRequestKeyVersion(request, logger) + expect(res).toBe(kv) + }) + } + + it(`Should return valid key version header when there's whitespace`, () => { + request.headers[KEY_VERSION_HEADER] = ' 1 ' + const res = getRequestKeyVersion(request, logger) + expect(res).toBe(1) + }) + + invalidKeyVersionHeaders.forEach((kv) => { + it(`Should throw for invalid key version ${kv}`, () => { + request.headers[KEY_VERSION_HEADER] = kv.toString() + expect(() => getRequestKeyVersion(request, logger)).toThrow( + WarningMessage.INVALID_KEY_VERSION_REQUEST + ) + }) + }) + }) + + describe(requestHasValidKeyVersion, () => { + it(`Should return true if key version header has not been set`, () => { + const res = requestHasValidKeyVersion(request, logger) + expect(res).toBe(true) + }) + it(`Should return true if key version header is undefined`, () => { + request.headers[KEY_VERSION_HEADER] = undefined + const res = requestHasValidKeyVersion(request, logger) + expect(res).toBe(true) + }) + it(`Should return true if key version header is empty`, () => { + request.headers[KEY_VERSION_HEADER] = '' + const res = requestHasValidKeyVersion(request, logger) + expect(res).toBe(true) + }) + + it(`Should return true if key version header is whitespace`, () => { + request.headers[KEY_VERSION_HEADER] = ' ' + const res = requestHasValidKeyVersion(request, logger) + expect(res).toBe(true) + }) + + for (let kv = 0; kv <= 10; kv++) { + it(`Should return true for valid key version header ${kv}`, () => { + request.headers[KEY_VERSION_HEADER] = kv.toString() + const res = requestHasValidKeyVersion(request, logger) + expect(res).toBe(true) + }) + } + + it(`Should return true for valid key version header when there's whitespace`, () => { + request.headers[KEY_VERSION_HEADER] = ' 1 ' + const res = requestHasValidKeyVersion(request, logger) + expect(res).toBe(true) + }) + + invalidKeyVersionHeaders.forEach((kv) => { + it(`Should return false for invalid key version ${kv}`, () => { + request.headers[KEY_VERSION_HEADER] = kv.toString() + const res = requestHasValidKeyVersion(request, logger) + expect(res).toBe(false) + }) + }) + }) + + describe(getResponseKeyVersion, () => { + it(`Should return undefined if key version header has not been set`, () => { + const res = getResponseKeyVersion(response, logger) + expect(res).toBe(undefined) + }) + it(`Should return undefined if key version header is undefined`, () => { + response.headers.delete(KEY_VERSION_HEADER) + const res = getResponseKeyVersion(response, logger) + expect(res).toBe(undefined) + }) + + it(`Should return undefined if key version header is empty`, () => { + response.headers.set(KEY_VERSION_HEADER, '') + const res = getResponseKeyVersion(response, logger) + expect(res).toBe(undefined) + }) + + it(`Should return undefined if key version header is whitespace`, () => { + response.headers.set(KEY_VERSION_HEADER, ' ') + const res = getResponseKeyVersion(response, logger) + expect(res).toBe(undefined) + }) + + for (let kv = 0; kv <= 10; kv++) { + it(`Should return valid key version header ${kv}`, () => { + response.headers.set(KEY_VERSION_HEADER, kv.toString()) + const res = getResponseKeyVersion(response, logger) + expect(res).toBe(kv) + }) + } + + it(`Should return valid key version header when there's whitespace`, () => { + response.headers.set(KEY_VERSION_HEADER, ' 1 ') + const res = getResponseKeyVersion(response, logger) + expect(res).toBe(1) + }) + + invalidKeyVersionHeaders.forEach((kv) => { + it(`Should throw for invalid key version ${kv}`, () => { + response.headers.set(KEY_VERSION_HEADER, kv.toString()) + expect(() => getResponseKeyVersion(response, logger)).toThrow( + ErrorMessage.INVALID_KEY_VERSION_RESPONSE + ) + }) + }) + }) + + describe(responseHasExpectedKeyVersion, () => { + const testCases = [ + { + responseKeyVersion: 1, + expectedKeyVersion: 1, + expectedResult: true, + }, + { + responseKeyVersion: 2, + expectedKeyVersion: 1, + expectedResult: false, + }, + { + responseKeyVersion: undefined, + expectedKeyVersion: 1, + expectedResult: false, + }, + { + responseKeyVersion: -1, + expectedKeyVersion: -1, + expectedResult: false, + }, + { + responseKeyVersion: 1.5, + expectedKeyVersion: 1.5, + expectedResult: false, + }, + { + responseKeyVersion: 'a', + expectedKeyVersion: Number('a'), + expectedResult: false, + }, + ] + + testCases.forEach((testCase) => { + it(JSON.stringify(testCase), () => { + const { responseKeyVersion, expectedKeyVersion, expectedResult } = testCase + if (responseKeyVersion === undefined) { + response.headers.delete(KEY_VERSION_HEADER) + } else { + response.headers.set(KEY_VERSION_HEADER, responseKeyVersion.toString()) + } + const res = responseHasExpectedKeyVersion(response, expectedKeyVersion, logger) + expect(res).toBe(expectedResult) + }) + }) + }) +}) diff --git a/packages/phone-number-privacy/common/test/utils/sequential-delay.test.ts b/packages/phone-number-privacy/common/test/utils/sequential-delay.test.ts index c833d0eeb29..decd72f9de8 100644 --- a/packages/phone-number-privacy/common/test/utils/sequential-delay.test.ts +++ b/packages/phone-number-privacy/common/test/utils/sequential-delay.test.ts @@ -40,14 +40,14 @@ describe('Sequential Delay Test Suite', () => { expectedResult: { accepted: false, notBefore: 0, - state: undefined, + state: { timer: 0, counter: 0, disabled: false, now: t - 1 }, }, }, { timestamp: t, expectedResult: { accepted: true, - state: { timer: t, counter: 1, disabled: false }, + state: { timer: t, counter: 1, disabled: false, now: t }, }, }, ] @@ -71,14 +71,14 @@ describe('Sequential Delay Test Suite', () => { timestamp: t + 1, expectedResult: { accepted: true, - state: { timer: t + 1, counter: 1, disabled: false }, + state: { timer: t + 1, counter: 1, disabled: false, now: t + 1 }, }, }, { timestamp: t + 1, expectedResult: { accepted: true, - state: { timer: t + 1, counter: 2, disabled: false }, + state: { timer: t + 1, counter: 2, disabled: false, now: t + 1 }, }, }, { @@ -86,7 +86,7 @@ describe('Sequential Delay Test Suite', () => { expectedResult: { accepted: false, notBefore: undefined, - state: { timer: t + 1, counter: 2, disabled: false }, + state: { timer: t + 1, counter: 2, disabled: false, now: t + 1 }, }, }, ] @@ -109,7 +109,7 @@ describe('Sequential Delay Test Suite', () => { result = checkSequentialDelayRateLimit(domain, t + 1, result?.state) expect(result).toEqual({ accepted: true, - state: { timer: t + 1, counter: 1, disabled: false }, + state: { timer: t + 1, counter: 1, disabled: false, now: t + 1 }, }) // Set the domain to disabled and attempt to make another reqeust. @@ -118,7 +118,7 @@ describe('Sequential Delay Test Suite', () => { expect(result).toEqual({ accepted: false, notBefore: undefined, - state: { timer: t + 1, counter: 1, disabled: true }, + state: { timer: t + 1, counter: 1, disabled: true, now: t + 1 }, }) }) @@ -143,28 +143,28 @@ describe('Sequential Delay Test Suite', () => { timestamp: t + 3, expectedResult: { accepted: true, - state: { timer: t, counter: 1, disabled: false }, + state: { timer: t, counter: 1, disabled: false, now: t + 3 }, }, }, { timestamp: t + 3, expectedResult: { accepted: true, - state: { timer: t + 1, counter: 2, disabled: false }, + state: { timer: t + 1, counter: 2, disabled: false, now: t + 3 }, }, }, { timestamp: t + 3, expectedResult: { accepted: true, - state: { timer: t + 2, counter: 3, disabled: false }, + state: { timer: t + 2, counter: 3, disabled: false, now: t + 3 }, }, }, { timestamp: t + 3, expectedResult: { accepted: true, - state: { timer: t + 3, counter: 4, disabled: false }, + state: { timer: t + 3, counter: 4, disabled: false, now: t + 3 }, }, }, { @@ -172,7 +172,7 @@ describe('Sequential Delay Test Suite', () => { expectedResult: { accepted: false, notBefore: undefined, - state: { timer: t + 3, counter: 4, disabled: false }, + state: { timer: t + 3, counter: 4, disabled: false, now: t + 3 }, }, }, ] @@ -199,7 +199,7 @@ describe('Sequential Delay Test Suite', () => { timestamp: t + 2, expectedResult: { accepted: true, - state: { timer: t + 2, counter: 1, disabled: false }, + state: { timer: t + 2, counter: 1, disabled: false, now: t + 2 }, }, }, { @@ -207,14 +207,14 @@ describe('Sequential Delay Test Suite', () => { expectedResult: { accepted: false, notBefore: t + 3, - state: { timer: t + 2, counter: 1, disabled: false }, + state: { timer: t + 2, counter: 1, disabled: false, now: t + 2 }, }, }, { timestamp: t + 3, expectedResult: { accepted: true, - state: { timer: t + 3, counter: 2, disabled: false }, + state: { timer: t + 3, counter: 2, disabled: false, now: t + 3 }, }, }, ] @@ -222,14 +222,14 @@ describe('Sequential Delay Test Suite', () => { checkTestAttempts(t, domain, attempts) }) - it('should return he correct results in the example sequence', () => { - const t = 0 // initial delay + it('should return the correct results in the example sequence', () => { + const t = 10 // initial delay const domain: SequentialDelayDomain = { name: DomainIdentifiers.SequentialDelay, version: '1', stages: [ - { delay: 0, resetTimer: noBool, batchSize: defined(2), repetitions: noNumber }, + { delay: t, resetTimer: noBool, batchSize: defined(2), repetitions: noNumber }, { delay: 1, resetTimer: defined(false), batchSize: noNumber, repetitions: noNumber }, { delay: 1, resetTimer: defined(true), batchSize: noNumber, repetitions: noNumber }, { delay: 2, resetTimer: defined(false), batchSize: noNumber, repetitions: defined(1) }, @@ -245,42 +245,42 @@ describe('Sequential Delay Test Suite', () => { expectedResult: { accepted: false, notBefore: t, - state: undefined, + state: { timer: 0, counter: 0, disabled: false, now: t - 1 }, }, }, { timestamp: t, expectedResult: { accepted: true, - state: { timer: t, counter: 1, disabled: false }, + state: { timer: t, counter: 1, disabled: false, now: t }, }, }, { timestamp: t + 1, expectedResult: { accepted: true, - state: { timer: t + 1, counter: 2, disabled: false }, + state: { timer: t + 1, counter: 2, disabled: false, now: t + 1 }, }, }, { timestamp: t + 3, expectedResult: { accepted: true, - state: { timer: t + 2, counter: 3, disabled: false }, + state: { timer: t + 2, counter: 3, disabled: false, now: t + 3 }, }, }, { timestamp: t + 3, expectedResult: { accepted: true, - state: { timer: t + 3, counter: 4, disabled: false }, + state: { timer: t + 3, counter: 4, disabled: false, now: t + 3 }, }, }, { timestamp: t + 6, expectedResult: { accepted: true, - state: { timer: t + 5, counter: 5, disabled: false }, + state: { timer: t + 5, counter: 5, disabled: false, now: t + 6 }, }, }, { @@ -288,35 +288,35 @@ describe('Sequential Delay Test Suite', () => { expectedResult: { accepted: false, notBefore: t + 9, - state: { timer: t + 5, counter: 5, disabled: false }, + state: { timer: t + 5, counter: 5, disabled: false, now: t + 8 }, }, }, { timestamp: t + 9, expectedResult: { accepted: true, - state: { timer: t + 9, counter: 6, disabled: false }, + state: { timer: t + 9, counter: 6, disabled: false, now: t + 9 }, }, }, { timestamp: t + 10, expectedResult: { accepted: true, - state: { timer: t + 10, counter: 7, disabled: false }, + state: { timer: t + 10, counter: 7, disabled: false, now: t + 10 }, }, }, { timestamp: t + 14, expectedResult: { accepted: true, - state: { timer: t + 14, counter: 8, disabled: false }, + state: { timer: t + 14, counter: 8, disabled: false, now: t + 14 }, }, }, { timestamp: t + 15, expectedResult: { accepted: true, - state: { timer: t + 15, counter: 9, disabled: false }, + state: { timer: t + 15, counter: 9, disabled: false, now: t + 15 }, }, }, ] diff --git a/packages/phone-number-privacy/monitor/package.json b/packages/phone-number-privacy/monitor/package.json index 8fac68d4219..985fc9e7391 100644 --- a/packages/phone-number-privacy/monitor/package.json +++ b/packages/phone-number-privacy/monitor/package.json @@ -18,7 +18,7 @@ "clean": "tsc -b . --clean", "build": "tsc -b .", "lint": "tslint --project .", - "loadTest": "ts-node src/scripts/runLoadTest.ts" + "loadTest": "ts-node src/scripts/run-load-test.ts" }, "dependencies": { "@celo/contractkit": "2.3.0", @@ -35,6 +35,6 @@ "firebase-tools": "9.20.0" }, "engines": { - "node": "12" + "node": ">=12" } } \ No newline at end of file diff --git a/packages/phone-number-privacy/monitor/src/scripts/runLoadTest.ts b/packages/phone-number-privacy/monitor/src/scripts/run-load-test.ts similarity index 100% rename from packages/phone-number-privacy/monitor/src/scripts/runLoadTest.ts rename to packages/phone-number-privacy/monitor/src/scripts/run-load-test.ts diff --git a/packages/phone-number-privacy/signer/.env b/packages/phone-number-privacy/signer/.env index 9702fda18ce..df5a15a507d 100644 --- a/packages/phone-number-privacy/signer/.env +++ b/packages/phone-number-privacy/signer/.env @@ -13,11 +13,12 @@ DB_USE_SSL=true #KEYSTORE_AZURE_CLIENT_SECRET=useMock #KEYSTORE_AZURE_TENANT=useMock #KEYSTORE_AZURE_VAULT_NAME=useMock -#KEYSTORE_AZURE_SECRET_NAME=useMock +PHONE_NUMBER_PRIVACY_KEY_NAME_BASE='phoneNumberPrivacy' +DOMAINS_KEY_NAME_BASE='domains' +PHONE_NUMBER_PRIVACY_LATEST_KEY_VERSION='1' +DOMAINS_LATEST_KEY_VERSION='1' KEYSTORE_TYPE=MockSecretManager KEYSTORE_GOOGLE_PROJECT_ID=mockProjectId -KEYSTORE_GOOGLE_SECRET_NAME=mockSecretName -KEYSTORE_GOOGLE_SECRET_VERSION=latest # Options: json, human (default), stackdriver LOG_FORMAT=stackdriver # Options: fatal, error, warn, info (default), debug, trace @@ -27,7 +28,12 @@ SERVICE_NAME='odis-signer' ODIS_SIGNER_SERVICE_URL=https://staging-pgpnp-signer0.azurefd.net ODIS_COMBINER_SERVICE_URL=https://us-central1-celo-phone-number-privacy-stg.cloudfunctions.net ODIS_BLOCKCHAIN_PROVIDER=https://alfajores-forno.celo-testnet.org -ODIS_PUBLIC_POLYNOMIAL= 020000000000000090fa11c56744759fcd777b909e9dc5245b39e33ba24be92caaf3a6f71f2f63a2873c6f23adfab2b2f211534c2da98b01280ed2a00b0808c06ff02fc56f66690ceaa14aabebfec65b6681e641fbdbaabcdbb4320fbd422b1e0452d3274908cb00f3d2ba1d64ddc12f387ef5c6fb98265cee27afa66626edf91b9839d49f23890d75a550a49a2e7a75b06b3b49734a160035558eb2079c41926388ac560e75f1962dada39e5c30ba35bef59eb84ff4329432cdc10383b4dea40f5ad8fabbb09a81 -# alfajores polynomial = 020000000000000090fa11c56744759fcd777b909e9dc5245b39e33ba24be92caaf3a6f71f2f63a2873c6f23adfab2b2f211534c2da98b01280ed2a00b0808c06ff02fc56f66690ceaa14aabebfec65b6681e641fbdbaabcdbb4320fbd422b1e0452d3274908cb00f3d2ba1d64ddc12f387ef5c6fb98265cee27afa66626edf91b9839d49f23890d75a550a49a2e7a75b06b3b49734a160035558eb2079c41926388ac560e75f1962dada39e5c30ba35bef59eb84ff4329432cdc10383b4dea40f5ad8fabbb09a81 -# mainnet polynomial = 060000000000000016fade1df2e68418f0c47c6cc5ecab70e2ed4a89c2f63ecadd6ad2e106a962c407e8b75a0d368d1a69e540c7c5634e01a7f2b8c00bea4303bdfdba8f54229ff197bc399a3c16b9a8838258e31022c2bb2a397c6e835d7e86d8c47b5a63e2e30017f865337fd0060497457135173e2b0eaec6f8f14f0cacb17a5d150218e15bd46963ed1b9d56f956f9c4fc692813100042f098b7f70913f671e28ed1c99104b9b740549c42c59212b6671f1e1675674f7e6b6d690a13bd474ab9f0c83cd48e017514ca3874606f6abde2b957c791376e24d55efe6ccc7a1194a685b9589ca873a51c7e77b7b814a76cd9af2aafef500155280fb84efd3219b04312635568788b3393fd45a11f431a7eef8a8fc59ff2bfd4aab744baf9221bf1774653dda61d8193b720f60c627d5a9fec5c2c16a27e948f2f4545b460090303327262ec87f51fbf860f58d5e051d91d5bb869c8912300a9b1c2d922d329c9b7d5179946e049d52ed9b3876f36e5c8b2a47831eb235a51d8d877a284fbe07750449f9654d332808beb9641404188813cddb8ffad906752d71f3f042b583f501b3b7f3906946f9931c598575bf4c8d3e8941168f8cc8e001c092117257bb073db3885dffca5e8dd76b689d395bb5555cf00f9943a9e1ec9939f9d700407330163220f3c15a9420011b8693fb95c635168b6b0a021263b246301343e80161eac44fe79ba657fe59deb9d297ced18d090a8f65dc9c2e0990177f186d7501a2256ac9ecca36743e118f5dd4ce35dc976d38c8679d53cd11b0f11edb45c3473ce848d35875e63b2d100 -# staging polynomial = 0200000000000000ec5b161ac167995bd17cc0e9cf3f79369efac1fff5b0f68ad0e83dca207e3fc41b8e20bc155ebb3416a7b3d87364490169032189aa7380c47a0a464864fbe0c106e803197ae4959165e7067b95775cee2c74a78d7a67406764f342e5a4b99a003a510287524c9437b12ebb0bfdc7ea46078b807d1b665966961784bd71c4227c272b01c0fcd19c5b92226c1aac324b010abef36192e8ff3abb25686b3e6707bc747b129c32e572b5850db8446bd8f0af9a3fbf6b579793002b1b68528ca4ac00 \ No newline at end of file +# Public Polynomials +ALFAJORES_PHONE_NUMBER_PRIVACY_POLYNOMIAL=0200000000000000ec5b161ac167995bd17cc0e9cf3f79369efac1fff5b0f68ad0e83dca207e3fc41b8e20bc155ebb3416a7b3d87364490169032189aa7380c47a0a464864fbe0c106e803197ae4959165e7067b95775cee2c74a78d7a67406764f342e5a4b99a003a510287524c9437b12ebb0bfdc7ea46078b807d1b665966961784bd71c4227c272b01c0fcd19c5b92226c1aac324b010abef36192e8ff3abb25686b3e6707bc747b129c32e572b5850db8446bd8f0af9a3fbf6b579793002b1b68528ca4ac00 +STAGING_PHONE_NUMBER_PRIVACY_POLYNOMIAL=020000000000000090fa11c56744759fcd777b909e9dc5245b39e33ba24be92caaf3a6f71f2f63a2873c6f23adfab2b2f211534c2da98b01280ed2a00b0808c06ff02fc56f66690ceaa14aabebfec65b6681e641fbdbaabcdbb4320fbd422b1e0452d3274908cb00f3d2ba1d64ddc12f387ef5c6fb98265cee27afa66626edf91b9839d49f23890d75a550a49a2e7a75b06b3b49734a160035558eb2079c41926388ac560e75f1962dada39e5c30ba35bef59eb84ff4329432cdc10383b4dea40f5ad8fabbb09a81 +MAINNET_PHONE_NUMBER_PRIVACY_POLYNOMIAL=060000000000000016fade1df2e68418f0c47c6cc5ecab70e2ed4a89c2f63ecadd6ad2e106a962c407e8b75a0d368d1a69e540c7c5634e01a7f2b8c00bea4303bdfdba8f54229ff197bc399a3c16b9a8838258e31022c2bb2a397c6e835d7e86d8c47b5a63e2e30017f865337fd0060497457135173e2b0eaec6f8f14f0cacb17a5d150218e15bd46963ed1b9d56f956f9c4fc692813100042f098b7f70913f671e28ed1c99104b9b740549c42c59212b6671f1e1675674f7e6b6d690a13bd474ab9f0c83cd48e017514ca3874606f6abde2b957c791376e24d55efe6ccc7a1194a685b9589ca873a51c7e77b7b814a76cd9af2aafef500155280fb84efd3219b04312635568788b3393fd45a11f431a7eef8a8fc59ff2bfd4aab744baf9221bf1774653dda61d8193b720f60c627d5a9fec5c2c16a27e948f2f4545b460090303327262ec87f51fbf860f58d5e051d91d5bb869c8912300a9b1c2d922d329c9b7d5179946e049d52ed9b3876f36e5c8b2a47831eb235a51d8d877a284fbe07750449f9654d332808beb9641404188813cddb8ffad906752d71f3f042b583f501b3b7f3906946f9931c598575bf4c8d3e8941168f8cc8e001c092117257bb073db3885dffca5e8dd76b689d395bb5555cf00f9943a9e1ec9939f9d700407330163220f3c15a9420011b8693fb95c635168b6b0a021263b246301343e80161eac44fe79ba657fe59deb9d297ced18d090a8f65dc9c2e0990177f186d7501a2256ac9ecca36743e118f5dd4ce35dc976d38c8679d53cd11b0f11edb45c3473ce848d35875e63b2d100 +# ALFAJORES_DOMAINS_POLYNOMIAL=NA +# STAGING_DOMAINS_POLYNOMIAL=NA +MAINNET_DOMAINS_POLYNOMIAL=05000000000000002d7e2d2e2b989bc81e677ced987ee8216cf8a215eddde3d14ddf416c6f513bce8d32b0297e58a888ecca62d22cca3100d2e6ab9d7f049a8fa5b936386f0116a60643c8f604e9431602805a641772e8d0cc800c526dd36d69012ae757c18c250029d97c8a3d4b81e305780b49d511c80dc3009c02b8f651a06c8ec2d5530937a1f7eadf730ad46762a4c089bbd973a000ba77717ec36ebb6fd58904b444a6cde7dd3b3b7ac6fa37f9cd8d00aa67e7cfe81adee5ed45218f7f78b4f8473b564601f4361d228dc6dabf7decd3f61f5bb0ad2c7bd7fe5b7a88054959543e82f4deb08d4fe9af4ac775c9353e038e79f82200863ac9cb7fd6b5fa263eb9d1dead51002607f3eadac153596b671b854715bdb07bee1b0bc8d5178f0dac1b4d00ed0700f46e37135e96604d389f3a323028e29b07f36279e829da00eee1794f3ad6e5dca24eba65a7821755cc464add27c7a601c7e187756e79a5ec3c847f4d91b037fe3cd40590fc1a46b46c2f68c0edcbe5cd7727162a195a711008e4e956eb8a81011b290057cee3f14b9a4198a3e9909cac69a9e7d648fa3dd185794acc4c1e4b994637dca36621d463b42e015115ac2c015fc176d8f143bf99cca654ae95a3101afbdc0c5026f95fbf31af1ac115399f5b6b6d1de09af367745415be9533f8c08 +ODIS_PUBLIC_POLYNOMIAL=$STAGING_PHONE_NUMBER_PRIVACY_POLYNOMIAL +ODIS_KEY_VERSION=1 diff --git a/packages/phone-number-privacy/signer/README.md b/packages/phone-number-privacy/signer/README.md index 0a05c442feb..1aa514c3ff8 100644 --- a/packages/phone-number-privacy/signer/README.md +++ b/packages/phone-number-privacy/signer/README.md @@ -66,11 +66,18 @@ The BLS key share should only exist in the keystore or as an encrypted backup. T ### Keystores -Currently, the service retrieving keys from Azure Key Vault (AKV), Google Secret Manager and AWS Secrets Manager. -You must specify the type, and then the keystore configs for that type. +Currently, the service supports Azure Key Vault (AKV), Google Secret Manager and AWS Secrets Manager. +You must specify the type, and then the keystore configs for that type as follows. - `KEYSTORE_TYPE` - `AzureKeyVault`, `GoogleSecretManager` or `AWSSecretManager` +In addition, you must name your keys in your keystore according to the pattern `-` where + +- `keyName` is configurable via the env variables `PHONE_NUMBER_PRIVACY_KEY_NAME_BASE` and `DOMAINS_KEY_NAME_BASE` which default to `phoneNumberPrivacy` and `domains` respectively. +- `keyVersion` is an integer corresponding to the iteration of the given key share. The variables `PHONE_NUMBER_PRIVACY_LATEST_KEY_VERSION` and `DOMAINS_LATEST_KEY_VERSION` should specify the latest version of the appropriate key share. This version will be fetched when the signer starts up. + +For example, the first iteration of the key share used for phone number privacy should be stored as `phoneNumberPrivacy-1` and the second iteration (after resharing) should be stored as `phoneNumberPrivacy-2` unless you specify a `PHONE_NUMBER_PRIVACY_KEY_NAME_BASE` env variable, in which case `phoneNumberPrivacy` should be replaced with that value. The version numbers and `-` delimeter are mandatory and not configurable. + #### Azure Key Vault Use the following to configure the AKV connection. These values are generated when creating a service principal account (see [Configuring your Key Vault](https://www.npmjs.com/package/@azure/keyvault-keys#configuring-your-key-vault)). Or if the service is being hosted on Azure itself, authentication can be done by granted key access to the VM's managed identity, in which case the client_id, client_secret, and tenant configs can be left blank. @@ -125,6 +132,18 @@ Then check on the service to make sure its running: `docker logs -f {CONTAINER_ID_HERE}` +#### Key rotations + +After a key resharing, signers should rotate their key shares as follows: + +1. Store the new key share in the keystore according to the naming convention specified in the [Keystores](#keystores) section above. +2. Increment `PHONE_NUMBER_PRIVACY_LATEST_KEY_VERSION` or `DOMAINS_LATEST_KEY_VERSION` as appropriate. This will instruct the signer to prefetch this new key version the next time it starts up, but there is no need to restart the signer at this point. +3. Notify the combiner operator that your signer is ready for the key rotation. +4. The combiner operator will run e2e tests against your signer to verify it has the correct key configuration. +5. The combiner operator will update the combiner to request the new key share version via a custom request header field once all signers are ready. The combiner operator should remember to update the public polynomial in the combiner's config appropriately. +6. The signers will fetch the new key shares from their keystores upon receiving these requests. +7. When the combiner operator sees that all signers are signing with the new key share and confirms that the system is healthy, signers will be instructed to delete their old key shares. Deleting the deprecated key shares ensures they cannot be stored and used by an attacker. + ### Validate before going live You can test your mainnet service is set up correctly by running a specific end-to-end test that checks the signature against a public polynomial. Because the test requires quota, you must first point your provider endpoint to Alfajores. diff --git a/packages/phone-number-privacy/signer/azure-templates/README.md b/packages/phone-number-privacy/signer/azure-templates/README.md deleted file mode 100644 index d87ed47bf76..00000000000 --- a/packages/phone-number-privacy/signer/azure-templates/README.md +++ /dev/null @@ -1,129 +0,0 @@ -# Azure deployment templates - -Templates to facilitate deployment of new signers on Azure. - -## Prequisites - -This setup assumes you've got the Azure CLI installed and that you've already created the Key Vault with the secret. - -The container also requires a Log Analytics workspace id and key. To acquire those, [create a Log Analtyics workspace](https://docs.microsoft.com/en-us/azure/azure-monitor/learn/quick-create-workspace). If you won't be using Log Analytics, you'll need to cut those settings from the `container-template.json` file. - -## Choose subscription and resource group - -```bash -az account set --subscription {YOUR_SUB} -RESOURCE_GROUP={YOUR_RG} -``` - -## Deploy the database (postgres) - -Fill in the `TODO` placeholder params in `db-parameters.json` and then run the following: - -```bash -SERVER_NAME={YOUR_SERVER_NAME} - -az deployment group create \ - --resource-group $RESOURCE_GROUP \ - --template-file db-template.json \ - --parameters @db-parameters.json \ - --parameters serverName=${SERVER_NAME} - -az postgres db create \ - --name phoneNumberPrivacy \ - --resource-group $RESOURCE_GROUP \ - --server-name $SERVER_NAME - -az postgres db list --resource-group $RESOURCE_GROUP --server-name $SERVER_NAME - -# Allow access to Azure services -az postgres server firewall-rule create -g $RESOURCE_GROUP -s $SERVER_NAME -n AllowAllWindowsAzureIps --start-ip-address 0.0.0.0 --end-ip-address 0.0.0.0 -``` - -You'll also need to run the db migrations, either via a special docker command or by temporarily whitelisting your dev box (see parent [Readme](../README.md)). - -## Create a managed identity, add it to container-parameters and grant it access to keyvault - -If a managed identity already exists for the signer, you can get it by clicking on the managed identity resource and looking under `properties -> Resource ID`, then just add that string to the container-parameters file. If not, you can create a new managed identity with the following command: - -```bash -az identity create \ - --resource-group $RESOURCE_GROUP \ - --name $CONTAINER_NAME -``` - -The command will output some json, and the string you need to add to the container-parameters file will be under the "id" field. You can then grant the new managed identity access to the keyvault with the following command, or via the azure portal. - -```bash -RESOURCE_ID={YOUR_RESOURCE_ID} -az keyvault set-policy \ - --name $KEYVAULT_NAME \ - --resource-group $RESOURCE_GROUP \ - --object-id $RESOURCE_ID \ - --secret-permissions get -``` - -## Deploy the container instance - -Fill in the `TODO` placeholder params in `container-parameters.json`. The `dnsNameLabel` will act as the prefix for your cointainer hostname. Run the following: - -```bash -CONTAINER_NAME={YOUR_CONTAINER_NAME} - -KEYVAULT_NAME={YOUR_VAULT_NAME} - -az deployment group create \ - --resource-group $RESOURCE_GROUP \ - --template-file container-template.json \ - --parameters @container-parameters.json \ - --parameters containerName=$CONTAINER_NAME -``` - -## Deploy the front door for TLS Termination - -```bash -CONTAINER_FQDN=$(az container show \ - --resource-group $RESOURCE_GROUP \ - --name $CONTAINER_NAME \ - --query ipAddress.fqdn --out tsv) - -FRONTDOOR_NAME={YOUR_FRONTDOOR_NAME} - -az deployment group create \ - --resource-group $RESOURCE_GROUP \ - --template-file frontdoor-template.json \ - --parameters frontdoors_name=$FRONTDOOR_NAME \ - --parameters backend_url=$CONTAINER_FQDN -``` - -## Monitoring - -### Logging - -The logs from the container should flow automatically into the configured Log Analytics workspace. - -To see errors, execute a query like the following, which you can save for convinient use in creating alerts: -`ContainerInstanceLog_CL | where Message contains "celo_odis_err"` - -### Metrics - -We use prometheus metrics to generate alerting and dashboards. For the most up to date on-call information please see the Runbook (https://docs.google.com/document/u/1/d/1fSN2_J-OHMr1TqAbj1_i5p7sFgBpZ9xsDIe9WeG4FV8/edit). - -### Deploy the prometheus server and sidecar - -We use prometheus metrics to collect real time data from signers. The prometheus server scrapes these metrics from the `/metrics` endpoint on the signers and sends them to the sidecar container via a shared volume. The sidecar then reformats the metrics data and exports it to stackdriver. - -To deploy the prometheus server and sidecar you must first find the `prometheus-service-account-key` json and paste it into the `prometheus-service-account-key.json` file. Make sure you remember to delete this key from the file again after you finish the deployment and before you push to github! The key can be found by searching for `Service Accounts` in GCP and then creating a service account with `metrics write` permissions if one does not already exist. To get the key, go to the `Actions` tab on the service account and select `Create key`. This should download some JSON to your computer that you can copy and paste into the `prometheus-service-account-key.json` file. - -Once you have the service account key, you should fill in the missing fields labeled `"TODO"` in `prometheus-parameters.yaml`. The location parameters should be filled in with the location of the resource group. For the `prometheus.yaml` and `prometheus-service-account-key.json` parameters, you must base64 encode the contents of the `prometheus-.yaml` and `prometheus-service-account-key.json` files respectively, and then paste the results into the file. - -```bash -cat | base64 -``` - -Then run the following command to deploy the containers - -```bash -az deployment group create --resource-group $RESOURCE_GROUP --template-file prometheus-template.json --parameters @prometheus-parameters.json -``` - -Make sure to change all these values back to `"TODO"` after you deploy and before committing to master. If you accidentally push the service account key to github, simply delete the service account and create a new one. Then, use the new service account to generate a key and repeat the steps above to redeploy the prometheus server and sidecar. diff --git a/packages/phone-number-privacy/signer/azure-templates/container-parameters.json b/packages/phone-number-privacy/signer/azure-templates/container-parameters.json deleted file mode 100644 index 16d23c1789e..00000000000 --- a/packages/phone-number-privacy/signer/azure-templates/container-parameters.json +++ /dev/null @@ -1,106 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "location": { - "value": "TODO" - }, - "imageType": { - "value": "Public" - }, - "imageName": { - "value": "us.gcr.io/celo-testnet/celo-monorepo:phone-number-privacy-rc2" - }, - "osType": { - "value": "Linux" - }, - "numberCpuCores": { - "value": "2" - }, - "memory": { - "value": "2" - }, - "restartPolicy": { - "value": "Always" - }, - "environmentVariables": { - "value": [ - { - "name": "BLOCKCHAIN_PROVIDER", - "value": "https://forno.celo.org" - }, - { - "name": "SERVER_PORT", - "value": "80" - }, - { - "name": "DB_TYPE", - "value": "postgres" - }, - { - "name": "DB_HOST", - "value": "TODO" - }, - { - "name": "DB_USERNAME", - "value": "TODO" - }, - { - "name": "DB_PASSWORD", - "secureValue": "TODO" - }, - { - "name": "DB_DATABASE", - "value": "phoneNumberPrivacy" - }, - { - "name": "KEYSTORE_TYPE", - "value": "AzureKeyVault" - }, - { - "name": "KEYSTORE_AZURE_VAULT_NAME", - "value": "TODO" - }, - { - "name": "KEYSTORE_AZURE_SECRET_NAME", - "value": "TODO" - }, - { - "name": "LOG_LEVEL", - "value": "trace" - }, - { - "name": "LOG_FORMAT", - "value": "stackdriver" - } - ] - }, - "ipAddressType": { - "value": "Public" - }, - "ports": { - "value": [ - { - "port": "80", - "protocol": "TCP" - }, - { - "port": "443", - "protocol": "TCP" - } - ] - }, - "dnsNameLabel": { - "value": "TODO" - }, - "logAnalyticsWorkspaceId": { - "value": "TODO" - }, - "logAnalyticsWorkspaceKey": { - "value": "TODO" - }, - "userAssignedIdentity": { - "value": "TODO" - } - } -} \ No newline at end of file diff --git a/packages/phone-number-privacy/signer/azure-templates/container-template.json b/packages/phone-number-privacy/signer/azure-templates/container-template.json deleted file mode 100644 index 142ea9e7327..00000000000 --- a/packages/phone-number-privacy/signer/azure-templates/container-template.json +++ /dev/null @@ -1,115 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "location": { - "type": "string" - }, - "containerName": { - "type": "string" - }, - "imageType": { - "type": "string", - "allowedValues": [ - "Public", - "Private" - ] - }, - "imageName": { - "type": "string" - }, - "osType": { - "type": "string", - "allowedValues": [ - "Linux", - "Windows" - ] - }, - "numberCpuCores": { - "type": "string" - }, - "memory": { - "type": "string" - }, - "restartPolicy": { - "type": "string", - "allowedValues": [ - "OnFailure", - "Always", - "Never" - ] - }, - "environmentVariables": { - "type": "array" - }, - "ipAddressType": { - "type": "string" - }, - "ports": { - "type": "array" - }, - "dnsNameLabel": { - "type": "string" - }, - "logAnalyticsWorkspaceId": { - "type": "string" - }, - "logAnalyticsWorkspaceKey": { - "type": "string" - }, - "userAssignedIdentity": { - "type": "string" - } - }, - "resources": [ - { - "location": "[parameters('location')]", - "name": "[parameters('containerName')]", - "type": "Microsoft.ContainerInstance/containerGroups", - "apiVersion": "2018-10-01", - "properties": { - "containers": [ - { - "name": "[parameters('containerName')]", - "properties": { - "image": "[parameters('imageName')]", - "resources": { - "requests": { - "cpu": "[int(parameters('numberCpuCores'))]", - "memoryInGB": "[float(parameters('memory'))]" - } - }, - "environmentVariables": "[parameters('environmentVariables')]", - "ports": "[parameters('ports')]" - } - } - ], - "imageRegistryCredentials": [ - { - "server": "celoprod.azurecr.io", - "username": "publicpuller", - "password": "" - } - ], - "restartPolicy": "[parameters('restartPolicy')]", - "osType": "[parameters('osType')]", - "ipAddress": { - "type": "[parameters('ipAddressType')]", - "ports": "[parameters('ports')]", - "dnsNameLabel": "[parameters('dnsNameLabel')]" - }, - "diagnostics": { - "logAnalytics": { - "workspaceId": "[parameters('logAnalyticsWorkspaceId')]", - "workspaceKey": "[parameters('logAnalyticsWorkspaceKey')]" - } - } - }, - "identity":{ - "type": "UserAssigned", - "userAssignedIdentities": { "[parameters('userAssignedIdentity')]": {} } - }, - "tags": {} - } - ] -} diff --git a/packages/phone-number-privacy/signer/azure-templates/db-parameters.json b/packages/phone-number-privacy/signer/azure-templates/db-parameters.json deleted file mode 100644 index 6832959293f..00000000000 --- a/packages/phone-number-privacy/signer/azure-templates/db-parameters.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "location": { - "value": "TODO" - }, - "skuName": { - "value": "B_Gen5_1" - }, - "skuTier": { - "value": "Basic" - }, - "skuCapacity": { - "value": 1 - }, - "skuFamily": { - "value": "Gen5" - }, - "skuSizeMB": { - "value": 51200 - }, - "backupRetentionDays": { - "value": 35 - }, - "geoRedundantBackup": { - "value": "Disabled" - }, - "storageAutoGrow": { - "value": "Enabled" - }, - "tags": { - "value": {} - }, - "infrastructureEncryption": { - "value": "Disabled" - }, - "administratorLogin": { - "value": "TODO" - }, - "administratorLoginPassword": { - "value": "TODO" - }, - "previewFeature": { - "value": "" - }, - "version": { - "value": "10" - } - } -} \ No newline at end of file diff --git a/packages/phone-number-privacy/signer/azure-templates/db-template.json b/packages/phone-number-privacy/signer/azure-templates/db-template.json deleted file mode 100644 index ad743a61213..00000000000 --- a/packages/phone-number-privacy/signer/azure-templates/db-template.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "$schema": "http://schema.management.azure.com/schemas/2014-04-01-preview/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "administratorLogin": { - "type": "string" - }, - "administratorLoginPassword": { - "type": "securestring" - }, - "location": { - "type": "string" - }, - "serverName": { - "type": "string" - }, - "skuCapacity": { - "type": "int" - }, - "skuFamily": { - "type": "string" - }, - "skuName": { - "type": "string" - }, - "skuSizeMB": { - "type": "int" - }, - "skuTier": { - "type": "string" - }, - "version": { - "type": "string" - }, - "backupRetentionDays": { - "type": "int" - }, - "geoRedundantBackup": { - "type": "string" - }, - "previewFeature": { - "type": "string", - "defaultValue": "" - }, - "tags": { - "type": "object", - "defaultValue": {} - }, - "storageAutoGrow": { - "type": "string", - "defaultValue": "Disabled" - }, - "infrastructureEncryption": { - "type": "string", - "defaultValue": "Disabled" - } - }, - "resources": [ - { - "apiVersion": "2017-12-01-preview", - "kind": "", - "location": "[parameters('location')]", - "name": "[parameters('serverName')]", - "properties": { - "version": "[parameters('version')]", - "administratorLogin": "[parameters('administratorLogin')]", - "administratorLoginPassword": "[parameters('administratorLoginPassword')]", - "storageProfile": { - "storageMB": "[parameters('skuSizeMB')]", - "backupRetentionDays": "[parameters('backupRetentionDays')]", - "geoRedundantBackup": "[parameters('geoRedundantBackup')]", - "storageAutoGrow": "[parameters('storageAutoGrow')]" - }, - "previewFeature": "[parameters('previewFeature')]", - "infrastructureEncryption": "[parameters('infrastructureEncryption')]" - }, - "sku": { - "name": "[parameters('skuName')]", - "tier": "[parameters('skuTier')]", - "capacity": "[parameters('skuCapacity')]", - "size": "[parameters('skuSizeMB')]", - "family": "[parameters('skuFamily')]" - }, - "tags": "[parameters('tags')]", - "type": "Microsoft.DBforPostgreSQL/servers" - } - ], - "variables": {} -} \ No newline at end of file diff --git a/packages/phone-number-privacy/signer/azure-templates/frontdoor-template.json b/packages/phone-number-privacy/signer/azure-templates/frontdoor-template.json deleted file mode 100644 index 8f3c67798d6..00000000000 --- a/packages/phone-number-privacy/signer/azure-templates/frontdoor-template.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "frontdoors_name": { - "type": "string" - }, - "backend_url": { - "type": "string" - } - }, - "variables": {}, - "resources": [ - { - "type": "Microsoft.Network/frontdoors", - "apiVersion": "2020-05-01", - "name": "[parameters('frontdoors_name')]", - "location": "Global", - "properties": { - "resourceState": "Enabled", - "backendPools": [ - { - "id": "[concat(resourceId('Microsoft.Network/frontdoors', parameters('frontdoors_name')), concat('/BackendPools/', parameters('frontdoors_name')))]", - "name": "[parameters('frontdoors_name')]", - "properties": { - "backends": [ - { - "address": "[parameters('backend_url')]", - "httpPort": 80, - "httpsPort": 443, - "priority": 1, - "weight": 100, - "backendHostHeader": "[parameters('backend_url')]", - "enabledState": "Enabled" - } - ], - "healthProbeSettings": { - "id": "[concat(resourceId('Microsoft.Network/frontdoors', parameters('frontdoors_name')), '/healthprobesettings/healthprobesettings-1592864885908')]" - }, - "loadBalancingSettings": { - "id": "[concat(resourceId('Microsoft.Network/frontdoors', parameters('frontdoors_name')), '/loadbalancingsettings/loadbalancingsettings-1592864885908')]" - }, - "resourceState": "Enabled" - } - } - ], - "healthProbeSettings": [ - { - "id": "[concat(resourceId('Microsoft.Network/frontdoors', parameters('frontdoors_name')), '/HealthProbeSettings/healthProbeSettings-1592864885908')]", - "name": "healthProbeSettings-1592864885908", - "properties": { - "intervalInSeconds": 60, - "path": "/status", - "protocol": "Http", - "resourceState": "Enabled", - "enabledState": "Disabled", - "healthProbeMethod": "Get" - } - } - ], - "frontendEndpoints": [ - { - "id": "[concat(resourceId('Microsoft.Network/frontdoors', parameters('frontdoors_name')), concat('/FrontendEndpoints/', parameters('frontdoors_name'), '-azurefd-net'))]", - "name": "[concat(parameters('frontdoors_name'), '-azurefd-net')]", - "properties": { - "hostName": "[concat(parameters('frontdoors_name'), '.azurefd.net')]", - "sessionAffinityEnabledState": "Disabled", - "sessionAffinityTtlSeconds": 0, - "resourceState": "Enabled" - } - } - ], - "loadBalancingSettings": [ - { - "id": "[concat(resourceId('Microsoft.Network/frontdoors', parameters('frontdoors_name')), '/LoadBalancingSettings/loadBalancingSettings-1592864885908')]", - "name": "loadBalancingSettings-1592864885908", - "properties": { - "additionalLatencyMilliseconds": 0, - "sampleSize": 4, - "successfulSamplesRequired": 3, - "resourceState": "Enabled" - } - } - ], - "routingRules": [ - { - "id": "[concat(resourceId('Microsoft.Network/frontdoors', parameters('frontdoors_name')), concat('/RoutingRules/', parameters('frontdoors_name')))]", - "name": "[parameters('frontdoors_name')]", - "properties": { - "frontendEndpoints": [ - { - "id": "[concat(resourceId('Microsoft.Network/frontdoors', parameters('frontdoors_name')), concat('/frontendendpoints/', parameters('frontdoors_name'), '-azurefd-net'))]" - } - ], - "acceptedProtocols": [ - "Https" - ], - "patternsToMatch": [ - "/*" - ], - "enabledState": "Enabled", - "resourceState": "Enabled", - "routeConfiguration": { - "@odata.type": "#Microsoft.Azure.FrontDoor.Models.FrontdoorForwardingConfiguration", - "forwardingProtocol": "HttpOnly", - "backendPool": { - "id": "[concat(resourceId('Microsoft.Network/frontdoors', parameters('frontdoors_name')), concat('/backendPools/', parameters('frontdoors_name')))]" - } - } - } - } - ], - "backendPoolsSettings": { - "enforceCertificateNameCheck": "Enabled", - "sendRecvTimeoutSeconds": 30 - }, - "enabledState": "Enabled", - "friendlyName": "[parameters('frontdoors_name')]" - } - } - ] -} \ No newline at end of file diff --git a/packages/phone-number-privacy/signer/azure-templates/prometheus-alfajores.yaml b/packages/phone-number-privacy/signer/azure-templates/prometheus-alfajores.yaml deleted file mode 100644 index 183deb57600..00000000000 --- a/packages/phone-number-privacy/signer/azure-templates/prometheus-alfajores.yaml +++ /dev/null @@ -1,14 +0,0 @@ -global: - scrape_interval: 60s -scrape_configs: - - job_name: scrape-odis - metrics_path: /metrics - scheme: http - static_configs: - - targets: - - pgpnp-alfajores-signer1.eastus.azurecontainer.io:80 - - pgpnp-alfajores-signer3.eastus.azurecontainer.io:80 - - pgpnp-alfajores-signer2.eastus.azurecontainer.io:80 - labels: - _generic_location: us-central1 - _generic_namespace: odis-signer diff --git a/packages/phone-number-privacy/signer/azure-templates/prometheus-mainnet.yaml b/packages/phone-number-privacy/signer/azure-templates/prometheus-mainnet.yaml deleted file mode 100644 index 744843b2f0f..00000000000 --- a/packages/phone-number-privacy/signer/azure-templates/prometheus-mainnet.yaml +++ /dev/null @@ -1,14 +0,0 @@ -global: - scrape_interval: 60s -scrape_configs: - - job_name: scrape-odis - metrics_path: /metrics - scheme: http - static_configs: - - targets: - - clabs-mainnet-pgpnp-signer.eastasia.azurecontainer.io:80 - - clabs-mainnet-pgpnp-signer.brazilsouth.azurecontainer.io:80 - - clabs-mainnet-pgpnp-signer.westus2.azurecontainer.io:80 - labels: - _generic_location: us-central1 - _generic_namespace: odis-signer diff --git a/packages/phone-number-privacy/signer/azure-templates/prometheus-parameters.json b/packages/phone-number-privacy/signer/azure-templates/prometheus-parameters.json deleted file mode 100644 index 24d5a39cc4a..00000000000 --- a/packages/phone-number-privacy/signer/azure-templates/prometheus-parameters.json +++ /dev/null @@ -1,130 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "location": { - "value": "TODO" - }, - "containerGroupName": { - "value": "odis-prometheus" - }, - "containerName1": { - "value": "prometheus-server" - }, - "containerName2": { - "value": "prometheus-sidecar" - }, - "imageType1": { - "value": "Public" - }, - "imageType2": { - "value": "Public" - }, - "imageName1": { - "value": "prom/prometheus:v2.17.0" - }, - "imageName2": { - "value": "gcr.io/stackdriver-prometheus/stackdriver-prometheus-sidecar:0.7.3" - }, - "osType": { - "value": "Linux" - }, - "numberCpuCores": { - "value": "2" - }, - "memory": { - "value": "2" - }, - "restartPolicy": { - "value": "Always" - }, - "environmentVariables2": { - "value": [ - { - "name": "GOOGLE_APPLICATION_CREDENTIALS", - "value": "/var/secrets/google/prometheus-service-account-key.json" - } - ] - }, - "ipAddressType": { - "value": "Public" - }, - "ports1": { - "value": [ - { - "port": "9090", - "protocol": "TCP" - } - ] - }, - "ports2": { - "value": [ - { - "port": "9091", - "protocol": "TCP" - } - ] - }, - "command1": { - "value": [ - "/bin/prometheus", - "--config.file=/etc/prometheus/prometheus.yaml", - "--storage.tsdb.path=/prometheus/" - ] - }, - "command2": { - "value": [ - "/bin/stackdriver-prometheus-sidecar", - "--stackdriver.project-id=celo-phone-number-privacy-stg", - "--prometheus.wal-directory=/prometheus/wal", - "--stackdriver.kubernetes.location=us-central1", - "--stackdriver.kubernetes.cluster-name=odis-staging", - "--log.level=debug" - ] - }, - "volumeMounts1": { - "value": [ - { - "name": "prometheus-storage-volume", - "mountPath": "/prometheus" - }, - { - "name": "prometheus-config-volume", - "mountPath": "/etc/prometheus/" - } - ] - }, - "volumeMounts2": { - "value": [ - { - "name": "prometheus-storage-volume", - "mountPath": "/prometheus" - }, - { - "name": "prometheus-service-account-key", - "mountPath": "/var/secrets/google" - } - ] - }, - "volumes": { - "value": [ - { - "name": "prometheus-storage-volume", - "emptyDir": {} - }, - { - "name": "prometheus-service-account-key", - "secret": { - "prometheus-service-account-key.json": "TODO" - } - }, - { - "name": "prometheus-config-volume", - "secret": { - "prometheus.yaml": "TODO" - } - } - ] - } - } -} \ No newline at end of file diff --git a/packages/phone-number-privacy/signer/azure-templates/prometheus-service-account-key.json b/packages/phone-number-privacy/signer/azure-templates/prometheus-service-account-key.json deleted file mode 100644 index f31a0e4bedc..00000000000 --- a/packages/phone-number-privacy/signer/azure-templates/prometheus-service-account-key.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "service_account", - "project_id": "TODO", - "private_key_id": "TODO", - "private_key": "TODO", - "client_email": "TODO", - "client_id": "TODO", - "auth_uri": "TODO", - "token_uri": "TODO", - "auth_provider_x509_cert_url": "TODO", - "client_x509_cert_url": "TODO" -} \ No newline at end of file diff --git a/packages/phone-number-privacy/signer/azure-templates/prometheus-staging.yaml b/packages/phone-number-privacy/signer/azure-templates/prometheus-staging.yaml deleted file mode 100644 index d6e7a94e190..00000000000 --- a/packages/phone-number-privacy/signer/azure-templates/prometheus-staging.yaml +++ /dev/null @@ -1,14 +0,0 @@ -global: - scrape_interval: 60s -scrape_configs: - - job_name: scrape-odis - metrics_path: /metrics - scheme: http - static_configs: - - targets: - - clabs-staging-pgpnp1-signer.centralus.azurecontainer.io:80 - - clabs-staging-pgpnp-signer.centralus.azurecontainer.io:80 - - clabs-staging-pgpnp2-signer.centralus.azurecontainer.io:80 - labels: - _generic_location: us-central1 - _generic_namespace: odis-signer diff --git a/packages/phone-number-privacy/signer/azure-templates/prometheus-template.json b/packages/phone-number-privacy/signer/azure-templates/prometheus-template.json deleted file mode 100644 index 4f80d68f99d..00000000000 --- a/packages/phone-number-privacy/signer/azure-templates/prometheus-template.json +++ /dev/null @@ -1,132 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "location": { - "type": "string" - }, - "containerGroupName": { - "type": "string" - }, - "containerName1": { - "type": "string" - }, - "containerName2": { - "type": "string" - }, - "imageType1": { - "type": "string", - "allowedValues": [ - "Public", - "Private" - ] - }, - "imageType2": { - "type": "string", - "allowedValues": [ - "Public", - "Private" - ] - }, - "imageName1": { - "type": "string" - }, - "imageName2": { - "type": "string" - }, - "osType": { - "type": "string", - "allowedValues": [ - "Linux" - ] - }, - "numberCpuCores": { - "type": "string" - }, - "memory": { - "type": "string" - }, - "restartPolicy": { - "type": "string", - "allowedValues": [ - "OnFailure", - "Always", - "Never" - ] - }, - "environmentVariables2": { - "type": "array" - }, - "ipAddressType": { - "type": "string" - }, - "ports1": { - "type": "array" - }, - "ports2": { - "type": "array" - }, - "command1": { - "type": "array" - }, - "command2": { - "type": "array" - }, - "volumeMounts1": { - "type": "array" - }, - "volumeMounts2": { - "type": "array" - }, - "volumes": { - "type": "array" - } - }, - "resources": [ - { - "location": "[parameters('location')]", - "name": "[parameters('containerGroupName')]", - "type": "Microsoft.ContainerInstance/containerGroups", - "apiVersion": "2018-10-01", - "properties": { - "containers": [ - { - "name": "[parameters('containerName1')]", - "properties": { - "image": "[parameters('imageName1')]", - "resources": { - "requests": { - "cpu": "[int(parameters('numberCpuCores'))]", - "memoryInGB": "[float(parameters('memory'))]" - } - }, - "ports": "[parameters('ports1')]", - "volumeMounts": "[parameters('volumeMounts1')]", - "command": "[parameters('command1')]" - } - }, - { - "name": "[parameters('containerName2')]", - "properties": { - "image": "[parameters('imageName2')]", - "resources": { - "requests": { - "cpu": "[int(parameters('numberCpuCores'))]", - "memoryInGB": "[float(parameters('memory'))]" - } - }, - "environmentVariables": "[parameters('environmentVariables2')]", - "ports": "[parameters('ports2')]", - "volumeMounts": "[parameters('volumeMounts2')]", - "command": "[parameters('command2')]" - } - } - ], - "restartPolicy": "[parameters('restartPolicy')]", - "osType": "[parameters('osType')]", - "volumes": "[parameters('volumes')]" - }, - "tags": {} - } - ] -} \ No newline at end of file diff --git a/packages/phone-number-privacy/signer/jest.config.js b/packages/phone-number-privacy/signer/jest.config.js index 4d2076c1d8c..11c683c662f 100644 --- a/packages/phone-number-privacy/signer/jest.config.js +++ b/packages/phone-number-privacy/signer/jest.config.js @@ -4,4 +4,11 @@ module.exports = { preset: 'ts-jest', ...nodeFlakeTracking, setupFiles: ['dotenv/config'], + coverageReporters: [['lcov', { projectRoot: '../../../' }], 'text'], + collectCoverageFrom: ['./src/**'], + coverageThreshold: { + global: { + lines: 80, + }, + }, } diff --git a/packages/phone-number-privacy/signer/package.json b/packages/phone-number-privacy/signer/package.json index 26eba15f693..47369a108b2 100644 --- a/packages/phone-number-privacy/signer/package.json +++ b/packages/phone-number-privacy/signer/package.json @@ -1,6 +1,6 @@ { "name": "@celo/phone-number-privacy-signer", - "version": "1.1.9", + "version": "2.0.0-dev", "description": "Signing participator of ODIS", "author": "Celo", "license": "Apache-2.0", @@ -11,7 +11,12 @@ "clean": "tsc -b . --clean", "build": "tsc -b .", "lint": "tslint --project .", - "test": "jest --testPathIgnorePatterns test/end-to-end", + "test": "SKIP_KNOWN_FLAKES=false jest --testPathIgnorePatterns test/end-to-end", + "test:debughandles": "jest --watch --runInBand --detectOpenHandles --testPathIgnorePatterns test/end-to-end", + "test:debug": "node --inspect ../../../node_modules/.bin/jest --runInBand", + "test:coverage": "yarn test --coverage", + "test:integration": "jest --runInBand test/integration", + "test:integration:debugdb": "VERBOSE_DB_LOGGING=true jest --runInBand test/integration", "test:e2e": "jest --runInBand test/end-to-end", "test:e2e:staging:0": "ODIS_SIGNER_SERVICE_URL=https://staging-pgpnp-signer0.azurefd.net yarn test:e2e", "test:e2e:staging:1": "ODIS_SIGNER_SERVICE_URL=https://staging-pgpnp-signer1.azurefd.net yarn test:e2e", @@ -25,13 +30,14 @@ "test:e2e:mainnet:westeurope": "ODIS_SIGNER_SERVICE_URL=https://mainnet-pgpnp-westeurope.azurefd.net yarn test:e2e", "db:migrate": "ts-node scripts/run-migrations.ts", "db:migrate:make": "knex --migrations-directory ./src/migrations migrate:make -x ts", - "bls:keygen": "ts-node scripts/create-bls-keys.ts", + "bls:keygen": "ts-node scripts/threshold-bls-keygen.ts", + "poprf:keygen": "ts-node scripts/poprf-keygen.ts", "ssl:keygen": "./scripts/create-ssl-cert.sh" }, "dependencies": { "@celo/base": "2.3.1-dev", "@celo/contractkit": "2.3.1-dev", - "@celo/phone-number-privacy-common": "1.0.39", + "@celo/phone-number-privacy-common": "1.0.42-dev", "@celo/identity": "2.3.1-dev", "@celo/utils": "2.3.1-dev", "@celo/wallet-hsm-azure": "2.3.1-dev", @@ -42,7 +48,7 @@ "blind-threshold-bls": "https://github.com/celo-org/blind-threshold-bls-wasm#e1e2f8a", "dotenv": "^8.2.0", "express": "^4.17.1", - "knex": "^0.21.1", + "knex": "^2.1.0", "mssql": "^6.3.1", "mysql2": "^2.1.0", "pg": "^8.2.1", @@ -52,8 +58,9 @@ "devDependencies": { "@types/btoa": "^1.2.3", "@types/express": "^4.17.6", - "@types/supertest": "^2.0.9", - "supertest": "^4.0.2", + "@types/supertest": "^2.0.12", + "sqlite3": "^5.0.8", + "supertest": "^6.2.3", "ts-mockito": "^2.6.1", "ts-node": "^8.3.0", "typescript": "4.4.3" @@ -64,4 +71,4 @@ "engines": { "node": ">=10" } -} \ No newline at end of file +} diff --git a/packages/phone-number-privacy/signer/scripts/poprf-keygen.ts b/packages/phone-number-privacy/signer/scripts/poprf-keygen.ts new file mode 100644 index 00000000000..313e257f856 --- /dev/null +++ b/packages/phone-number-privacy/signer/scripts/poprf-keygen.ts @@ -0,0 +1,21 @@ +// tslint:disable: no-console +import * as poprf from '@celo/poprf' +import crypto from 'crypto' + +const t = 2 +const n = 3 +console.log('Creating POPRF Threshold BLS keypairs with %s/%s ratio...', t, n) +console.log('USE ONLY FOR DEVELOPMENT OR TESTING') + +const seed = crypto.randomBytes(32) +const keys = poprf.thresholdKeygen(n, t, seed) +console.log('Private keys (hex):') +for (let i = 0; i < keys.numShares(); i++) { + console.log('Key #%s: %s', i + 1, Buffer.from(keys.getShare(i)).toString('hex')) +} + +console.log('Threshold Public key (base64):') +console.log(Buffer.from(keys.thresholdPublicKey).toString('base64')) + +console.log('Polynomial (hex):') +console.log(Buffer.from(keys.polynomial).toString('hex')) diff --git a/packages/phone-number-privacy/signer/scripts/run-migrations.ts b/packages/phone-number-privacy/signer/scripts/run-migrations.ts index 1ed642c5f3a..583e13b6b29 100644 --- a/packages/phone-number-privacy/signer/scripts/run-migrations.ts +++ b/packages/phone-number-privacy/signer/scripts/run-migrations.ts @@ -1,10 +1,12 @@ // tslint:disable: no-console -import { initDatabase } from '../src/database/database' + +import { initDatabase } from '../src/common/database/database' +import { config } from '../src/config' async function start() { console.info('Running migrations') console.warn('It is no longer necessary to run db migrations seperately prior to startup') - await initDatabase(false) + await initDatabase(config, undefined, false) } start() diff --git a/packages/phone-number-privacy/signer/scripts/create-bls-keys.ts b/packages/phone-number-privacy/signer/scripts/threshold-bls-keygen.ts similarity index 70% rename from packages/phone-number-privacy/signer/scripts/create-bls-keys.ts rename to packages/phone-number-privacy/signer/scripts/threshold-bls-keygen.ts index ea972d78f12..cc5172604ba 100644 --- a/packages/phone-number-privacy/signer/scripts/create-bls-keys.ts +++ b/packages/phone-number-privacy/signer/scripts/threshold-bls-keygen.ts @@ -2,9 +2,9 @@ import threshold_bls from 'blind-threshold-bls' import crypto from 'crypto' -const t = 3 -const n = 4 -console.log('Creating Threshold BLS keypairs with %s/%s ratio...', t, n) +const t = 2 +const n = 3 +console.log('Creating OPRF Threshold BLS keypairs with %s/%s ratio...', t, n) console.log('USE ONLY FOR DEVELOPMENT OR TESTING') const seed = crypto.randomBytes(32) @@ -14,8 +14,8 @@ for (let i = 0; i < keys.numShares(); i++) { console.log('Key #%s: %s', i + 1, Buffer.from(keys.getShare(i)).toString('hex')) } -console.log('Threshold Public key (hex):') -console.log(Buffer.from(keys.thresholdPublicKey).toString('hex')) +console.log('Threshold Public key (base64):') +console.log(Buffer.from(keys.thresholdPublicKey).toString('base64')) console.log('Polynomial (hex):') console.log(Buffer.from(keys.polynomial).toString('hex')) diff --git a/packages/phone-number-privacy/signer/src/common/action.ts b/packages/phone-number-privacy/signer/src/common/action.ts new file mode 100644 index 00000000000..7b77c4b38eb --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/action.ts @@ -0,0 +1,21 @@ +import { + DomainRequest, + OdisRequest, + PhoneNumberPrivacyRequest, +} from '@celo/phone-number-privacy-common' +import { SignerConfig } from '../config' +import { DomainSession } from '../domain/session' +import { PnpSession } from '../pnp/session' +import { IO } from './io' + +export type Session = R extends DomainRequest + ? DomainSession + : never | R extends PhoneNumberPrivacyRequest + ? PnpSession + : never + +export interface Action { + readonly config: SignerConfig + readonly io: IO + perform(session: Session, timeoutError: symbol): Promise +} diff --git a/packages/phone-number-privacy/signer/src/bls/bls-cryptography-client.ts b/packages/phone-number-privacy/signer/src/common/bls/bls-cryptography-client.ts similarity index 83% rename from packages/phone-number-privacy/signer/src/bls/bls-cryptography-client.ts rename to packages/phone-number-privacy/signer/src/common/bls/bls-cryptography-client.ts index b3cbe9b006a..6e8b2d0205f 100644 --- a/packages/phone-number-privacy/signer/src/bls/bls-cryptography-client.ts +++ b/packages/phone-number-privacy/signer/src/common/bls/bls-cryptography-client.ts @@ -1,7 +1,7 @@ import { ErrorMessage } from '@celo/phone-number-privacy-common' import threshold_bls from 'blind-threshold-bls' import Logger from 'bunyan' -import { Counters } from '../common/metrics' +import { Counters } from '../metrics' /* * Computes the BLS signature for the blinded phone number. */ @@ -25,7 +25,7 @@ export function computeBlindedSignature( return Buffer.from(signedMsg).toString('base64') } catch (err) { Counters.signatureComputationErrors.inc() - logger.error(ErrorMessage.SIGNATURE_COMPUTATION_FAILURE) - throw err + logger.error({ err }, ErrorMessage.SIGNATURE_COMPUTATION_FAILURE) + throw new Error(ErrorMessage.SIGNATURE_COMPUTATION_FAILURE) } } diff --git a/packages/phone-number-privacy/signer/src/common/controller.ts b/packages/phone-number-privacy/signer/src/common/controller.ts new file mode 100644 index 00000000000..1854f930f20 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/controller.ts @@ -0,0 +1,52 @@ +import { + ErrorMessage, + ErrorType, + OdisRequest, + OdisResponse, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { Request, Response } from 'express' +import { Action } from './action' +import { Counters, Histograms, meter } from './metrics' + +export class Controller { + constructor(readonly action: Action) {} + + public async handle( + request: Request<{}, {}, unknown>, + response: Response> + ): Promise { + Counters.requests.labels(this.action.io.endpoint).inc() + // Unique error to be thrown on timeout + const timeoutError = Symbol() + await meter( + async () => { + const session = await this.action.io.init(request, response) + // Init returns a response to the user internally. + if (session) { + await this.action.perform(session, timeoutError) + } + }, + [], + (err: any) => { + response.locals.logger.error({ err }, `Error in handler for ${this.action.io.endpoint}`) + + let errMsg: ErrorType = ErrorMessage.UNKNOWN_ERROR + if (err === timeoutError) { + Counters.timeouts.inc() + errMsg = ErrorMessage.TIMEOUT_FROM_SIGNER + } else if ( + err instanceof Error && + // Propagate standard error & warning messages thrown during endpoint handling + (Object.values(ErrorMessage).includes(err.message as ErrorMessage) || + Object.values(WarningMessage).includes(err.message as WarningMessage)) + ) { + errMsg = err.message as ErrorType + } + this.action.io.sendFailure(errMsg, 500, response) + }, + Histograms.responseLatency, + [this.action.io.endpoint] + ) + } +} diff --git a/packages/phone-number-privacy/signer/src/database/database.ts b/packages/phone-number-privacy/signer/src/common/database/database.ts similarity index 50% rename from packages/phone-number-privacy/signer/src/database/database.ts rename to packages/phone-number-privacy/signer/src/common/database/database.ts index b5204a55bfd..40231ee194e 100644 --- a/packages/phone-number-privacy/signer/src/database/database.ts +++ b/packages/phone-number-privacy/signer/src/common/database/database.ts @@ -1,19 +1,22 @@ -import { rootLogger as logger } from '@celo/phone-number-privacy-common' -import knex from 'knex' -import Knex from 'knex/types' -import config, { DEV_MODE, SupportedDatabase } from '../config' +import { rootLogger } from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import { Knex, knex } from 'knex' +import { DEV_MODE, SignerConfig, SupportedDatabase, VERBOSE_DB_LOGGING } from '../../config' import { ACCOUNTS_COLUMNS, ACCOUNTS_TABLE } from './models/account' -let db: Knex | undefined - -export async function initDatabase(doTestQuery = true) { - logger().info({ config: config.db }, 'Initializing database connection') +export async function initDatabase( + config: SignerConfig, + migrationsPath?: string, + doTestQuery = true +): Promise { + const logger = rootLogger(config.serviceName) + logger.info({ config: config.db }, 'Initializing database connection') const { type, host, port, user, password, database, ssl, poolMaxSize } = config.db let connection: any let client: string if (type === SupportedDatabase.Postgres) { - logger().info('Using Postgres') + logger.info('Using Postgres') client = 'pg' connection = { user, @@ -25,7 +28,7 @@ export async function initDatabase(doTestQuery = true) { pool: { max: poolMaxSize }, } } else if (type === SupportedDatabase.MySql) { - logger().info('Using MySql') + logger.info('Using MySql') client = 'mysql2' connection = { user, @@ -37,7 +40,7 @@ export async function initDatabase(doTestQuery = true) { pool: { max: poolMaxSize }, } } else if (type === SupportedDatabase.MsSql) { - logger().info('Using MS SQL') + logger.info('Using MS SQL') client = 'mssql' connection = { user, @@ -48,47 +51,38 @@ export async function initDatabase(doTestQuery = true) { pool: { max: poolMaxSize }, } } else if (type === SupportedDatabase.Sqlite) { - logger().info('Using SQLite') + logger.info('Using SQLite') client = 'sqlite3' connection = ':memory:' } else { throw new Error(`Unsupported database type: ${type}`) } - db = knex({ + const db = knex({ client, useNullAsDefault: type === SupportedDatabase.Sqlite, connection, - debug: DEV_MODE, + debug: DEV_MODE && VERBOSE_DB_LOGGING, }) - logger().info('Running Migrations') + logger.info('Running Migrations') await db.migrate.latest({ - directory: './dist/migrations', - loadExtensions: ['.js'], + directory: migrationsPath ?? './src/common/database/migrations', + loadExtensions: ['.ts', '.js'], }) if (doTestQuery) { - await executeTestQuery(db) + await executeTestQuery(db, logger) } - logger().info('Database initialized successfully') + logger.info('Database initialized successfully') return db } -// Closes the connections to the database. -// If the database is sqlite in-memory database, the database will be destroyed. -export async function closeDatabase() { - // NOTE: If this operation is stuck (e.g. if you tests are failing because this operation causes - // them to time out) it is likely because a connection is being held open e.g. by a transaction. - await db?.destroy() - db = undefined -} - -async function executeTestQuery(_db: Knex) { - logger().info('Counting accounts') - const result = await _db(ACCOUNTS_TABLE).count(ACCOUNTS_COLUMNS.address).first() +async function executeTestQuery(db: Knex, logger: Logger) { + logger.info('Counting accounts') + const result = await db(ACCOUNTS_TABLE.LEGACY).count(ACCOUNTS_COLUMNS.address).first() if (!result) { throw new Error('No result from count, have migrations been run?') @@ -99,13 +93,5 @@ async function executeTestQuery(_db: Knex) { throw new Error('No result from count, have migrations been run?') } - logger().info(`Found ${count} accounts`) -} - -export function getDatabase() { - if (!db) { - throw new Error('Database not yet initialized') - } - - return db + logger.info(`Found ${count} accounts`) } diff --git a/packages/phone-number-privacy/signer/src/migrations/20200330212224_create-accounts-table.ts b/packages/phone-number-privacy/signer/src/common/database/migrations/20200330212224_create-accounts-table.ts similarity index 55% rename from packages/phone-number-privacy/signer/src/migrations/20200330212224_create-accounts-table.ts rename to packages/phone-number-privacy/signer/src/common/database/migrations/20200330212224_create-accounts-table.ts index 2a2365422b5..fae5a17ca13 100644 --- a/packages/phone-number-privacy/signer/src/migrations/20200330212224_create-accounts-table.ts +++ b/packages/phone-number-privacy/signer/src/common/database/migrations/20200330212224_create-accounts-table.ts @@ -1,19 +1,18 @@ -import * as Knex from 'knex' -import { ACCOUNTS_COLUMNS, ACCOUNTS_TABLE } from '../database/models/account' +import { Knex } from 'knex' +import { ACCOUNTS_COLUMNS, ACCOUNTS_TABLE } from '../models/account' export async function up(knex: Knex): Promise { // This check was necessary to switch from using .ts migrations to .js migrations. - if (!(await knex.schema.hasTable(ACCOUNTS_TABLE))) { - return knex.schema.createTable(ACCOUNTS_TABLE, (t) => { + if (!(await knex.schema.hasTable(ACCOUNTS_TABLE.LEGACY))) { + return knex.schema.createTable(ACCOUNTS_TABLE.LEGACY, (t) => { t.string(ACCOUNTS_COLUMNS.address).notNullable().primary() t.dateTime(ACCOUNTS_COLUMNS.createdAt).notNullable() t.integer(ACCOUNTS_COLUMNS.numLookups).unsigned() - t.dateTime(ACCOUNTS_COLUMNS.didMatchmaking) }) } return null } export async function down(knex: Knex): Promise { - return knex.schema.dropTable(ACCOUNTS_TABLE) + return knex.schema.dropTable(ACCOUNTS_TABLE.LEGACY) } diff --git a/packages/phone-number-privacy/signer/src/migrations/20200811163913_create_requests_table.ts b/packages/phone-number-privacy/signer/src/common/database/migrations/20200811163913_create-requests-table.ts similarity index 65% rename from packages/phone-number-privacy/signer/src/migrations/20200811163913_create_requests_table.ts rename to packages/phone-number-privacy/signer/src/common/database/migrations/20200811163913_create-requests-table.ts index 4534fa6b8d3..8c8014725d5 100644 --- a/packages/phone-number-privacy/signer/src/migrations/20200811163913_create_requests_table.ts +++ b/packages/phone-number-privacy/signer/src/common/database/migrations/20200811163913_create-requests-table.ts @@ -1,10 +1,10 @@ -import * as Knex from 'knex' -import { REQUESTS_COLUMNS, REQUESTS_TABLE } from '../database/models/request' +import { Knex } from 'knex' +import { REQUESTS_COLUMNS, REQUESTS_TABLE } from '../models/request' export async function up(knex: Knex): Promise { // This check was necessary to switch from using .ts migrations to .js migrations. - if (!(await knex.schema.hasTable(REQUESTS_TABLE))) { - return knex.schema.createTable(REQUESTS_TABLE, (t) => { + if (!(await knex.schema.hasTable(REQUESTS_TABLE.LEGACY))) { + return knex.schema.createTable(REQUESTS_TABLE.LEGACY, (t) => { t.string(REQUESTS_COLUMNS.address).notNullable() t.dateTime(REQUESTS_COLUMNS.timestamp).notNullable() t.string(REQUESTS_COLUMNS.blindedQuery).notNullable() @@ -19,5 +19,5 @@ export async function up(knex: Knex): Promise { } export async function down(knex: Knex): Promise { - return knex.schema.dropTable(REQUESTS_TABLE) + return knex.schema.dropTable(REQUESTS_TABLE.LEGACY) } diff --git a/packages/phone-number-privacy/signer/src/common/database/migrations/20210421212301_create-indices.ts b/packages/phone-number-privacy/signer/src/common/database/migrations/20210421212301_create-indices.ts new file mode 100644 index 00000000000..94672330d33 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/database/migrations/20210421212301_create-indices.ts @@ -0,0 +1,17 @@ +import { Knex } from 'knex' +import { ACCOUNTS_COLUMNS, ACCOUNTS_TABLE } from '../models/account' + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(ACCOUNTS_TABLE.LEGACY))) { + throw new Error('Unexpected error: Could not find ACCOUNTS_TABLE.LEGACY') + } + return knex.schema.alterTable(ACCOUNTS_TABLE.LEGACY, (t) => { + t.index(ACCOUNTS_COLUMNS.address) + }) +} + +export async function down(knex: Knex): Promise { + return knex.schema.alterTable(ACCOUNTS_TABLE.LEGACY, (t) => { + t.dropIndex(ACCOUNTS_COLUMNS.address) + }) +} diff --git a/packages/phone-number-privacy/signer/src/common/database/migrations/20210921173354_create-domain-state.ts b/packages/phone-number-privacy/signer/src/common/database/migrations/20210921173354_create-domain-state.ts new file mode 100644 index 00000000000..f69ea71c3fb --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/database/migrations/20210921173354_create-domain-state.ts @@ -0,0 +1,19 @@ +import { Knex } from 'knex' +import { DOMAIN_STATE_COLUMNS, DOMAIN_STATE_TABLE } from '../models/domain-state' + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(DOMAIN_STATE_TABLE))) { + return knex.schema.createTable(DOMAIN_STATE_TABLE, (t) => { + t.string(DOMAIN_STATE_COLUMNS.domainHash).notNullable().primary() + t.integer(DOMAIN_STATE_COLUMNS.counter).nullable() + t.boolean(DOMAIN_STATE_COLUMNS.disabled).notNullable().defaultTo(false) + t.integer(DOMAIN_STATE_COLUMNS.timer).nullable() + }) + } + + return null +} + +export async function down(knex: Knex): Promise { + return knex.schema.dropTable(DOMAIN_STATE_TABLE) +} diff --git a/packages/phone-number-privacy/signer/src/common/database/migrations/20220119165335_domain-requests.ts b/packages/phone-number-privacy/signer/src/common/database/migrations/20220119165335_domain-requests.ts new file mode 100644 index 00000000000..7ea61768747 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/database/migrations/20220119165335_domain-requests.ts @@ -0,0 +1,26 @@ +import { Knex } from 'knex' +import { DOMAIN_REQUESTS_COLUMNS, DOMAIN_REQUESTS_TABLE } from '../models/domain-request' + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(DOMAIN_REQUESTS_TABLE))) { + return knex.schema.createTable(DOMAIN_REQUESTS_TABLE, (t) => { + t.string(DOMAIN_REQUESTS_COLUMNS.domainHash).notNullable() + // TODO when implementing replay handling, + // this field needs to either be nonNullable or taken out of the PK + // issue: https://github.com/celo-org/celo-monorepo/issues/9909 + t.dateTime(DOMAIN_REQUESTS_COLUMNS.timestamp).nullable() + t.string(DOMAIN_REQUESTS_COLUMNS.blindedMessage).notNullable() + t.primary([ + DOMAIN_REQUESTS_COLUMNS.domainHash, + DOMAIN_REQUESTS_COLUMNS.timestamp, + DOMAIN_REQUESTS_COLUMNS.blindedMessage, + ]) + }) + } + + return null +} + +export async function down(knex: Knex): Promise { + return knex.schema.dropTable(DOMAIN_REQUESTS_TABLE) +} diff --git a/packages/phone-number-privacy/signer/src/common/database/migrations/20220923161710_pnp-requests-onchain.ts b/packages/phone-number-privacy/signer/src/common/database/migrations/20220923161710_pnp-requests-onchain.ts new file mode 100644 index 00000000000..9e1dd91184e --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/database/migrations/20220923161710_pnp-requests-onchain.ts @@ -0,0 +1,22 @@ +import { Knex } from 'knex' +import { REQUESTS_COLUMNS, REQUESTS_TABLE } from '../models/request' + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(REQUESTS_TABLE.ONCHAIN))) { + return knex.schema.createTable(REQUESTS_TABLE.ONCHAIN, (t) => { + t.string(REQUESTS_COLUMNS.address).notNullable() + t.dateTime(REQUESTS_COLUMNS.timestamp).notNullable() + t.string(REQUESTS_COLUMNS.blindedQuery).notNullable() + t.primary([ + REQUESTS_COLUMNS.address, + REQUESTS_COLUMNS.timestamp, + REQUESTS_COLUMNS.blindedQuery, + ]) + }) + } + return null +} + +export async function down(knex: Knex): Promise { + return knex.schema.dropTable(REQUESTS_TABLE.ONCHAIN) +} diff --git a/packages/phone-number-privacy/signer/src/common/database/migrations/20220923165433_pnp-accounts-onchain.ts b/packages/phone-number-privacy/signer/src/common/database/migrations/20220923165433_pnp-accounts-onchain.ts new file mode 100644 index 00000000000..8e3d3e16843 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/database/migrations/20220923165433_pnp-accounts-onchain.ts @@ -0,0 +1,18 @@ +import { Knex } from 'knex' +import { ACCOUNTS_COLUMNS, ACCOUNTS_TABLE } from '../models/account' + +export async function up(knex: Knex): Promise { + // This check was necessary to switch from using .ts migrations to .js migrations. + if (!(await knex.schema.hasTable(ACCOUNTS_TABLE.ONCHAIN))) { + return knex.schema.createTable(ACCOUNTS_TABLE.ONCHAIN, (t) => { + t.string(ACCOUNTS_COLUMNS.address).notNullable().primary().index() + t.dateTime(ACCOUNTS_COLUMNS.createdAt).notNullable() + t.integer(ACCOUNTS_COLUMNS.numLookups).unsigned() + }) + } + return null +} + +export async function down(knex: Knex): Promise { + return knex.schema.dropTable(ACCOUNTS_TABLE.ONCHAIN) +} diff --git a/packages/phone-number-privacy/signer/src/common/database/models/account.ts b/packages/phone-number-privacy/signer/src/common/database/models/account.ts new file mode 100644 index 00000000000..aab8bd3444b --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/database/models/account.ts @@ -0,0 +1,24 @@ +export enum ACCOUNTS_TABLE { + ONCHAIN = 'accountsOnChain', + LEGACY = 'accounts', +} + +export enum ACCOUNTS_COLUMNS { + address = 'address', + createdAt = 'created_at', + numLookups = 'num_lookups', +} + +export interface AccountRecord { + [ACCOUNTS_COLUMNS.address]: string + [ACCOUNTS_COLUMNS.createdAt]: Date + [ACCOUNTS_COLUMNS.numLookups]: number +} + +export function toAccountRecord(account: string, numLookups: number): AccountRecord { + return { + [ACCOUNTS_COLUMNS.address]: account, + [ACCOUNTS_COLUMNS.createdAt]: new Date(), + [ACCOUNTS_COLUMNS.numLookups]: numLookups, + } +} diff --git a/packages/phone-number-privacy/signer/src/common/database/models/domain-request.ts b/packages/phone-number-privacy/signer/src/common/database/models/domain-request.ts new file mode 100644 index 00000000000..84a3a6fd959 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/database/models/domain-request.ts @@ -0,0 +1,25 @@ +import { Domain, domainHash } from '@celo/phone-number-privacy-common' + +export const DOMAIN_REQUESTS_TABLE = 'domainRequests' +export enum DOMAIN_REQUESTS_COLUMNS { + domainHash = 'domainHash', + timestamp = 'timestamp', + blindedMessage = 'blinded_message', +} + +export interface DomainRequestRecord { + [DOMAIN_REQUESTS_COLUMNS.domainHash]: string + [DOMAIN_REQUESTS_COLUMNS.timestamp]: Date + [DOMAIN_REQUESTS_COLUMNS.blindedMessage]: string +} + +export function toDomainRequestRecord( + domain: D, + blindedMessage: string +): DomainRequestRecord { + return { + [DOMAIN_REQUESTS_COLUMNS.domainHash]: domainHash(domain).toString('hex'), + [DOMAIN_REQUESTS_COLUMNS.timestamp]: new Date(), + [DOMAIN_REQUESTS_COLUMNS.blindedMessage]: blindedMessage, + } +} diff --git a/packages/phone-number-privacy/signer/src/common/database/models/domain-state.ts b/packages/phone-number-privacy/signer/src/common/database/models/domain-state.ts new file mode 100644 index 00000000000..ae1a964eced --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/database/models/domain-state.ts @@ -0,0 +1,46 @@ +import { + Domain, + domainHash, + DomainState, + SequentialDelayDomainState, +} from '@celo/phone-number-privacy-common/lib/domains' + +export const DOMAIN_STATE_TABLE = 'domainState' +export enum DOMAIN_STATE_COLUMNS { + domainHash = 'domainHash', + counter = 'counter', + timer = 'timer', + disabled = 'disabled', +} + +export interface DomainStateRecord { + [DOMAIN_STATE_COLUMNS.domainHash]: string + [DOMAIN_STATE_COLUMNS.disabled]: boolean + [DOMAIN_STATE_COLUMNS.counter]: number + [DOMAIN_STATE_COLUMNS.timer]: number +} + +export function toDomainStateRecord( + domain: D, + domainState: DomainState +): DomainStateRecord { + return { + [DOMAIN_STATE_COLUMNS.domainHash]: domainHash(domain).toString('hex'), + [DOMAIN_STATE_COLUMNS.disabled]: domainState.disabled, + [DOMAIN_STATE_COLUMNS.counter]: domainState.counter, + [DOMAIN_STATE_COLUMNS.timer]: domainState.timer, + } +} + +export function toSequentialDelayDomainState( + record: DomainStateRecord, + attemptTime?: number +): SequentialDelayDomainState { + return { + disabled: record[DOMAIN_STATE_COLUMNS.disabled], + counter: record[DOMAIN_STATE_COLUMNS.counter], + timer: record[DOMAIN_STATE_COLUMNS.timer], + // Timestamp precision is lowered to seconds to reduce the chance of effective timing attacks. + now: attemptTime ?? Math.floor(Date.now() / 1000), + } +} diff --git a/packages/phone-number-privacy/signer/src/common/database/models/request.ts b/packages/phone-number-privacy/signer/src/common/database/models/request.ts new file mode 100644 index 00000000000..dcdb5ae5f75 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/database/models/request.ts @@ -0,0 +1,27 @@ +export enum REQUESTS_TABLE { + LEGACY = 'requests', + ONCHAIN = 'requestsOnChain', +} + +export enum REQUESTS_COLUMNS { + address = 'caller_address', + timestamp = 'timestamp', + blindedQuery = 'blinded_query', +} + +export interface PnpSignRequestRecord { + [REQUESTS_COLUMNS.address]: string + [REQUESTS_COLUMNS.timestamp]: Date + [REQUESTS_COLUMNS.blindedQuery]: string +} + +export function toPnpSignRequestRecord( + account: string, + blindedQuery: string +): PnpSignRequestRecord { + return { + [REQUESTS_COLUMNS.address]: account, + [REQUESTS_COLUMNS.timestamp]: new Date(), + [REQUESTS_COLUMNS.blindedQuery]: blindedQuery, + } +} diff --git a/packages/phone-number-privacy/signer/src/common/database/utils.ts b/packages/phone-number-privacy/signer/src/common/database/utils.ts new file mode 100644 index 00000000000..4d2f8c03eef --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/database/utils.ts @@ -0,0 +1,42 @@ +import { ErrorMessage } from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import { Knex } from 'knex' +import { Counters, Labels } from '../metrics' + +export type DatabaseErrorMessage = + | ErrorMessage.DATABASE_GET_FAILURE + | ErrorMessage.DATABASE_INSERT_FAILURE + | ErrorMessage.DATABASE_UPDATE_FAILURE + +export function countAndThrowDBError( + err: any, + logger: Logger, + errorMsg: DatabaseErrorMessage +): T { + let label: Labels + switch (errorMsg) { + case ErrorMessage.DATABASE_UPDATE_FAILURE: + label = Labels.UPDATE + break + case ErrorMessage.DATABASE_GET_FAILURE: + label = Labels.READ + break + case ErrorMessage.DATABASE_INSERT_FAILURE: + label = Labels.INSERT + break + default: + throw new Error('Unknown database label provided') + } + + Counters.databaseErrors.labels(label).inc() + logger.error({ err }, errorMsg) + throw new Error(errorMsg) +} + +export function tableWithLockForTrx(baseQuery: Knex.QueryBuilder, trx?: Knex.Transaction) { + if (trx) { + // Lock relevant database rows for the duration of the transaction + return baseQuery.transacting(trx).forUpdate() + } + return baseQuery +} diff --git a/packages/phone-number-privacy/signer/src/common/database/wrappers/account.ts b/packages/phone-number-privacy/signer/src/common/database/wrappers/account.ts new file mode 100644 index 00000000000..b40af283d4c --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/database/wrappers/account.ts @@ -0,0 +1,105 @@ +import { DB_TIMEOUT, ErrorMessage } from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import { Knex } from 'knex' +import { Histograms, meter } from '../../metrics' +import { AccountRecord, ACCOUNTS_COLUMNS, ACCOUNTS_TABLE, toAccountRecord } from '../models/account' +import { countAndThrowDBError, tableWithLockForTrx } from '../utils' + +function accounts(db: Knex, table: ACCOUNTS_TABLE) { + return db(table) +} + +/* + * Returns how many queries the account has already performed. + */ +export async function getPerformedQueryCount( + db: Knex, + accountsTable: ACCOUNTS_TABLE, + account: string, + logger: Logger, + trx?: Knex.Transaction +): Promise { + return meter( + async () => { + logger.debug({ account }, 'Getting performed query count') + const queryCounts = await tableWithLockForTrx(accounts(db, accountsTable), trx) + .select(ACCOUNTS_COLUMNS.numLookups) + .where(ACCOUNTS_COLUMNS.address, account) + .first() + .timeout(DB_TIMEOUT) + return queryCounts === undefined ? 0 : queryCounts[ACCOUNTS_COLUMNS.numLookups] + }, + [], + (err: any) => countAndThrowDBError(err, logger, ErrorMessage.DATABASE_GET_FAILURE), + Histograms.dbOpsInstrumentation, + ['getPerformedQueryCount'] + ) +} + +async function getAccountExists( + db: Knex, + accountsTable: ACCOUNTS_TABLE, + account: string, + logger: Logger, + trx?: Knex.Transaction +): Promise { + return meter( + async () => { + const accountRecord = await tableWithLockForTrx(accounts(db, accountsTable), trx) + .where(ACCOUNTS_COLUMNS.address, account) + .first() + .timeout(DB_TIMEOUT) + + return !!accountRecord + }, + [], + (err: any) => countAndThrowDBError(err, logger, ErrorMessage.DATABASE_GET_FAILURE), + Histograms.dbOpsInstrumentation, + ['getAccountExists'] + ) +} + +/* + * Increments query count in database. If record doesn't exist, create one. + */ +export async function incrementQueryCount( + db: Knex, + accountsTable: ACCOUNTS_TABLE, + account: string, + logger: Logger, + trx: Knex.Transaction +): Promise { + return meter( + async () => { + logger.debug({ account }, 'Incrementing query count') + if (await getAccountExists(db, accountsTable, account, logger, trx)) { + await accounts(db, accountsTable) + .transacting(trx) + .where(ACCOUNTS_COLUMNS.address, account) + .increment(ACCOUNTS_COLUMNS.numLookups, 1) + .timeout(DB_TIMEOUT) + } else { + const newAccountRecord = toAccountRecord(account, 1) + await insertRecord(db, accountsTable, newAccountRecord, logger, trx) + } + }, + [], + (err: any) => countAndThrowDBError(err, logger, ErrorMessage.DATABASE_UPDATE_FAILURE), + Histograms.dbOpsInstrumentation, + ['incrementQueryCount'] + ) +} + +async function insertRecord( + db: Knex, + accountsTable: ACCOUNTS_TABLE, + data: AccountRecord, + logger: Logger, + trx: Knex.Transaction +): Promise { + try { + await accounts(db, accountsTable).transacting(trx).insert(data).timeout(DB_TIMEOUT) + } catch (error) { + countAndThrowDBError(error, logger, ErrorMessage.DATABASE_INSERT_FAILURE) + } +} diff --git a/packages/phone-number-privacy/signer/src/common/database/wrappers/domain-request.ts b/packages/phone-number-privacy/signer/src/common/database/wrappers/domain-request.ts new file mode 100644 index 00000000000..3ce83ccfc67 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/database/wrappers/domain-request.ts @@ -0,0 +1,68 @@ +import { DB_TIMEOUT, Domain, domainHash, ErrorMessage } from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import { Knex } from 'knex' +import { Histograms, meter } from '../../metrics' +import { + DOMAIN_REQUESTS_COLUMNS, + DOMAIN_REQUESTS_TABLE, + DomainRequestRecord, + toDomainRequestRecord, +} from '../models/domain-request' +import { countAndThrowDBError } from '../utils' + +// TODO implement replay handling; this file is currently unused +// https://github.com/celo-org/celo-monorepo/issues/9909 + +function domainRequests(db: Knex) { + return db(DOMAIN_REQUESTS_TABLE) +} + +export async function getDomainRequestRecordExists( + db: Knex, + domain: D, + blindedMessage: string, + trx: Knex.Transaction, + logger: Logger +): Promise { + return meter( + async () => { + const hash = domainHash(domain).toString('hex') + logger.debug({ domain, blindedMessage, hash }, 'Checking if domain request exists') + const existingRequest = await domainRequests(db) + .transacting(trx) + .where({ + [DOMAIN_REQUESTS_COLUMNS.domainHash]: hash, + [DOMAIN_REQUESTS_COLUMNS.blindedMessage]: blindedMessage, + }) + .first() + .timeout(DB_TIMEOUT) + return !!existingRequest + }, + [], + (err: any) => countAndThrowDBError(err, logger, ErrorMessage.DATABASE_GET_FAILURE), + Histograms.dbOpsInstrumentation, + ['getDomainRequestRecordExists'] + ) +} + +export async function storeDomainRequestRecord( + db: Knex, + domain: D, + blindedMessage: string, + trx: Knex.Transaction, + logger: Logger +) { + return meter( + async () => { + logger.debug({ domain, blindedMessage }, 'Storing domain restricted signature request') + await domainRequests(db) + .transacting(trx) + .insert(toDomainRequestRecord(domain, blindedMessage)) + .timeout(DB_TIMEOUT) + }, + [], + (err: any) => countAndThrowDBError(err, logger, ErrorMessage.DATABASE_INSERT_FAILURE), + Histograms.dbOpsInstrumentation, + ['storeDomainRequestRecord'] + ) +} diff --git a/packages/phone-number-privacy/signer/src/common/database/wrappers/domain-state.ts b/packages/phone-number-privacy/signer/src/common/database/wrappers/domain-state.ts new file mode 100644 index 00000000000..2c87d23db43 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/database/wrappers/domain-state.ts @@ -0,0 +1,141 @@ +import { DB_TIMEOUT, ErrorMessage } from '@celo/phone-number-privacy-common' +import { Domain, domainHash } from '@celo/phone-number-privacy-common/lib/domains' +import Logger from 'bunyan' +import { Knex } from 'knex' +import { Histograms, meter } from '../../metrics' +import { + DOMAIN_STATE_COLUMNS, + DOMAIN_STATE_TABLE, + DomainStateRecord, + toDomainStateRecord, +} from '../models/domain-state' +import { countAndThrowDBError, tableWithLockForTrx } from '../utils' + +function domainStates(db: Knex) { + return db(DOMAIN_STATE_TABLE) +} + +export async function setDomainDisabled( + db: Knex, + domain: D, + trx: Knex.Transaction, + logger: Logger +): Promise { + return meter( + async () => { + const hash = domainHash(domain).toString('hex') + logger.debug({ hash, domain }, 'Disabling domain') + await domainStates(db) + .transacting(trx) + .where(DOMAIN_STATE_COLUMNS.domainHash, hash) + .update(DOMAIN_STATE_COLUMNS.disabled, true) + .timeout(DB_TIMEOUT) + }, + [], + (err: any) => countAndThrowDBError(err, logger, ErrorMessage.DATABASE_UPDATE_FAILURE), + Histograms.dbOpsInstrumentation, + ['disableDomain'] + ) +} + +export async function getDomainStateRecordOrEmpty( + db: Knex, + domain: Domain, + logger: Logger, + trx?: Knex.Transaction +): Promise { + return ( + (await getDomainStateRecord(db, domain, logger, trx)) ?? createEmptyDomainStateRecord(domain) + ) +} + +export function createEmptyDomainStateRecord(domain: Domain, disabled: boolean = false) { + return toDomainStateRecord(domain, { + timer: 0, + counter: 0, + disabled, + now: 0, + }) +} + +export async function getDomainStateRecord( + db: Knex, + domain: D, + logger: Logger, + trx?: Knex.Transaction +): Promise { + return meter( + async () => { + const hash = domainHash(domain).toString('hex') + logger.debug({ hash, domain }, 'Getting domain state from db') + const result = await tableWithLockForTrx(domainStates(db), trx) + .where(DOMAIN_STATE_COLUMNS.domainHash, hash) + .first() + .timeout(DB_TIMEOUT) + + // bools are stored in db as ints (1 or 0), so we must cast them back + if (result) { + result.disabled = !!result.disabled + } + + return result ?? null + }, + [], + (err: any) => countAndThrowDBError(err, logger, ErrorMessage.DATABASE_GET_FAILURE), + Histograms.dbOpsInstrumentation, + ['getDomainStateRecord'] + ) +} + +export async function updateDomainStateRecord( + db: Knex, + domain: D, + domainState: DomainStateRecord, + trx: Knex.Transaction, + logger: Logger +): Promise { + return meter( + async () => { + const hash = domainHash(domain).toString('hex') + logger.debug({ hash, domain, domainState }, 'Update domain state') + // Check whether the domain is already in the database. + // The current signature flow results in redundant queries of the domain state. + // Consider optimizing in the future: https://github.com/celo-org/celo-monorepo/issues/9855 + const result = await getDomainStateRecord(db, domain, logger, trx) + + // Insert or update the domain state record. + if (!result) { + await insertDomainStateRecord(db, domainState, trx, logger) + } else { + await domainStates(db) + .transacting(trx) + .where(DOMAIN_STATE_COLUMNS.domainHash, hash) + .update(domainState) + .timeout(DB_TIMEOUT) + } + }, + [], + (err: any) => countAndThrowDBError(err, logger, ErrorMessage.DATABASE_UPDATE_FAILURE), + Histograms.dbOpsInstrumentation, + ['updateDomainStateRecord'] + ) +} + +export async function insertDomainStateRecord( + db: Knex, + domainState: DomainStateRecord, + trx: Knex.Transaction, + logger: Logger +): Promise { + return meter( + async () => { + logger.debug({ domainState }, 'Insert domain state') + await domainStates(db).transacting(trx).insert(domainState).timeout(DB_TIMEOUT) + return domainState + }, + [], + (err: any) => countAndThrowDBError(err, logger, ErrorMessage.DATABASE_INSERT_FAILURE), + Histograms.dbOpsInstrumentation, + ['insertDomainState'] + ) +} diff --git a/packages/phone-number-privacy/signer/src/common/database/wrappers/request.ts b/packages/phone-number-privacy/signer/src/common/database/wrappers/request.ts new file mode 100644 index 00000000000..6e90960ffb7 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/database/wrappers/request.ts @@ -0,0 +1,67 @@ +import { DB_TIMEOUT, ErrorMessage } from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import { Knex } from 'knex' +import { Histograms, meter } from '../../metrics' +import { + PnpSignRequestRecord, + REQUESTS_COLUMNS, + REQUESTS_TABLE, + toPnpSignRequestRecord, +} from '../models/request' +import { countAndThrowDBError, tableWithLockForTrx } from '../utils' + +function requests(db: Knex, table: REQUESTS_TABLE) { + return db(table) +} + +export async function getRequestExists( + db: Knex, + requestsTable: REQUESTS_TABLE, + account: string, + blindedQuery: string, + logger: Logger, + trx?: Knex.Transaction +): Promise { + return meter( + async () => { + logger.debug( + `Checking if request exists for account: ${account}, blindedQuery: ${blindedQuery}` + ) + const existingRequest = await tableWithLockForTrx(requests(db, requestsTable), trx) + .where({ + [REQUESTS_COLUMNS.address]: account, + [REQUESTS_COLUMNS.blindedQuery]: blindedQuery, + }) + .first() + .timeout(DB_TIMEOUT) + return !!existingRequest + }, + [], + (err: any) => countAndThrowDBError(err, logger, ErrorMessage.DATABASE_GET_FAILURE), + Histograms.dbOpsInstrumentation, + ['getRequestExists'] + ) +} + +export async function storeRequest( + db: Knex, + requestsTable: REQUESTS_TABLE, + account: string, + blindedQuery: string, + logger: Logger, + trx: Knex.Transaction +): Promise { + return meter( + async () => { + logger.debug(`Storing salt request for: ${account}, blindedQuery: ${blindedQuery}`) + await requests(db, requestsTable) + .transacting(trx) + .insert(toPnpSignRequestRecord(account, blindedQuery)) + .timeout(DB_TIMEOUT) + }, + [], + (err: any) => countAndThrowDBError(err, logger, ErrorMessage.DATABASE_INSERT_FAILURE), + Histograms.dbOpsInstrumentation, + ['storeRequest'] + ) +} diff --git a/packages/phone-number-privacy/signer/src/common/domain/domainState.mapper.ts b/packages/phone-number-privacy/signer/src/common/domain/domainState.mapper.ts deleted file mode 100644 index 8ee165a0dae..00000000000 --- a/packages/phone-number-privacy/signer/src/common/domain/domainState.mapper.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { SequentialDelayDomainState } from '@celo/phone-number-privacy-common' -import { DOMAINS_STATES_COLUMNS, DomainStateRecord } from '../../database/models/domainState' - -export function toSequentialDelayDomainState( - domainState: DomainStateRecord -): SequentialDelayDomainState { - return { - counter: domainState[DOMAINS_STATES_COLUMNS.counter]!, - timer: domainState[DOMAINS_STATES_COLUMNS.timer]!, - disabled: domainState[DOMAINS_STATES_COLUMNS.disabled]!, - } -} diff --git a/packages/phone-number-privacy/signer/src/common/error-utils.ts b/packages/phone-number-privacy/signer/src/common/error-utils.ts deleted file mode 100644 index f353582b75b..00000000000 --- a/packages/phone-number-privacy/signer/src/common/error-utils.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - ErrorMessage, - SignMessageResponseFailure, - WarningMessage, -} from '@celo/phone-number-privacy-common' -import Logger from 'bunyan' -import { Response } from 'express' -import { getVersion } from '../config' -import { Counters } from './metrics' - -export type ErrorType = ErrorMessage | WarningMessage - -export function respondWithError( - endpoint: string, - res: Response, - statusCode: number, - error: ErrorType, - performedQueryCount: number = -1, - totalQuota: number = -1, - blockNumber: number = -1, - signature?: string -) { - const response: SignMessageResponseFailure = { - success: false, - version: getVersion(), - error, - performedQueryCount, - totalQuota, - blockNumber, - signature, - } - - const logger: Logger = res.locals.logger - - if (error in WarningMessage) { - logger.warn({ error, statusCode, response }, 'Responding with warning') - } else { - logger.error({ error, statusCode, response }, 'Responding with error') - } - - Counters.responses.labels(endpoint, statusCode.toString()).inc() - res.status(statusCode).json(response) -} diff --git a/packages/phone-number-privacy/signer/src/common/io.ts b/packages/phone-number-privacy/signer/src/common/io.ts new file mode 100644 index 00000000000..96df9e8115b --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/io.ts @@ -0,0 +1,59 @@ +import { + ErrorType, + FailureResponse, + OdisRequest, + OdisResponse, + SignerEndpoint, + SuccessResponse, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import { Request, Response } from 'express' +import { Session } from './action' + +export abstract class IO { + abstract readonly endpoint: SignerEndpoint + + constructor(readonly enabled: boolean) {} + + abstract init( + request: Request<{}, {}, unknown>, + response: Response> + ): Promise | null> + + abstract validate(request: Request<{}, {}, unknown>): request is Request<{}, {}, R> + + abstract authenticate( + request: Request<{}, {}, R>, + warnings?: string[], + logger?: Logger + ): Promise + + abstract sendFailure( + error: ErrorType, + status: number, + response: Response>, + ...args: unknown[] + ): void + + abstract sendSuccess( + status: number, + response: Response>, + ...args: unknown[] + ): void + + protected inputChecks( + request: Request<{}, {}, unknown>, + response: Response> + ): request is Request<{}, {}, R> { + if (!this.enabled) { + this.sendFailure(WarningMessage.API_UNAVAILABLE, 503, response) + return false + } + if (!this.validate(request)) { + this.sendFailure(WarningMessage.INVALID_INPUT, 400, response) + return false + } + return true + } +} diff --git a/packages/phone-number-privacy/signer/src/common/key-management/aws-key-provider.ts b/packages/phone-number-privacy/signer/src/common/key-management/aws-key-provider.ts new file mode 100644 index 00000000000..5990dc5089e --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/key-management/aws-key-provider.ts @@ -0,0 +1,78 @@ +import { ErrorMessage, rootLogger } from '@celo/phone-number-privacy-common' +import { SecretsManager } from 'aws-sdk' +import { config } from '../../config' +import { Key, KeyProviderBase } from './key-provider-base' + +interface SecretStringResult { + [key: string]: string +} + +export class AWSKeyProvider extends KeyProviderBase { + public async fetchPrivateKeyFromStore(key: Key) { + const logger = rootLogger(config.serviceName) + try { + // Credentials are managed by AWS client as described in https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/setting-credentials-node.html + const { region, secretName, secretKey } = config.keystore.aws + const client = new SecretsManager({ region }) + client.config.update({ region }) + + let privateKey: string + try { + privateKey = await this.fetch(client, this.getCustomKeyVersionString(key), secretKey) + } catch (err) { + logger.info(`Error retrieving key: ${key}`) + logger.error(err) + logger.error(ErrorMessage.KEY_FETCH_ERROR) + privateKey = await this.fetch(client, secretName, secretKey) + } + + this.setPrivateKey(key, privateKey) + } catch (err) { + logger.info('Error retrieving key') + logger.error(err) + throw new Error(ErrorMessage.KEY_FETCH_ERROR) + } + } + + private async fetch(client: SecretsManager, secretName: string, secretKey: string) { + // check for empty strings from undefined env vars + if (!secretName) { + throw new Error('key name is undefined') + } + + const response = await client.getSecretValue({ SecretId: secretName }).promise() + + let privateKey + if (response.SecretString) { + privateKey = this.tryParseSecretString(response.SecretString, secretKey) + } else if (response.SecretBinary) { + // @ts-ignore AWS sdk typings not quite correct + const buff = new Buffer(response.SecretBinary, 'base64') + privateKey = buff.toString('ascii') + } else { + throw new Error('Response has neither string nor binary') + } + + if (!privateKey) { + throw new Error('Secret is empty or undefined') + } + + return privateKey + } + + private tryParseSecretString(secretString: string, key: string) { + if (!secretString) { + throw new Error('Cannot parse empty string') + } + if (!key) { + throw new Error('Cannot parse secret without key') + } + + try { + const secret = JSON.parse(secretString) as SecretStringResult + return secret[key] + } catch (e) { + throw new Error('Expecting JSON, secret string is not valid JSON') + } + } +} diff --git a/packages/phone-number-privacy/signer/src/common/key-management/azure-key-provider.ts b/packages/phone-number-privacy/signer/src/common/key-management/azure-key-provider.ts new file mode 100644 index 00000000000..eef656e72cb --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/key-management/azure-key-provider.ts @@ -0,0 +1,45 @@ +import { ErrorMessage, rootLogger } from '@celo/phone-number-privacy-common' +import { AzureKeyVaultClient } from '@celo/wallet-hsm-azure' +import { config } from '../../config' +import { Key, KeyProviderBase } from './key-provider-base' + +export class AzureKeyProvider extends KeyProviderBase { + public async fetchPrivateKeyFromStore(key: Key) { + const logger = rootLogger(config.serviceName) + try { + const { vaultName, secretName } = config.keystore.azure + const client = new AzureKeyVaultClient(vaultName) + + let privateKey: string + try { + privateKey = await this.fetch(client, this.getCustomKeyVersionString(key)) + } catch (err) { + logger.info(`Error retrieving key: ${key}`) + logger.error(err) + logger.error(ErrorMessage.KEY_FETCH_ERROR) + privateKey = await this.fetch(client, secretName) + } + + this.setPrivateKey(key, privateKey) + } catch (err) { + logger.info('Error retrieving key') + logger.error(err) + throw new Error(ErrorMessage.KEY_FETCH_ERROR) + } + } + + private async fetch(client: AzureKeyVaultClient, secretName: string) { + // check for empty strings from undefined env vars + if (!secretName) { + throw new Error('key name is undefined') + } + + const privateKey = await client.getSecret(secretName) + + if (!privateKey) { + throw new Error('Key is empty or undefined') + } + + return privateKey + } +} diff --git a/packages/phone-number-privacy/signer/src/common/key-management/google-key-provider.ts b/packages/phone-number-privacy/signer/src/common/key-management/google-key-provider.ts new file mode 100644 index 00000000000..b3ad4ccbaa2 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/key-management/google-key-provider.ts @@ -0,0 +1,58 @@ +import { ErrorMessage, rootLogger } from '@celo/phone-number-privacy-common' +import { SecretManagerServiceClient } from '@google-cloud/secret-manager/build/src/v1' +import { config } from '../../config' +import { Key, KeyProviderBase } from './key-provider-base' + +export class GoogleKeyProvider extends KeyProviderBase { + public async fetchPrivateKeyFromStore(key: Key) { + const logger = rootLogger(config.serviceName) + try { + const { projectId, secretName, secretVersion } = config.keystore.google + const client = new SecretManagerServiceClient() + + let privateKey: string + try { + privateKey = await this.fetch( + client, + projectId, + this.getCustomKeyName(key), + key.version.toString() + ) + } catch (err) { + logger.info(`Error retrieving key: ${key}`) + logger.error(err) + logger.error(ErrorMessage.KEY_FETCH_ERROR) + privateKey = await this.fetch(client, projectId, secretName, secretVersion) + } + + this.setPrivateKey(key, privateKey) + } catch (err) { + logger.info('Error retrieving key') + logger.error(err) + throw new Error(ErrorMessage.KEY_FETCH_ERROR) + } + } + + private async fetch( + client: SecretManagerServiceClient, + projectId: string, + secretName: string, + secretVersion: string + ) { + // check for empty strings from undefined env vars + if (!(projectId && secretName && secretVersion)) { + throw new Error('key name is undefined') + } + const secretID = `projects/${projectId}/secrets/${secretName}/versions/${secretVersion}` + const [versionResponse] = await client.accessSecretVersion({ name: secretID }) + + // Extract the payload as a string. + const privateKey = versionResponse?.payload?.data?.toString() + + if (!privateKey) { + throw new Error('Key is empty or undefined') + } + + return privateKey + } +} diff --git a/packages/phone-number-privacy/signer/src/common/key-management/key-provider-base.ts b/packages/phone-number-privacy/signer/src/common/key-management/key-provider-base.ts new file mode 100644 index 00000000000..db1f8e03fcd --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/key-management/key-provider-base.ts @@ -0,0 +1,70 @@ +import { config } from '../../config' + +export enum DefaultKeyName { + PHONE_NUMBER_PRIVACY = 'phoneNumberPrivacy', + DOMAINS = 'domains', +} +export interface Key { + name: DefaultKeyName + version: number +} +export interface KeyProvider { + fetchPrivateKeyFromStore: (key: Key) => Promise + getPrivateKey: (key: Key) => string + getPrivateKeyOrFetchFromStore: (key: Key) => Promise +} + +const PRIVATE_KEY_SIZE = 72 + +export abstract class KeyProviderBase implements KeyProvider { + protected privateKeys: Map + + constructor() { + this.privateKeys = new Map() + } + + public getPrivateKey(key: Key) { + const privateKey = this.privateKeys.get(this.getCustomKeyVersionString(key)) + if (!privateKey) { + throw new Error(`Private key is unavailable: ${key}`) + } + return privateKey + } + + public async getPrivateKeyOrFetchFromStore(key: Key): Promise { + if (key.version < 0) { + throw new Error('Invalid private key version. Key version must be a positive integer.') + } + try { + return this.getPrivateKey(key) + } catch { + await this.fetchPrivateKeyFromStore(key) + return this.getPrivateKey(key) + } + } + + public abstract fetchPrivateKeyFromStore(key: Key): Promise + + getCustomKeyVersionString(key: Key): string { + return `${this.getCustomKeyName(key)}-${key.version}` + } + + protected setPrivateKey(key: Key, privateKey: string) { + privateKey = privateKey ? privateKey.trim() : '' + if (privateKey.length !== PRIVATE_KEY_SIZE) { + throw new Error('Invalid private key') + } + this.privateKeys.set(this.getCustomKeyVersionString(key), privateKey) + } + + protected getCustomKeyName(key: Key) { + switch (key.name) { + case DefaultKeyName.PHONE_NUMBER_PRIVACY: + return config.keystore.keys.phoneNumberPrivacy.name || key.name + case DefaultKeyName.DOMAINS: + return config.keystore.keys.domains.name || key.name + default: + return key.name + } + } +} diff --git a/packages/phone-number-privacy/signer/src/common/key-management/key-provider.ts b/packages/phone-number-privacy/signer/src/common/key-management/key-provider.ts new file mode 100644 index 00000000000..6e6e9a384d1 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/key-management/key-provider.ts @@ -0,0 +1,52 @@ +import { rootLogger } from '@celo/phone-number-privacy-common' +import { SignerConfig, SupportedKeystore } from '../../config' +import { AWSKeyProvider } from './aws-key-provider' +import { AzureKeyProvider } from './azure-key-provider' +import { GoogleKeyProvider } from './google-key-provider' +import { DefaultKeyName, Key, KeyProvider } from './key-provider-base' +import { MockKeyProvider } from './mock-key-provider' + +export function keysToPrefetch(config: SignerConfig): Key[] { + return [ + { + name: DefaultKeyName.PHONE_NUMBER_PRIVACY, + version: config.keystore.keys.phoneNumberPrivacy.latest, + }, + { + name: DefaultKeyName.DOMAINS, + version: config.keystore.keys.domains.latest, + }, + ] +} + +export async function initKeyProvider(config: SignerConfig): Promise { + const logger = rootLogger(config.serviceName) + logger.info('Initializing keystore') + const type = config.keystore.type + + let keyProvider: KeyProvider + + if (type === SupportedKeystore.AZURE_KEY_VAULT) { + logger.info('Using Azure key vault') + keyProvider = new AzureKeyProvider() + } else if (type === SupportedKeystore.GOOGLE_SECRET_MANAGER) { + logger.info('Using Google Secret Manager') + keyProvider = new GoogleKeyProvider() + } else if (type === SupportedKeystore.AWS_SECRET_MANAGER) { + logger.info('Using AWS Secret Manager') + keyProvider = new AWSKeyProvider() + } else if (type === SupportedKeystore.MOCK_SECRET_MANAGER) { + logger.info('Using Mock Secret Manager') + keyProvider = new MockKeyProvider() + } else { + throw new Error('Valid keystore type must be provided') + } + + logger.info(`Fetching keys: ${JSON.stringify(keysToPrefetch(config))}`) + await Promise.all( + keysToPrefetch(config).map(keyProvider.fetchPrivateKeyFromStore.bind(keyProvider)) + ) + logger.info('Done fetching key. Key provider initialized successfully.') + + return keyProvider +} diff --git a/packages/phone-number-privacy/signer/src/common/key-management/mock-key-provider.ts b/packages/phone-number-privacy/signer/src/common/key-management/mock-key-provider.ts new file mode 100644 index 00000000000..8d3e11b5b28 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/key-management/mock-key-provider.ts @@ -0,0 +1,51 @@ +import { + DOMAINS_THRESHOLD_DEV_PK_SHARE_1_V1, + DOMAINS_THRESHOLD_DEV_PK_SHARE_1_V2, + DOMAINS_THRESHOLD_DEV_PK_SHARE_1_V3, + PNP_THRESHOLD_DEV_PK_SHARE_1_V1, + PNP_THRESHOLD_DEV_PK_SHARE_1_V2, + PNP_THRESHOLD_DEV_PK_SHARE_1_V3, +} from '@celo/phone-number-privacy-common/lib/test/values' +import { DefaultKeyName, Key, KeyProviderBase } from './key-provider-base' + +export class MockKeyProvider extends KeyProviderBase { + // prettier-ignore + constructor( + private keyMocks: Map = new Map([ + [ + `${DefaultKeyName.PHONE_NUMBER_PRIVACY}-1`, + PNP_THRESHOLD_DEV_PK_SHARE_1_V1 + ], + [ + `${DefaultKeyName.PHONE_NUMBER_PRIVACY}-2`, + PNP_THRESHOLD_DEV_PK_SHARE_1_V2, + ], + [ + `${DefaultKeyName.PHONE_NUMBER_PRIVACY}-3`, + PNP_THRESHOLD_DEV_PK_SHARE_1_V3 + ], + [ + `${DefaultKeyName.DOMAINS}-1`, + DOMAINS_THRESHOLD_DEV_PK_SHARE_1_V1 + ], + [ + `${DefaultKeyName.DOMAINS}-2`, + DOMAINS_THRESHOLD_DEV_PK_SHARE_1_V2, + ], + [ + `${DefaultKeyName.DOMAINS}-3`, + DOMAINS_THRESHOLD_DEV_PK_SHARE_1_V3 + ], + ]) + ) { + super() + } + + public async fetchPrivateKeyFromStore(key: Key) { + const keyString = this.keyMocks.get(this.getCustomKeyVersionString(key)) + if (keyString) { + return this.setPrivateKey(key, keyString) + } + throw new Error('unknown key for MockKeyProvider') + } +} diff --git a/packages/phone-number-privacy/signer/src/common/metrics.ts b/packages/phone-number-privacy/signer/src/common/metrics.ts index 809fcf5b153..dc5d613ec70 100644 --- a/packages/phone-number-privacy/signer/src/common/metrics.ts +++ b/packages/phone-number-privacy/signer/src/common/metrics.ts @@ -4,10 +4,10 @@ const { Counter, Histogram } = client client.collectDefaultMetrics() // This is just so autocomplete will remind devs what the options are. -export const Labels = { - read: 'read', - update: 'update', - insert: 'insert', +export enum Labels { + READ = 'read', + UPDATE = 'update', + INSERT = 'insert', } export const Counters = { @@ -60,6 +60,24 @@ export const Counters = { name: 'timeouts', help: 'Counter for the number of signer timeouts as measured by the signer', }), + requestsFailingOpen: new Counter({ + name: 'requests_failing_open', + help: + 'Counter for the number of requests bypassing quota or authentication checks due to full-node errors', + }), + requestsFailingClosed: new Counter({ + name: 'requests_failing_closed', + help: + 'Counter for the number of requests failing quota or authentication checks due to full-node errors', + }), + errorsCaughtInEndpointHandler: new Counter({ + name: 'errors_caught_in_endpoint_handler', + help: 'Counter for the number of errors caught in the outermost endpoint handler', + }), + errorsThrownAfterResponseSent: new Counter({ + name: 'errors_thrown_after_response_sent', + help: 'Counter for the number of errors thrown after a response was already sent', + }), } const buckets = [ 0.001, @@ -103,7 +121,7 @@ export const Histograms = { getRemainingQueryCountInstrumentation: new Histogram({ name: 'get_remaining_query_count_instrumentation', help: 'Histogram tracking latency of getRemainingQueryCount function by code segment', - labelNames: ['codeSegment'], + labelNames: ['codeSegment', 'endpoint'], buckets, }), dbOpsInstrumentation: new Histogram({ @@ -115,6 +133,22 @@ export const Histograms = { userRemainingQuotaAtRequest: new Histogram({ name: 'user_remaining_quota_at_request', help: 'Histogram tracking remaining quota of users at time of request', + labelNames: ['endpoint'], buckets, }), } + +declare type InFunction = (...params: T) => Promise + +export async function meter( + inFunction: InFunction, + params: T, + onError: (err: any) => U, + prometheus: client.Histogram, + labels: string[] +): Promise { + const _meter = prometheus.labels(...labels).startTimer() + return inFunction(...params) + .catch(onError) + .finally(_meter) +} diff --git a/packages/phone-number-privacy/signer/src/common/quota.ts b/packages/phone-number-privacy/signer/src/common/quota.ts new file mode 100644 index 00000000000..7900eaf7473 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/quota.ts @@ -0,0 +1,34 @@ +import { + DomainQuotaStatusRequest, + DomainRestrictedSignatureRequest, + OdisRequest, + PnpQuotaRequest, + PnpQuotaStatus, + SignMessageRequest, +} from '@celo/phone-number-privacy-common' +import { Knex } from 'knex' +import { Session } from './action' +import { DomainStateRecord } from './database/models/domain-state' + +// prettier-ignore +export type OdisQuotaStatus = R extends + | DomainQuotaStatusRequest | DomainRestrictedSignatureRequest ? DomainStateRecord : never + | R extends SignMessageRequest | PnpQuotaRequest ? PnpQuotaStatus: never + +export interface OdisQuotaStatusResult { + sufficient: boolean + state: OdisQuotaStatus +} + +export interface QuotaService { + checkAndUpdateQuotaStatus( + state: OdisQuotaStatus, + session: Session, + trx: Knex.Transaction> + ): Promise> + + getQuotaStatus( + session: Session, + trx?: Knex.Transaction> + ): Promise> +} diff --git a/packages/phone-number-privacy/signer/src/common/web3/contracts.ts b/packages/phone-number-privacy/signer/src/common/web3/contracts.ts new file mode 100644 index 00000000000..7b0801f8f3b --- /dev/null +++ b/packages/phone-number-privacy/signer/src/common/web3/contracts.ts @@ -0,0 +1,203 @@ +import { NULL_ADDRESS, retryAsyncWithBackOffAndTimeout } from '@celo/base' +import { ContractKit, StableToken } from '@celo/contractkit' +import { + FULL_NODE_TIMEOUT_IN_MS, + RETRY_COUNT, + RETRY_DELAY_IN_MS, +} from '@celo/phone-number-privacy-common' +import { BigNumber } from 'bignumber.js' +import Logger from 'bunyan' +import { Counters, Histograms, Labels, meter } from '../metrics' + +export async function getBlockNumber(kit: ContractKit): Promise { + return meter( + retryAsyncWithBackOffAndTimeout, + [ + () => kit.connection.getBlockNumber(), + RETRY_COUNT, + [], + RETRY_DELAY_IN_MS, + undefined, + FULL_NODE_TIMEOUT_IN_MS, + ], + (err: any) => { + Counters.blockchainErrors.labels(Labels.READ).inc() + throw err + }, + Histograms.getBlindedSigInstrumentation, + ['getBlockNumber'] + ) +} + +export async function getTransactionCount( + kit: ContractKit, + logger: Logger, + endpoint: string, + ...addresses: string[] +): Promise { + const _getTransactionCount = (...params: string[]) => + Promise.all( + params + .filter((address) => address !== NULL_ADDRESS) + .map((address) => + retryAsyncWithBackOffAndTimeout( + () => kit.connection.getTransactionCount(address), + RETRY_COUNT, + [], + RETRY_DELAY_IN_MS, + undefined, + FULL_NODE_TIMEOUT_IN_MS + ).catch((err) => { + Counters.blockchainErrors.labels(Labels.READ).inc() + throw err + }) + ) + ).then((values) => { + logger.trace({ addresses, txCounts: values }, 'Fetched txCounts for addresses') + return values.reduce((a, b) => a + b) + }) + return meter( + _getTransactionCount, + addresses.filter((address) => address !== NULL_ADDRESS), + (err: any) => { + throw err + }, + Histograms.getRemainingQueryCountInstrumentation, + ['getTransactionCount', endpoint] + ) +} + +export async function getStableTokenBalance( + kit: ContractKit, + stableToken: StableToken, + logger: Logger, + endpoint: string, + ...addresses: string[] +): Promise { + const _getStableTokenBalance = (...params: string[]) => + Promise.all( + params + .filter((address) => address !== NULL_ADDRESS) + .map((address) => + retryAsyncWithBackOffAndTimeout( + async () => (await kit.contracts.getStableToken(stableToken)).balanceOf(address), + RETRY_COUNT, + [], + RETRY_DELAY_IN_MS, + undefined, + FULL_NODE_TIMEOUT_IN_MS + ).catch((err) => { + Counters.blockchainErrors.labels(Labels.READ).inc() + throw err + }) + ) + ).then((values) => { + logger.trace( + { addresses, balances: values.map((bn) => bn.toString()) }, + `Fetched ${stableToken} balances for addresses` + ) + return values.reduce((a, b) => a.plus(b)) + }) + return meter( + _getStableTokenBalance, + addresses, + (err: any) => { + throw err + }, + Histograms.getRemainingQueryCountInstrumentation, + ['getStableTokenBalance', endpoint] + ) +} + +export async function getCeloBalance( + kit: ContractKit, + logger: Logger, + endpoint: string, + ...addresses: string[] +): Promise { + const _getCeloBalance = (...params: string[]) => + Promise.all( + params + .filter((address) => address !== NULL_ADDRESS) + .map((address) => + retryAsyncWithBackOffAndTimeout( + async () => (await kit.contracts.getGoldToken()).balanceOf(address), + RETRY_COUNT, + [], + RETRY_DELAY_IN_MS, + undefined, + FULL_NODE_TIMEOUT_IN_MS + ).catch((err) => { + Counters.blockchainErrors.labels(Labels.READ).inc() + throw err + }) + ) + ).then((values) => { + logger.trace( + { addresses, balances: values.map((bn) => bn.toString()) }, + 'Fetched celo balances for addresses' + ) + return values.reduce((a, b) => a.plus(b)) + }) + return meter( + _getCeloBalance, + addresses, + (err: any) => { + throw err + }, + Histograms.getRemainingQueryCountInstrumentation, + ['getStableTokenBalance', endpoint] + ) +} + +export async function getWalletAddress( + kit: ContractKit, + logger: Logger, + account: string, + endpoint: string +): Promise { + return meter( + retryAsyncWithBackOffAndTimeout, + [ + async () => (await kit.contracts.getAccounts()).getWalletAddress(account), + RETRY_COUNT, + [], + RETRY_DELAY_IN_MS, + undefined, + FULL_NODE_TIMEOUT_IN_MS, + ], + (err: any) => { + logger.error({ err, account }, 'failed to get wallet address for account') + Counters.blockchainErrors.labels(Labels.READ).inc() + return NULL_ADDRESS + }, + Histograms.getRemainingQueryCountInstrumentation, + ['getWalletAddress', endpoint] + ) +} + +export async function getOnChainOdisPayments( + kit: ContractKit, + logger: Logger, + account: string, + endpoint: string +): Promise { + return meter( + retryAsyncWithBackOffAndTimeout, + [ + async () => (await kit.contracts.getOdisPayments()).totalPaidCUSD(account), + RETRY_COUNT, + [], + RETRY_DELAY_IN_MS, + undefined, + FULL_NODE_TIMEOUT_IN_MS, + ], + (err: any) => { + logger.error({ err, account }, 'failed to get on-chain odis balance for account') + Counters.blockchainErrors.labels(Labels.READ).inc() + throw err + }, + Histograms.getRemainingQueryCountInstrumentation, + ['getOnChainOdisPayments', endpoint] + ) +} diff --git a/packages/phone-number-privacy/signer/src/config.ts b/packages/phone-number-privacy/signer/src/config.ts index 6feacf6d87c..85e6012b089 100644 --- a/packages/phone-number-privacy/signer/src/config.ts +++ b/packages/phone-number-privacy/signer/src/config.ts @@ -1,4 +1,4 @@ -import { toBool, toNum } from '@celo/phone-number-privacy-common' +import { BlockchainConfig, toBool } from '@celo/phone-number-privacy-common' import BigNumber from 'bignumber.js' require('dotenv').config() @@ -7,13 +7,7 @@ export function getVersion(): string { return process.env.npm_package_version ? process.env.npm_package_version : '0.0.0' } export const DEV_MODE = process.env.NODE_ENV !== 'production' - -export const DEV_PUBLIC_KEY = - '1f33136ac029a702eb041096bd9ef09dc9c368dde52a972866bdeaff0896f8596b74ab7adfd7318bba38527599768400df44bcab66bcf3843c17a2ce838bcd5a8ba1634c18314ff0565a7c769905b8a8fba27a86bf4c6cb22df89e1badfe2b81' -export const DEV_PRIVATE_KEY = - '00000000dd0005bf4de5f2f052174f5cf58dae1af1d556c7f7f85d6fb3656e1d0f10720f' -export const DEV_POLYNOMIAL = - '01000000000000001f33136ac029a702eb041096bd9ef09dc9c368dde52a972866bdeaff0896f8596b74ab7adfd7318bba38527599768400df44bcab66bcf3843c17a2ce838bcd5a8ba1634c18314ff0565a7c769905b8a8fba27a86bf4c6cb22df89e1badfe2b81' +export const VERBOSE_DB_LOGGING = toBool(process.env.VERBOSE_DB_LOGGING, false) export enum SupportedDatabase { Postgres = 'postgres', // PostgresSQL @@ -23,15 +17,16 @@ export enum SupportedDatabase { } export enum SupportedKeystore { - AzureKeyVault = 'AzureKeyVault', - GoogleSecretManager = 'GoogleSecretManager', - AWSSecretManager = 'AWSSecretManager', - MockSecretManager = 'MockSecretManager', + AZURE_KEY_VAULT = 'AzureKeyVault', + GOOGLE_SECRET_MANAGER = 'GoogleSecretManager', + AWS_SECRET_MANAGER = 'AWSSecretManager', + MOCK_SECRET_MANAGER = 'MockSecretManager', } -interface Config { +export interface SignerConfig { + serviceName: string server: { - port: string | number + port: string | number | undefined sslKeyPath?: string sslCertPath?: string } @@ -42,14 +37,25 @@ interface Config { minDollarBalance: BigNumber minEuroBalance: BigNumber minCeloBalance: BigNumber + queryPriceInCUSD: BigNumber + } + api: { + domains: { + enabled: boolean + } + phoneNumberPrivacy: { + enabled: boolean + shouldFailOpen: boolean + } + legacyPhoneNumberPrivacy: { + enabled: boolean + shouldFailOpen: boolean + } } attestations: { numberAttestationsRequired: number } - blockchain: { - provider: string - apiKey?: string - } + blockchain: BlockchainConfig db: { type: SupportedDatabase user: string @@ -62,6 +68,16 @@ interface Config { } keystore: { type: SupportedKeystore + keys: { + phoneNumberPrivacy: { + name: string + latest: number + } + domains: { + name: string + latest: number + } + } azure: { clientID: string clientSecret: string @@ -85,25 +101,41 @@ interface Config { } const env = process.env as any -const config: Config = { +export const config: SignerConfig = { + serviceName: env.SERVICE_NAME ?? 'odis-signer', server: { - port: toNum(env.SERVER_PORT) || 8080, + port: Number(env.SERVER_PORT ?? 8080), sslKeyPath: env.SERVER_SSL_KEY_PATH, sslCertPath: env.SERVER_SSL_CERT_PATH, }, quota: { - unverifiedQueryMax: toNum(env.UNVERIFIED_QUERY_MAX) || 10, - additionalVerifiedQueryMax: toNum(env.ADDITIONAL_VERIFIED_QUERY_MAX) || 30, - queryPerTransaction: toNum(env.QUERY_PER_TRANSACTION) || 2, + unverifiedQueryMax: Number(env.UNVERIFIED_QUERY_MAX ?? 10), + additionalVerifiedQueryMax: Number(env.ADDITIONAL_VERIFIED_QUERY_MAX ?? 30), + queryPerTransaction: Number(env.QUERY_PER_TRANSACTION ?? 2), // Min balance is .01 cUSD - minDollarBalance: new BigNumber(env.MIN_DOLLAR_BALANCE || 1e16), + minDollarBalance: new BigNumber(env.MIN_DOLLAR_BALANCE ?? 1e16), // Min balance is .01 cEUR - minEuroBalance: new BigNumber(env.MIN_DOLLAR_BALANCE || 1e16), + minEuroBalance: new BigNumber(env.MIN_DOLLAR_BALANCE ?? 1e16), // Min balance is .005 CELO - minCeloBalance: new BigNumber(env.MIN_DOLLAR_BALANCE || 5e15), + minCeloBalance: new BigNumber(env.MIN_DOLLAR_BALANCE ?? 5e15), + // Equivalent to 0.001 cUSD/query + queryPriceInCUSD: new BigNumber(env.QUERY_PRICE_PER_CUSD ?? 0.001), + }, + api: { + domains: { + enabled: toBool(env.DOMAINS_API_ENABLED, false), + }, + phoneNumberPrivacy: { + enabled: toBool(env.PHONE_NUMBER_PRIVACY_API_ENABLED, false), + shouldFailOpen: toBool(env.FULL_NODE_ERRORS_SHOULD_FAIL_OPEN, false), + }, + legacyPhoneNumberPrivacy: { + enabled: toBool(env.LEGACY_PHONE_NUMBER_PRIVACY_API_ENABLED, false), + shouldFailOpen: toBool(env.LEGACY_FULL_NODE_ERRORS_SHOULD_FAIL_OPEN, false), + }, }, attestations: { - numberAttestationsRequired: toNum(env.ATTESTATIONS_NUMBER_ATTESTATIONS_REQUIRED) || 3, + numberAttestationsRequired: Number(env.ATTESTATIONS_NUMBER_ATTESTATIONS_REQUIRED ?? 3), }, blockchain: { provider: env.BLOCKCHAIN_PROVIDER, @@ -115,12 +147,22 @@ const config: Config = { password: env.DB_PASSWORD, database: env.DB_DATABASE, host: env.DB_HOST, - port: env.DB_PORT ? toNum(env.DB_PORT) : undefined, + port: env.DB_PORT ? Number(env.DB_PORT) : undefined, ssl: toBool(env.DB_USE_SSL, true), - poolMaxSize: env.DB_POOL_MAX_SIZE || 50, + poolMaxSize: env.DB_POOL_MAX_SIZE ?? 50, }, keystore: { type: env.KEYSTORE_TYPE, + keys: { + phoneNumberPrivacy: { + name: env.PHONE_NUMBER_PRIVACY_KEY_NAME_BASE, + latest: Number(env.PHONE_NUMBER_PRIVACY_LATEST_KEY_VERSION ?? 1), + }, + domains: { + name: env.DOMAINS_KEY_NAME_BASE, + latest: Number(env.DOMAINS_LATEST_KEY_VERSION ?? 1), + }, + }, azure: { clientID: env.KEYSTORE_AZURE_CLIENT_ID, clientSecret: env.KEYSTORE_AZURE_CLIENT_SECRET, @@ -131,7 +173,7 @@ const config: Config = { google: { projectId: env.KEYSTORE_GOOGLE_PROJECT_ID, secretName: env.KEYSTORE_GOOGLE_SECRET_NAME, - secretVersion: env.KEYSTORE_GOOGLE_SECRET_VERSION || 'latest', + secretVersion: env.KEYSTORE_GOOGLE_SECRET_VERSION ?? 'latest', }, aws: { region: env.KEYSTORE_AWS_REGION, @@ -139,7 +181,6 @@ const config: Config = { secretKey: env.KEYSTORE_AWS_SECRET_KEY, }, }, - timeout: env.ODIS_SIGNER_TIMEOUT || 5000, - test_quota_bypass_percentage: toNum(env.TEST_QUOTA_BYPASS_PERCENTAGE) || 0, + timeout: Number(env.ODIS_SIGNER_TIMEOUT ?? 5000), + test_quota_bypass_percentage: Number(env.TEST_QUOTA_BYPASS_PERCENTAGE ?? 0), } -export default config diff --git a/packages/phone-number-privacy/signer/src/database/models/account.ts b/packages/phone-number-privacy/signer/src/database/models/account.ts deleted file mode 100644 index 61abff838f3..00000000000 --- a/packages/phone-number-privacy/signer/src/database/models/account.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const ACCOUNTS_TABLE = 'accounts' -export enum ACCOUNTS_COLUMNS { - address = 'address', - createdAt = 'created_at', - numLookups = 'num_lookups', - didMatchmaking = 'did_matchmaking', -} -export class Account { - [ACCOUNTS_COLUMNS.address]: string | undefined; - [ACCOUNTS_COLUMNS.createdAt]: Date = new Date(); - [ACCOUNTS_COLUMNS.numLookups]: number = 0; - [ACCOUNTS_COLUMNS.didMatchmaking]: Date | null = null - - constructor(address: string) { - this.address = address - } -} diff --git a/packages/phone-number-privacy/signer/src/database/models/domainState.ts b/packages/phone-number-privacy/signer/src/database/models/domainState.ts deleted file mode 100644 index 6b36f0a8b7c..00000000000 --- a/packages/phone-number-privacy/signer/src/database/models/domainState.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { SequentialDelayDomainState, WarningMessage } from '@celo/phone-number-privacy-common' -import { - Domain, - domainHash, - isSequentialDelayDomain, -} from '@celo/phone-number-privacy-common/lib/domains' - -export const DOMAINS_STATES_TABLE = 'domainsStates' -export enum DOMAINS_STATES_COLUMNS { - domainHash = 'domainHash', - counter = 'counter', - timer = 'timer', - disabled = 'disabled', -} -export class DomainStateRecord { - public static createEmptyDomainState(domain: Domain): DomainStateRecord { - if (isSequentialDelayDomain(domain)) { - return { - [DOMAINS_STATES_COLUMNS.domainHash]: domainHash(domain).toString('hex'), - [DOMAINS_STATES_COLUMNS.counter]: 0, - [DOMAINS_STATES_COLUMNS.timer]: 0, - [DOMAINS_STATES_COLUMNS.disabled]: false, - } - } - - // canary provides a compile-time check that all subtypes of Domain have branches. If a case - // was missed, then an error will report that domain cannot be assigned to type `never`. - const canary = (x: never) => x - canary(domain) - - throw new Error(WarningMessage.UNKNOWN_DOMAIN) - } - - [DOMAINS_STATES_COLUMNS.domainHash]: string; - [DOMAINS_STATES_COLUMNS.counter]: number | undefined; - [DOMAINS_STATES_COLUMNS.timer]: number | undefined; - [DOMAINS_STATES_COLUMNS.disabled]: boolean - - constructor(hash: string, domainState: SequentialDelayDomainState) { - this[DOMAINS_STATES_COLUMNS.domainHash] = hash - this[DOMAINS_STATES_COLUMNS.counter] = domainState.counter - this[DOMAINS_STATES_COLUMNS.timer] = domainState.timer - this[DOMAINS_STATES_COLUMNS.disabled] = domainState.disabled - } -} diff --git a/packages/phone-number-privacy/signer/src/database/models/request.ts b/packages/phone-number-privacy/signer/src/database/models/request.ts deleted file mode 100644 index f8fea9db8cb..00000000000 --- a/packages/phone-number-privacy/signer/src/database/models/request.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { GetBlindedMessagePartialSigRequest } from '../../signing/get-partial-signature' - -export const REQUESTS_TABLE = 'requests' -export enum REQUESTS_COLUMNS { - address = 'caller_address', - timestamp = 'timestamp', - blindedQuery = 'blinded_query', -} -export class Request { - [REQUESTS_COLUMNS.address]: string; - [REQUESTS_COLUMNS.timestamp]: Date; - [REQUESTS_COLUMNS.blindedQuery]: string - - constructor(request: GetBlindedMessagePartialSigRequest) { - this[REQUESTS_COLUMNS.address] = request.account - this[REQUESTS_COLUMNS.timestamp] = new Date() - this[REQUESTS_COLUMNS.blindedQuery] = request.blindedQueryPhoneNumber - } -} diff --git a/packages/phone-number-privacy/signer/src/database/wrappers/account.ts b/packages/phone-number-privacy/signer/src/database/wrappers/account.ts deleted file mode 100644 index ee5a2f3c9ee..00000000000 --- a/packages/phone-number-privacy/signer/src/database/wrappers/account.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { DB_TIMEOUT, ErrorMessage } from '@celo/phone-number-privacy-common' -import Logger from 'bunyan' -import { Counters, Histograms, Labels } from '../../common/metrics' -import { getDatabase } from '../database' -import { Account, ACCOUNTS_COLUMNS, ACCOUNTS_TABLE } from '../models/account' - -function accounts() { - return getDatabase()(ACCOUNTS_TABLE) -} - -/* - * Returns how many queries the account has already performed. - */ -export async function getPerformedQueryCount(account: string, logger: Logger): Promise { - logger.debug({ account }, 'Getting performed query count') - const getPerformedQueryCountMeter = Histograms.dbOpsInstrumentation - .labels('getPerformedQueryCount') - .startTimer() - try { - const queryCounts = await accounts() - .select(ACCOUNTS_COLUMNS.numLookups) - .where(ACCOUNTS_COLUMNS.address, account) - .first() - .timeout(DB_TIMEOUT) - getPerformedQueryCountMeter() - return queryCounts === undefined ? 0 : queryCounts[ACCOUNTS_COLUMNS.numLookups] - } catch (err) { - Counters.databaseErrors.labels(Labels.read).inc() - logger.error(ErrorMessage.DATABASE_GET_FAILURE) - logger.error(err) - getPerformedQueryCountMeter() - return 0 - } -} - -async function getAccountExists(account: string): Promise { - const getAccountExistsMeter = Histograms.dbOpsInstrumentation - .labels('getAccountExists') - .startTimer() - return _getAccountExists(account).finally(getAccountExistsMeter) -} - -async function _getAccountExists(account: string): Promise { - const existingAccountRecord = await accounts().where(ACCOUNTS_COLUMNS.address, account).first() - return !!existingAccountRecord -} - -/* - * Increments query count in database. If record doesn't exist, create one. - */ -async function _incrementQueryCount(account: string, logger: Logger) { - logger.debug({ account }, 'Incrementing query count') - try { - if (await getAccountExists(account)) { - await accounts() - .where(ACCOUNTS_COLUMNS.address, account) - .increment(ACCOUNTS_COLUMNS.numLookups, 1) - .timeout(DB_TIMEOUT) - return true - } else { - const newAccount = new Account(account) - newAccount[ACCOUNTS_COLUMNS.numLookups] = 1 - return insertRecord(newAccount) - } - } catch (err) { - Counters.databaseErrors.labels(Labels.update).inc() - logger.error(ErrorMessage.DATABASE_UPDATE_FAILURE) - logger.error(err) - return null - } -} - -export async function incrementQueryCount(account: string, logger: Logger) { - const incrementQueryCountMeter = Histograms.dbOpsInstrumentation - .labels('incrementQueryCount') - .startTimer() - return _incrementQueryCount(account, logger).finally(incrementQueryCountMeter) -} - -async function insertRecord(data: Account) { - await accounts().insert(data).timeout(DB_TIMEOUT) - return true -} diff --git a/packages/phone-number-privacy/signer/src/database/wrappers/domainState.ts b/packages/phone-number-privacy/signer/src/database/wrappers/domainState.ts deleted file mode 100644 index 30cdf308dc2..00000000000 --- a/packages/phone-number-privacy/signer/src/database/wrappers/domainState.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { DB_TIMEOUT, ErrorMessage } from '@celo/phone-number-privacy-common' -import { Domain, domainHash } from '@celo/phone-number-privacy-common/lib/domains' -import Logger from 'bunyan' -import { Transaction } from 'knex' -import { Counters, Histograms, Labels } from '../../common/metrics' -import { getDatabase } from '../database' -import { - DOMAINS_STATES_COLUMNS, - DOMAINS_STATES_TABLE, - DomainStateRecord, -} from '../models/domainState' - -function domainsStates() { - return getDatabase()(DOMAINS_STATES_TABLE) -} - -export async function setDomainDisabled( - domain: Domain, - trx: Transaction, - logger: Logger -): Promise { - const disableDomainMeter = Histograms.dbOpsInstrumentation.labels('disableDomain').startTimer() - const hash = domainHash(domain).toString('hex') - logger.debug('Disabling domain', { hash, domain }) - try { - await domainsStates() - .transacting(trx) - .where(DOMAINS_STATES_COLUMNS.domainHash, hash) - .update(DOMAINS_STATES_COLUMNS.disabled, true) - .timeout(DB_TIMEOUT) - disableDomainMeter() - } catch (err) { - Counters.databaseErrors.labels(Labels.update).inc() - logger.error(ErrorMessage.DATABASE_UPDATE_FAILURE) - logger.error(err) - disableDomainMeter() - throw err - } finally { - disableDomainMeter() - } -} - -export async function getDomainState( - domain: Domain, - logger: Logger -): Promise { - const getDomainStateMeter = Histograms.dbOpsInstrumentation.labels('getDomainState').startTimer() - const hash = domainHash(domain).toString('hex') - logger.debug('Getting domain state from db', { hash, domain }) - try { - const result = await domainsStates() - .where(DOMAINS_STATES_COLUMNS.domainHash, hash) - .first() - .timeout(DB_TIMEOUT) - getDomainStateMeter() - return result ?? null - } catch (err) { - Counters.databaseErrors.labels(Labels.read).inc() - logger.error(ErrorMessage.DATABASE_GET_FAILURE) - logger.error(err) - getDomainStateMeter() - throw err - } finally { - getDomainStateMeter() - } -} - -export async function getDomainStateWithLock( - domain: Domain, - trx: Transaction, - logger: Logger -): Promise { - const getDomainStateWithLockMeter = Histograms.dbOpsInstrumentation - .labels('getDomainStateWithLock') - .startTimer() - const hash = domainHash(domain).toString('hex') - logger.debug('Getting domain state from db with lock', { hash, domain }) - try { - const result = await domainsStates() - .transacting(trx) - .forUpdate() - .where(DOMAINS_STATES_COLUMNS.domainHash, hash) - .first() - .timeout(DB_TIMEOUT) - getDomainStateWithLockMeter() - return result ?? null - } catch (err) { - Counters.databaseErrors.labels(Labels.read).inc() - logger.error(ErrorMessage.DATABASE_GET_FAILURE) - logger.error(err) - getDomainStateWithLockMeter() - throw err - } finally { - getDomainStateWithLockMeter() - } -} - -export async function updateDomainState( - domain: Domain, - domainState: DomainStateRecord, - trx: Transaction, - logger: Logger -): Promise { - const updateDomainStateMeter = Histograms.dbOpsInstrumentation - .labels('updateDomainState') - .startTimer() - const hash = domainHash(domain).toString('hex') - logger.debug('Update domain state', { hash, domain, domainState }) - try { - // Check whether the domain is already in the database. - // TODO(victor): Usage of this in the signature flow results in redudant queries of the current - // state. It would be good to refactor this to avoid making more than one SELECT. - const result = await domainsStates() - .transacting(trx) - .forUpdate() - .where(DOMAINS_STATES_COLUMNS.domainHash, hash) - .first() - .timeout(DB_TIMEOUT) - - // Insert or update the domain state record. - if (!result) { - await insertDomainState(domainState, trx, logger) - } else { - await domainsStates() - .transacting(trx) - .where(DOMAINS_STATES_COLUMNS.domainHash, hash) - .update(domainState) - .timeout(DB_TIMEOUT) - } - updateDomainStateMeter() - } catch (err) { - Counters.databaseErrors.labels(Labels.update).inc() - logger.error(ErrorMessage.DATABASE_UPDATE_FAILURE) - logger.error(err) - updateDomainStateMeter() - throw err - } finally { - updateDomainStateMeter() - } -} - -export async function insertDomainState( - domainState: DomainStateRecord, - trx: Transaction, - logger: Logger -): Promise { - const insertDomainStateMeter = Histograms.dbOpsInstrumentation - .labels('insertDomainState') - .startTimer() - logger.debug('Insert domain state', { domainState }) - try { - await domainsStates().transacting(trx).insert(domainState).timeout(DB_TIMEOUT) - - insertDomainStateMeter() - - return domainState - } catch (err) { - Counters.databaseErrors.labels(Labels.insert).inc() - logger.error(ErrorMessage.DATABASE_INSERT_FAILURE) - logger.error(err) - insertDomainStateMeter() - throw err - } finally { - insertDomainStateMeter() - } -} diff --git a/packages/phone-number-privacy/signer/src/database/wrappers/request.ts b/packages/phone-number-privacy/signer/src/database/wrappers/request.ts deleted file mode 100644 index 6b4dc7b6f49..00000000000 --- a/packages/phone-number-privacy/signer/src/database/wrappers/request.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { DB_TIMEOUT, ErrorMessage } from '@celo/phone-number-privacy-common' -import Logger from 'bunyan' -import { Counters, Histograms, Labels } from '../../common/metrics' -import { GetBlindedMessagePartialSigRequest } from '../../signing/get-partial-signature' -import { getDatabase } from '../database' -import { Request, REQUESTS_COLUMNS, REQUESTS_TABLE } from '../models/request' - -function requests() { - return getDatabase()(REQUESTS_TABLE) -} - -export async function getRequestExists( - request: GetBlindedMessagePartialSigRequest, - logger: Logger -): Promise { - logger.debug({ request }, 'Checking if request exists') - const getRequestExistsMeter = Histograms.dbOpsInstrumentation - .labels('getRequestExists') - .startTimer() - try { - const existingRequest = await requests() - .where({ - [REQUESTS_COLUMNS.address]: request.account, - [REQUESTS_COLUMNS.blindedQuery]: request.blindedQueryPhoneNumber, - }) - .first() - .timeout(DB_TIMEOUT) - getRequestExistsMeter() - return !!existingRequest - } catch (err) { - Counters.databaseErrors.labels(Labels.read).inc() - logger.error(ErrorMessage.DATABASE_GET_FAILURE) - logger.error(err) - getRequestExistsMeter() - return false - } -} - -export async function storeRequest(request: GetBlindedMessagePartialSigRequest, logger: Logger) { - const storeRequestMeter = Histograms.dbOpsInstrumentation.labels('storeRequest').startTimer() - logger.debug({ request }, 'Storing salt request') - try { - await requests().insert(new Request(request)).timeout(DB_TIMEOUT) - storeRequestMeter() - return true - } catch (err) { - Counters.databaseErrors.labels(Labels.update).inc() - logger.error(ErrorMessage.DATABASE_UPDATE_FAILURE) - logger.error(err) - storeRequestMeter() - return null - } -} diff --git a/packages/phone-number-privacy/signer/src/domain/auth/domainAuth.interface.ts b/packages/phone-number-privacy/signer/src/domain/auth/domainAuth.interface.ts deleted file mode 100644 index 3a05d7e3087..00000000000 --- a/packages/phone-number-privacy/signer/src/domain/auth/domainAuth.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface IDomainAuthService { - authCheck(): boolean // TODO Add params -} diff --git a/packages/phone-number-privacy/signer/src/domain/auth/domainAuth.service.ts b/packages/phone-number-privacy/signer/src/domain/auth/domainAuth.service.ts deleted file mode 100644 index da8bd9d2ff2..00000000000 --- a/packages/phone-number-privacy/signer/src/domain/auth/domainAuth.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { IDomainAuthService } from './domainAuth.interface' - -export class DomainAuthService implements IDomainAuthService { - // TODO real impl - public authCheck(): boolean { - return true - } -} diff --git a/packages/phone-number-privacy/signer/src/domain/domain.interface.ts b/packages/phone-number-privacy/signer/src/domain/domain.interface.ts deleted file mode 100644 index c84f2787141..00000000000 --- a/packages/phone-number-privacy/signer/src/domain/domain.interface.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { - DisableDomainRequest, - DomainQuotaStatusRequest, - DomainRestrictedSignatureRequest, -} from '@celo/phone-number-privacy-common' -import { Request, Response } from 'express' - -export interface IDomainService { - handleDisableDomain( - request: Request<{}, {}, DisableDomainRequest>, - response: Response - ): Promise - handleGetDomainQuotaStatus( - request: Request<{}, {}, DomainQuotaStatusRequest>, - response: Response - ): Promise - handleGetDomainRestrictedSignature( - request: Request<{}, {}, DomainRestrictedSignatureRequest>, - response: Response - ): Promise -} diff --git a/packages/phone-number-privacy/signer/src/domain/domain.service.ts b/packages/phone-number-privacy/signer/src/domain/domain.service.ts deleted file mode 100644 index cd60134f091..00000000000 --- a/packages/phone-number-privacy/signer/src/domain/domain.service.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { - disableDomainRequestSchema, - DisableDomainResponse, - Domain, - DomainEndpoint, - domainHash, - domainQuotaStatusRequestSchema, - DomainQuotaStatusResponse, - DomainQuotaStatusResponseSuccess, - DomainResponse, - domainRestrictedSignatureRequestSchema, - DomainRestrictedSignatureResponse, - DomainRestrictedSignatureResponseSuccess, - DomainSchema, - DomainState, - ErrorMessage, - WarningMessage, -} from '@celo/phone-number-privacy-common' -import Logger from 'bunyan' -import { Request, Response } from 'express' -import { computeBlindedSignature } from '../bls/bls-cryptography-client' -import { Counters } from '../common/metrics' -import { getVersion } from '../config' -import { getDatabase } from '../database/database' -import { DOMAINS_STATES_COLUMNS, DomainStateRecord } from '../database/models/domainState' -import { - getDomainState, - getDomainStateWithLock, - insertDomainState, - setDomainDisabled, -} from '../database/wrappers/domainState' -import { getKeyProvider } from '../key-management/key-provider' -import { IDomainAuthService } from './auth/domainAuth.interface' -import { IDomainService } from './domain.interface' -import { IDomainQuotaService } from './quota/domainQuota.interface' - -// TODO: De-dupe with common package -function respondWithError( - endpoint: DomainEndpoint, - res: Response, - status: number, - error: ErrorMessage | WarningMessage -) { - const response: DomainResponse = { - success: false, - version: getVersion(), - error, - } - - const logger: Logger = res.locals.logger - - if (error in WarningMessage) { - logger.warn({ error, status, response }, 'Responding with warning') - } else { - logger.error({ error, status, response }, 'Responding with error') - } - - Counters.responses.labels(endpoint, status.toString()).inc() - res.status(status).json(response) -} - -export class DomainService implements IDomainService { - public constructor( - private authService: IDomainAuthService, - private quotaService: IDomainQuotaService - ) {} - - public async handleDisableDomain( - request: Request<{}, {}, unknown>, - response: Response - ): Promise { - Counters.requests.labels(DomainEndpoint.DISABLE_DOMAIN).inc() - - // Check that the body contains the correct request type. - if (!disableDomainRequestSchema(DomainSchema).is(request.body)) { - respondWithError(DomainEndpoint.DISABLE_DOMAIN, response, 400, WarningMessage.INVALID_INPUT) - return - } - - const logger = response.locals.logger - const domain = request.body.domain - if (!this.authenticateRequest(domain, response, DomainEndpoint.DISABLE_DOMAIN, logger)) { - // authenticateRequest returns a response to the user internally. Nothing left to do. - return - } - - logger.info('Processing request to disable domain', { - name: domain.name, - version: domain.version, - hash: domainHash(domain), - }) - try { - // Inside a database transaction, update or create the domain to mark it disabled. - await getDatabase().transaction(async (trx) => { - const domainState = await getDomainStateWithLock(domain, trx, logger) - if (!domainState) { - // If the domain is not currently recorded in the state database, add it now. - await insertDomainState(DomainStateRecord.createEmptyDomainState(domain), trx, logger) - } - if (!(domainState?.disabled ?? false)) { - await setDomainDisabled(domain, trx, logger) - } - }) - - response.status(200).send({ success: true, version: getVersion() }) - } catch (error) { - logger.error('Error while disabling domain', error) - respondWithError( - DomainEndpoint.DISABLE_DOMAIN, - response, - 500, - ErrorMessage.DATABASE_UPDATE_FAILURE - ) - } - } - - public async handleGetDomainQuotaStatus( - request: Request<{}, {}, unknown>, - response: Response - ): Promise { - Counters.requests.labels(DomainEndpoint.DOMAIN_QUOTA_STATUS).inc() - - // Check that the body contains the correct request type. - if (!domainQuotaStatusRequestSchema(DomainSchema).is(request.body)) { - respondWithError( - DomainEndpoint.DOMAIN_QUOTA_STATUS, - response, - 400, - WarningMessage.INVALID_INPUT - ) - return - } - - const logger = response.locals.logger - const domain = request.body.domain - if (!this.authenticateRequest(domain, response, DomainEndpoint.DOMAIN_QUOTA_STATUS, logger)) { - // authenticateRequest returns a response to the user internally. Nothing left to do. - return - } - - logger.info('Processing request to get domain quota status', { - name: domain.name, - version: domain.version, - hash: domainHash(domain), - }) - try { - const domainState = await getDomainState(domain, logger) - let quotaStatus: DomainState - if (domainState) { - quotaStatus = { - counter: domainState[DOMAINS_STATES_COLUMNS.counter] ?? 0, - disabled: domainState[DOMAINS_STATES_COLUMNS.disabled], - timer: domainState[DOMAINS_STATES_COLUMNS.timer] ?? 0, - } - } else { - quotaStatus = { - counter: 0, - disabled: false, - timer: 0, - } - } - - const resultResponse: DomainQuotaStatusResponseSuccess = { - success: true, - version: getVersion(), - status: quotaStatus, - } - response.status(200).send(resultResponse) - } catch (error) { - logger.error('Error while getting domain status', error) - respondWithError( - DomainEndpoint.DOMAIN_QUOTA_STATUS, - response, - 500, - ErrorMessage.DATABASE_GET_FAILURE - ) - } - } - - public async handleGetDomainRestrictedSignature( - request: Request<{}, {}, unknown>, - response: Response - ): Promise { - Counters.requests.labels(DomainEndpoint.DOMAIN_SIGN).inc() - - // Check that the body contains the correct request type. - if (!domainRestrictedSignatureRequestSchema(DomainSchema).is(request.body)) { - respondWithError(DomainEndpoint.DOMAIN_SIGN, response, 400, WarningMessage.INVALID_INPUT) - return - } - - const logger = response.locals.logger - const domain = request.body.domain - const blindedMessage = request.body.blindedMessage - if (!this.authenticateRequest(domain, response, DomainEndpoint.DOMAIN_SIGN, logger)) { - // authenticateRequest returns a response to the user internally. Nothing left to do. - return - } - logger.info('Processing request to get domain signature ', { - name: domain.name, - version: domain.version, - hash: domainHash(domain), - }) - - try { - let signature: string | undefined - await getDatabase().transaction(async (trx) => { - // Get the current domain state record, or use an empty record one does not exist. - const domainState = - (await getDomainStateWithLock(domain, trx, logger)) ?? - DomainStateRecord.createEmptyDomainState(domain) - - const quotaState = await this.quotaService.checkAndUpdateQuota( - domain, - domainState, - trx, - logger - ) - - if (!quotaState.sufficient) { - logger.warn(`Exceeded quota`, { - name: domain.name, - version: domain.version, - hash: domainHash(domain), - }) - respondWithError(DomainEndpoint.DOMAIN_SIGN, response, 429, WarningMessage.EXCEEDED_QUOTA) - return - } - - // Compute the signature inside the transaction such that it will rollback on error. - const keyProvider = getKeyProvider() - const privateKey = keyProvider.getPrivateKey() - signature = computeBlindedSignature(blindedMessage, privateKey, logger) - }) - - // TODO(victor): Checking the existance of the sigature to determine whether this operation - // succeeded is a little clunky. Refactor this to improve the flow. - if (signature) { - const signMessageResponseSuccess: DomainRestrictedSignatureResponseSuccess = { - success: true, - version: getVersion(), - signature, - } - response.status(200).json(signMessageResponseSuccess) - } - } catch (err) { - logger.error('Failed to get signature for a domain') - logger.error(err) - respondWithError(DomainEndpoint.DOMAIN_SIGN, response, 500, ErrorMessage.UNKNOWN_ERROR) - } - } - - private authenticateRequest( - domain: Domain, - response: Response, - endpoint: DomainEndpoint, - logger: Logger - ): boolean { - if (!this.authService.authCheck()) { - logger.warn(`Received unauthorized request to ${endpoint} `, { - name: domain.name, - version: domain.version, - }) - respondWithError(endpoint, response, 403, WarningMessage.UNAUTHENTICATED_USER) - return false - } - - return true - } -} diff --git a/packages/phone-number-privacy/signer/src/domain/endpoints/disable/action.ts b/packages/phone-number-privacy/signer/src/domain/endpoints/disable/action.ts new file mode 100644 index 00000000000..779685b0123 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/domain/endpoints/disable/action.ts @@ -0,0 +1,63 @@ +import { timeout } from '@celo/base' +import { DisableDomainRequest, domainHash } from '@celo/phone-number-privacy-common' +import { Knex } from 'knex' +import { Action } from '../../../common/action' +import { toSequentialDelayDomainState } from '../../../common/database/models/domain-state' +import { + createEmptyDomainStateRecord, + getDomainStateRecord, + insertDomainStateRecord, + setDomainDisabled, +} from '../../../common/database/wrappers/domain-state' +import { SignerConfig } from '../../../config' +import { DomainSession } from '../../session' +import { DomainDisableIO } from './io' + +export class DomainDisableAction implements Action { + constructor(readonly db: Knex, readonly config: SignerConfig, readonly io: DomainDisableIO) {} + + public async perform( + session: DomainSession, + timeoutError: symbol + ): Promise { + const domain = session.request.body.domain + session.logger.info( + { + name: domain.name, + version: domain.version, + hash: domainHash(domain).toString('hex'), + }, + 'Processing request to disable domain' + ) + // Inside a database transaction, update or create the domain to mark it disabled. + const res = await this.db.transaction(async (trx) => { + const disableDomainHandler = async () => { + const domainStateRecord = + (await getDomainStateRecord(this.db, domain, session.logger, trx)) ?? + (await insertDomainStateRecord( + this.db, + createEmptyDomainStateRecord(domain, true), + trx, + session.logger + )) + if (!domainStateRecord.disabled) { + await setDomainDisabled(this.db, domain, trx, session.logger) + domainStateRecord.disabled = true + } + return { + success: true, + status: 200, + domainStateRecord, + } + } + // Ensure timeouts roll back DB trx + return timeout(disableDomainHandler, [], this.config.timeout, timeoutError) + }) + + this.io.sendSuccess( + res.status, + session.response, + toSequentialDelayDomainState(res.domainStateRecord) + ) + } +} diff --git a/packages/phone-number-privacy/signer/src/domain/endpoints/disable/io.ts b/packages/phone-number-privacy/signer/src/domain/endpoints/disable/io.ts new file mode 100644 index 00000000000..cc8d4ffd2af --- /dev/null +++ b/packages/phone-number-privacy/signer/src/domain/endpoints/disable/io.ts @@ -0,0 +1,78 @@ +import { + DisableDomainRequest, + disableDomainRequestSchema, + DisableDomainResponse, + DisableDomainResponseFailure, + DisableDomainResponseSuccess, + DomainSchema, + DomainState, + ErrorType, + send, + SignerEndpoint, + verifyDisableDomainRequestAuthenticity, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { Request, Response } from 'express' +import { IO } from '../../../common/io' +import { Counters } from '../../../common/metrics' +import { getVersion } from '../../../config' +import { DomainSession } from '../../session' + +export class DomainDisableIO extends IO { + readonly endpoint = SignerEndpoint.DISABLE_DOMAIN + + async init( + request: Request<{}, {}, unknown>, + response: Response + ): Promise | null> { + // Input checks sends a response to the user internally. + if (!super.inputChecks(request, response)) { + return null + } + if (!(await this.authenticate(request))) { + this.sendFailure(WarningMessage.UNAUTHENTICATED_USER, 401, response) + return null + } + return new DomainSession(request, response) + } + + validate(request: Request<{}, {}, unknown>): request is Request<{}, {}, DisableDomainRequest> { + return disableDomainRequestSchema(DomainSchema).is(request.body) + } + + authenticate(request: Request<{}, {}, DisableDomainRequest>): Promise { + return Promise.resolve(verifyDisableDomainRequestAuthenticity(request.body)) + } + + sendSuccess( + status: number, + response: Response, + domainState: DomainState + ) { + send( + response, + { + success: true, + version: getVersion(), + status: domainState, + }, + status, + response.locals.logger + ) + Counters.responses.labels(this.endpoint, status.toString()).inc() + } + + sendFailure(error: ErrorType, status: number, response: Response) { + send( + response, + { + success: false, + version: getVersion(), + error, + }, + status, + response.locals.logger + ) + Counters.responses.labels(this.endpoint, status.toString()).inc() + } +} diff --git a/packages/phone-number-privacy/signer/src/domain/endpoints/quota/action.ts b/packages/phone-number-privacy/signer/src/domain/endpoints/quota/action.ts new file mode 100644 index 00000000000..babddd4a7cf --- /dev/null +++ b/packages/phone-number-privacy/signer/src/domain/endpoints/quota/action.ts @@ -0,0 +1,35 @@ +import { timeout } from '@celo/base' +import { domainHash, DomainQuotaStatusRequest } from '@celo/phone-number-privacy-common' +import { Action } from '../../../common/action' +import { toSequentialDelayDomainState } from '../../../common/database/models/domain-state' +import { SignerConfig } from '../../../config' +import { DomainQuotaService } from '../../services/quota' +import { DomainSession } from '../../session' +import { DomainQuotaIO } from './io' + +export class DomainQuotaAction implements Action { + constructor( + readonly config: SignerConfig, + readonly quotaService: DomainQuotaService, + readonly io: DomainQuotaIO + ) {} + + public async perform( + session: DomainSession, + timeoutError: symbol + ): Promise { + const domain = session.request.body.domain + session.logger.info('Processing request to get domain quota status', { + name: domain.name, + version: domain.version, + hash: domainHash(domain).toString('hex'), + }) + const domainStateRecord = await timeout( + () => this.quotaService.getQuotaStatus(session), + [], + this.config.timeout, + timeoutError + ) + this.io.sendSuccess(200, session.response, toSequentialDelayDomainState(domainStateRecord)) + } +} diff --git a/packages/phone-number-privacy/signer/src/domain/endpoints/quota/io.ts b/packages/phone-number-privacy/signer/src/domain/endpoints/quota/io.ts new file mode 100644 index 00000000000..7153203eec0 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/domain/endpoints/quota/io.ts @@ -0,0 +1,83 @@ +import { + DomainQuotaStatusRequest, + domainQuotaStatusRequestSchema, + DomainQuotaStatusResponse, + DomainQuotaStatusResponseFailure, + DomainQuotaStatusResponseSuccess, + DomainSchema, + DomainState, + ErrorType, + send, + SignerEndpoint, + verifyDomainQuotaStatusRequestAuthenticity, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { Request, Response } from 'express' +import { IO } from '../../../common/io' +import { Counters } from '../../../common/metrics' +import { getVersion } from '../../../config' +import { DomainSession } from '../../session' + +export class DomainQuotaIO extends IO { + readonly endpoint = SignerEndpoint.DOMAIN_QUOTA_STATUS + + async init( + request: Request<{}, {}, unknown>, + response: Response + ): Promise | null> { + if (!super.inputChecks(request, response)) { + return null + } + if (!(await this.authenticate(request))) { + this.sendFailure(WarningMessage.UNAUTHENTICATED_USER, 401, response) + return null + } + return new DomainSession(request, response) + } + + validate( + request: Request<{}, {}, unknown> + ): request is Request<{}, {}, DomainQuotaStatusRequest> { + return domainQuotaStatusRequestSchema(DomainSchema).is(request.body) + } + + authenticate(request: Request<{}, {}, DomainQuotaStatusRequest>): Promise { + return Promise.resolve(verifyDomainQuotaStatusRequestAuthenticity(request.body)) + } + + sendSuccess( + status: number, + response: Response, + domainState: DomainState + ) { + send( + response, + { + success: true, + version: getVersion(), + status: domainState, + }, + status, + response.locals.logger + ) + Counters.responses.labels(this.endpoint, status.toString()).inc() + } + + sendFailure( + error: ErrorType, + status: number, + response: Response + ) { + send( + response, + { + success: false, + version: getVersion(), + error, + }, + status, + response.locals.logger + ) + Counters.responses.labels(this.endpoint, status.toString()).inc() + } +} diff --git a/packages/phone-number-privacy/signer/src/domain/endpoints/sign/action.ts b/packages/phone-number-privacy/signer/src/domain/endpoints/sign/action.ts new file mode 100644 index 00000000000..446baa506cb --- /dev/null +++ b/packages/phone-number-privacy/signer/src/domain/endpoints/sign/action.ts @@ -0,0 +1,175 @@ +import { timeout } from '@celo/base' +import { + Domain, + domainHash, + DomainRestrictedSignatureRequest, + ErrorType, + getRequestKeyVersion, + ThresholdPoprfServer, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { EIP712Optional } from '@celo/utils/lib/sign-typed-data-utils' +import { Knex } from 'knex' +import { Action, Session } from '../../../common/action' +import { + DomainStateRecord, + toSequentialDelayDomainState, +} from '../../../common/database/models/domain-state' +import { DefaultKeyName, Key, KeyProvider } from '../../../common/key-management/key-provider-base' +import { SignerConfig } from '../../../config' +import { DomainQuotaService } from '../../services/quota' +import { DomainSession } from '../../session' +import { DomainSignIO } from './io' + +type TrxResult = + | { + success: false + status: number + domainStateRecord: DomainStateRecord + error: ErrorType + } + | { + success: true + status: number + domainStateRecord: DomainStateRecord + key: Key + signature: string + } + +export class DomainSignAction implements Action { + constructor( + readonly db: Knex, + readonly config: SignerConfig, + readonly quota: DomainQuotaService, + readonly keyProvider: KeyProvider, + readonly io: DomainSignIO + ) {} + + public async perform( + session: DomainSession, + timeoutError: symbol + ): Promise { + const domain = session.request.body.domain + session.logger.info( + { + name: domain.name, + version: domain.version, + hash: domainHash(domain).toString('hex'), + }, + 'Processing request to get domain signature ' + ) + const res: TrxResult = await this.db.transaction(async (trx) => { + const domainSignHandler = async (): Promise => { + // Get the current domain state record, or use an empty record if one does not exist. + const domainStateRecord = await this.quota.getQuotaStatus(session, trx) + + // Note that this action occurs in the same transaction as the remainder of the siging + // action. As a result, this is included here rather than in the authentication function. + if (!this.nonceCheck(domainStateRecord, session)) { + return { + success: false, + status: 401, + domainStateRecord, + error: WarningMessage.INVALID_NONCE, + } + } + + const quotaStatus = await this.quota.checkAndUpdateQuotaStatus( + domainStateRecord, + session, + trx + ) + + if (!quotaStatus.sufficient) { + session.logger.warn( + { + name: domain.name, + version: domain.version, + hash: domainHash(domain), + }, + `Exceeded quota` + ) + return { + success: false, + status: 429, + domainStateRecord: quotaStatus.state, + error: WarningMessage.EXCEEDED_QUOTA, + } + } + + const key: Key = { + version: + getRequestKeyVersion(session.request, session.logger) ?? + this.config.keystore.keys.domains.latest, + name: DefaultKeyName.DOMAINS, + } + + // Compute evaluation inside transaction so it will rollback on error. + const evaluation = await this.eval( + domain, + session.request.body.blindedMessage, + key, + session + ) + + return { + success: true, + status: 200, + domainStateRecord: quotaStatus.state, + key, + signature: evaluation.toString('base64'), + } + } + // Ensure timeouts roll back DB trx + return timeout(domainSignHandler, [], this.config.timeout, timeoutError) + }) + + if (res.success) { + this.io.sendSuccess( + res.status, + session.response, + res.key, + res.signature, + toSequentialDelayDomainState(res.domainStateRecord) + ) + } else { + this.io.sendFailure( + res.error, + res.status, + session.response, + toSequentialDelayDomainState(res.domainStateRecord) + ) + } + } + + private nonceCheck( + domainStateRecord: DomainStateRecord, + session: DomainSession + ): boolean { + const nonce: EIP712Optional = session.request.body.options.nonce + if (!nonce.defined) { + session.logger.info('Nonce is undefined') + return false + } + return nonce.value >= domainStateRecord.counter + } + + private async eval( + domain: Domain, + blindedMessage: string, + key: Key, + session: Session + ): Promise { + let privateKey: string + try { + privateKey = await this.keyProvider.getPrivateKeyOrFetchFromStore(key) + } catch (err) { + session.logger.error({ key }, 'Requested key version not supported') + session.logger.error(err) + throw new Error(WarningMessage.INVALID_KEY_VERSION_REQUEST) + } + + const server = new ThresholdPoprfServer(Buffer.from(privateKey, 'hex')) + return server.blindPartialEval(domainHash(domain), Buffer.from(blindedMessage, 'base64')) + } +} diff --git a/packages/phone-number-privacy/signer/src/domain/endpoints/sign/io.ts b/packages/phone-number-privacy/signer/src/domain/endpoints/sign/io.ts new file mode 100644 index 00000000000..24e79b6fe58 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/domain/endpoints/sign/io.ts @@ -0,0 +1,96 @@ +import { + DomainRestrictedSignatureRequest, + domainRestrictedSignatureRequestSchema, + DomainRestrictedSignatureResponse, + DomainRestrictedSignatureResponseFailure, + DomainRestrictedSignatureResponseSuccess, + DomainSchema, + DomainState, + ErrorType, + KEY_VERSION_HEADER, + requestHasValidKeyVersion, + send, + SignerEndpoint, + verifyDomainRestrictedSignatureRequestAuthenticity, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { Request, Response } from 'express' +import { IO } from '../../../common/io' +import { Key } from '../../../common/key-management/key-provider-base' +import { Counters } from '../../../common/metrics' +import { getVersion } from '../../../config' +import { DomainSession } from '../../session' + +export class DomainSignIO extends IO { + readonly endpoint = SignerEndpoint.DOMAIN_SIGN + + async init( + request: Request<{}, {}, unknown>, + response: Response + ): Promise | null> { + if (!super.inputChecks(request, response)) { + return null + } + if (!requestHasValidKeyVersion(request, response.locals.logger)) { + this.sendFailure(WarningMessage.INVALID_KEY_VERSION_REQUEST, 400, response) + return null + } + if (!(await this.authenticate(request))) { + this.sendFailure(WarningMessage.UNAUTHENTICATED_USER, 401, response) + return null + } + return new DomainSession(request, response) + } + + validate( + request: Request<{}, {}, unknown> + ): request is Request<{}, {}, DomainRestrictedSignatureRequest> { + return domainRestrictedSignatureRequestSchema(DomainSchema).is(request.body) + } + + authenticate(request: Request<{}, {}, DomainRestrictedSignatureRequest>): Promise { + return Promise.resolve(verifyDomainRestrictedSignatureRequestAuthenticity(request.body)) + } + + sendSuccess( + status: number, + response: Response, + key: Key, + signature: string, + domainState: DomainState + ) { + response.set(KEY_VERSION_HEADER, key.version.toString()) + send( + response, + { + success: true, + version: getVersion(), + signature, + status: domainState, + }, + status, + response.locals.logger + ) + Counters.responses.labels(this.endpoint, status.toString()).inc() + } + + sendFailure( + error: ErrorType, + status: number, + response: Response, + domainState?: DomainState + ) { + send( + response, + { + success: false, + version: getVersion(), + error, + status: domainState, + }, + status, + response.locals.logger + ) + Counters.responses.labels(this.endpoint, status.toString()).inc() + } +} diff --git a/packages/phone-number-privacy/signer/src/domain/quota/domainQuota.interface.ts b/packages/phone-number-privacy/signer/src/domain/quota/domainQuota.interface.ts deleted file mode 100644 index 6caef1b0c07..00000000000 --- a/packages/phone-number-privacy/signer/src/domain/quota/domainQuota.interface.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Domain } from '@celo/phone-number-privacy-common/lib/domains' -import Logger from 'bunyan' -import { Transaction } from 'knex' -import { DomainStateRecord } from '../../database/models/domainState' - -export interface IDomainQuotaService { - checkAndUpdateQuota: ( - domain: Domain, - domainState: DomainStateRecord, - trx: Transaction, - logger: Logger - ) => Promise<{ sufficient: boolean; newState: DomainStateRecord }> -} diff --git a/packages/phone-number-privacy/signer/src/domain/quota/domainQuota.service.ts b/packages/phone-number-privacy/signer/src/domain/quota/domainQuota.service.ts deleted file mode 100644 index 42010963a10..00000000000 --- a/packages/phone-number-privacy/signer/src/domain/quota/domainQuota.service.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { ErrorMessage } from '@celo/phone-number-privacy-common' -import { Domain, isSequentialDelayDomain } from '@celo/phone-number-privacy-common/lib/domains' -import { checkSequentialDelayRateLimit } from '@celo/phone-number-privacy-common/lib/domains/sequential-delay' -import Logger from 'bunyan' -import { Transaction } from 'knex' -import { toSequentialDelayDomainState } from '../../common/domain/domainState.mapper' -import { DOMAINS_STATES_COLUMNS, DomainStateRecord } from '../../database/models/domainState' -import { updateDomainState } from '../../database/wrappers/domainState' -import { IDomainQuotaService } from './domainQuota.interface' - -export class DomainQuotaService implements IDomainQuotaService { - public async checkAndUpdateQuota( - domain: Domain, - domainState: DomainStateRecord, - trx: Transaction, - logger: Logger - ): Promise<{ sufficient: boolean; newState: DomainStateRecord }> { - if (isSequentialDelayDomain(domain)) { - return this.handleSequentialDelayDomain(domain, domainState, trx, logger) - } else { - throw new Error(ErrorMessage.UNSUPPORTED_DOMAIN) - } - } - - private async handleSequentialDelayDomain( - domain: Domain, - domainState: DomainStateRecord, - trx: Transaction, - logger: Logger - ) { - const result = checkSequentialDelayRateLimit( - domain, - // Divide by 1000 to convert the current time in ms to seconds. - Date.now() / 1000, - toSequentialDelayDomainState(domainState) - ) - - // If the result indicates insufficient quota, return a failure. - // Note that the database will not be updated. - if (!result.accepted || !result.state) { - return { sufficient: false, newState: domainState } - } - - // Convert the result to a database record. - const newState: DomainStateRecord = { - timer: result.state.timer, - counter: result.state.counter, - domainHash: domainState[DOMAINS_STATES_COLUMNS.domainHash], - disabled: domainState[DOMAINS_STATES_COLUMNS.disabled], - } - - // Persist the updated domain quota to the database. - // This will trigger an insert if this is the first update to the domain. - await updateDomainState(domain, newState, trx, logger) - - return { - sufficient: true, - newState, - } - } -} diff --git a/packages/phone-number-privacy/signer/src/domain/services/quota.ts b/packages/phone-number-privacy/signer/src/domain/services/quota.ts new file mode 100644 index 00000000000..475be753afa --- /dev/null +++ b/packages/phone-number-privacy/signer/src/domain/services/quota.ts @@ -0,0 +1,63 @@ +import { + checkSequentialDelayRateLimit, + DomainQuotaStatusRequest, + DomainRestrictedSignatureRequest, + ErrorMessage, + isSequentialDelayDomain, +} from '@celo/phone-number-privacy-common' +import { Knex } from 'knex' +import { + DomainStateRecord, + toDomainStateRecord, + toSequentialDelayDomainState, +} from '../../common/database/models/domain-state' +import { + getDomainStateRecordOrEmpty, + updateDomainStateRecord, +} from '../../common/database/wrappers/domain-state' +import { OdisQuotaStatusResult, QuotaService } from '../../common/quota' +import { DomainSession } from '../session' + +declare type QuotaDependentDomainRequest = + | DomainQuotaStatusRequest + | DomainRestrictedSignatureRequest + +export class DomainQuotaService implements QuotaService { + constructor(readonly db: Knex) {} + + async checkAndUpdateQuotaStatus( + state: DomainStateRecord, + session: DomainSession, + trx: Knex.Transaction, + attemptTime?: number + ): Promise> { + const { domain } = session.request.body + // Timestamp precision is lowered to seconds to reduce the chance of effective timing attacks. + attemptTime = attemptTime ?? Math.floor(Date.now() / 1000) + if (isSequentialDelayDomain(domain)) { + const result = checkSequentialDelayRateLimit( + domain, + attemptTime, + toSequentialDelayDomainState(state, attemptTime) + ) + if (result.accepted) { + const newState = toDomainStateRecord(domain, result.state) + // Persist the updated domain quota to the database. + // This will trigger an insert if its the first update to the domain instance. + await updateDomainStateRecord(this.db, domain, newState, trx, session.logger) + return { sufficient: true, state: newState } + } + // If the result was rejected, the domainStateRecord does not change + return { sufficient: false, state } + } else { + throw new Error(ErrorMessage.UNSUPPORTED_DOMAIN) + } + } + + async getQuotaStatus( + session: DomainSession, + trx?: Knex.Transaction + ): Promise { + return getDomainStateRecordOrEmpty(this.db, session.request.body.domain, session.logger, trx) + } +} diff --git a/packages/phone-number-privacy/signer/src/domain/session.ts b/packages/phone-number-privacy/signer/src/domain/session.ts new file mode 100644 index 00000000000..3d9080aab1d --- /dev/null +++ b/packages/phone-number-privacy/signer/src/domain/session.ts @@ -0,0 +1,14 @@ +import { DomainRequest, OdisResponse } from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import { Request, Response } from 'express' + +export class DomainSession { + readonly logger: Logger + + public constructor( + readonly request: Request<{}, {}, R>, + readonly response: Response> + ) { + this.logger = response.locals.logger + } +} diff --git a/packages/phone-number-privacy/signer/src/index.ts b/packages/phone-number-privacy/signer/src/index.ts index 7c03b0d4226..29c531d8a09 100644 --- a/packages/phone-number-privacy/signer/src/index.ts +++ b/packages/phone-number-privacy/signer/src/index.ts @@ -1,27 +1,37 @@ -import { rootLogger as logger } from '@celo/phone-number-privacy-common' -import config, { DEV_MODE } from './config' -import { initDatabase } from './database/database' -import { initKeyProvider } from './key-management/key-provider' -import { createServer } from './server' +import { getContractKit, rootLogger } from '@celo/phone-number-privacy-common' +import { initDatabase } from './common/database/database' +import { initKeyProvider } from './common/key-management/key-provider' +import { KeyProvider } from './common/key-management/key-provider-base' +import { config, DEV_MODE } from './config' +import { startSigner } from './server' -async function start() { - logger().info(`Starting. Dev mode: ${DEV_MODE}`) - await initDatabase() - await initKeyProvider() +require('dotenv').config() - const server = createServer() - logger().info('Starting server') - const port = config.server.port +async function start() { + const logger = rootLogger(config.serviceName) + logger.info(`Starting. Dev mode: ${DEV_MODE}`) + const db = await initDatabase(config) + const keyProvider: KeyProvider = await initKeyProvider(config) + const server = startSigner(config, db, keyProvider, getContractKit(config.blockchain)) + logger.info('Starting server') + const port = config.server.port ?? 0 const backupTimeout = config.timeout * 1.2 server .listen(port, () => { - logger().info(`Server is listening on port ${port}`) + logger.info(`Server is listening on port ${port}`) }) .setTimeout(backupTimeout) } -start().catch((err) => { - logger().info('Fatal error occured. Exiting') - logger().error(err) - process.exit(1) -}) +if (!DEV_MODE) { + start().catch((err) => { + const logger = rootLogger(config.serviceName) + logger.error({ err }, 'Fatal error occured. Exiting') + process.exit(1) + }) +} + +export { initDatabase } from './common/database/database' +export { initKeyProvider } from './common/key-management/key-provider' +export { config, SupportedDatabase, SupportedKeystore } from './config' +export * from './server' diff --git a/packages/phone-number-privacy/signer/src/key-management/aws-key-provider.ts b/packages/phone-number-privacy/signer/src/key-management/aws-key-provider.ts deleted file mode 100644 index 07eb9463e69..00000000000 --- a/packages/phone-number-privacy/signer/src/key-management/aws-key-provider.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { ErrorMessage, rootLogger as logger } from '@celo/phone-number-privacy-common' -import { SecretsManager } from 'aws-sdk' -import config from '../config' -import { KeyProviderBase } from './key-provider-base' - -interface SecretStringResult { - [key: string]: string -} - -export class AWSKeyProvider extends KeyProviderBase { - public async fetchPrivateKeyFromStore() { - try { - // Credentials are managed by AWS client as described in https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/setting-credentials-node.html - const { region, secretName, secretKey } = config.keystore.aws - - const client = new SecretsManager({ region }) - client.config.update({ region }) - - const response = await client - .getSecretValue({ - SecretId: secretName, - }) - .promise() - - let privateKey - if (response.SecretString) { - privateKey = this.tryParseSecretString(response.SecretString, secretKey) - } else if (response.SecretBinary) { - // @ts-ignore AWS sdk typings not quite correct - const buff = new Buffer(response.SecretBinary, 'base64') - privateKey = buff.toString('ascii') - } else { - throw new Error('Response has neither string nor binary') - } - - if (!privateKey) { - throw new Error('Secret is empty or undefined') - } - this.setPrivateKey(privateKey) - } catch (err) { - logger().info('Error retrieving key') - logger().error(err) - throw new Error(ErrorMessage.KEY_FETCH_ERROR) - } - } - - private tryParseSecretString(secretString: string, key: string) { - if (!secretString) { - throw new Error('Cannot parse empty string') - } - if (!key) { - throw new Error('Cannot parse secret without key') - } - - try { - const secret = JSON.parse(secretString) as SecretStringResult - return secret[key] - } catch (e) { - throw new Error('Expecting JSON, secret string is not valid JSON') - } - } -} diff --git a/packages/phone-number-privacy/signer/src/key-management/azure-key-provider.ts b/packages/phone-number-privacy/signer/src/key-management/azure-key-provider.ts deleted file mode 100644 index 735641d0bce..00000000000 --- a/packages/phone-number-privacy/signer/src/key-management/azure-key-provider.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ErrorMessage, rootLogger as logger } from '@celo/phone-number-privacy-common' -import { AzureKeyVaultClient } from '@celo/wallet-hsm-azure' -import config from '../config' -import { KeyProviderBase } from './key-provider-base' - -export class AzureKeyProvider extends KeyProviderBase { - public async fetchPrivateKeyFromStore() { - try { - const { vaultName, secretName } = config.keystore.azure - - const keyVaultClient = new AzureKeyVaultClient(vaultName) - const privateKey = await keyVaultClient.getSecret(secretName) - this.setPrivateKey(privateKey) - } catch (err) { - logger().info('Error retrieving key') - logger().error(err) - throw new Error(ErrorMessage.KEY_FETCH_ERROR) - } - } -} diff --git a/packages/phone-number-privacy/signer/src/key-management/google-key-provider.ts b/packages/phone-number-privacy/signer/src/key-management/google-key-provider.ts deleted file mode 100644 index 23e82f7c363..00000000000 --- a/packages/phone-number-privacy/signer/src/key-management/google-key-provider.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ErrorMessage, rootLogger as logger } from '@celo/phone-number-privacy-common' -import { SecretManagerServiceClient } from '@google-cloud/secret-manager' -import config from '../config' -import { KeyProviderBase } from './key-provider-base' - -export class GoogleKeyProvider extends KeyProviderBase { - public async fetchPrivateKeyFromStore() { - try { - const { projectId, secretName, secretVersion } = config.keystore.google - - const client = new SecretManagerServiceClient() - const [version] = await client.accessSecretVersion({ - name: `projects/${projectId}/secrets/${secretName}/versions/${secretVersion}`, - }) - - // Extract the payload as a string. - const privateKey = version?.payload?.data?.toString() - - if (!privateKey) { - throw new Error('Key is empty or undefined') - } - - this.setPrivateKey(privateKey) - } catch (err) { - logger().info('Error retrieving key') - logger().error(err) - throw new Error(ErrorMessage.KEY_FETCH_ERROR) - } - } -} diff --git a/packages/phone-number-privacy/signer/src/key-management/key-provider-base.ts b/packages/phone-number-privacy/signer/src/key-management/key-provider-base.ts deleted file mode 100644 index 03f0246a195..00000000000 --- a/packages/phone-number-privacy/signer/src/key-management/key-provider-base.ts +++ /dev/null @@ -1,28 +0,0 @@ -export interface KeyProvider { - fetchPrivateKeyFromStore: () => Promise - getPrivateKey: () => string -} - -const PRIVATE_KEY_SIZE = 72 - -export abstract class KeyProviderBase implements KeyProvider { - protected privateKey: string | null = null - - public getPrivateKey() { - if (!this.privateKey) { - throw new Error('Private key is empty, provider not properly initialized') - } - - return this.privateKey - } - - public abstract fetchPrivateKeyFromStore(): Promise - - protected setPrivateKey(key: string) { - key = key ? key.trim() : '' - if (key.length !== PRIVATE_KEY_SIZE) { - throw new Error('Invalid private key') - } - this.privateKey = key - } -} diff --git a/packages/phone-number-privacy/signer/src/key-management/key-provider.ts b/packages/phone-number-privacy/signer/src/key-management/key-provider.ts deleted file mode 100644 index c1164ba4ea6..00000000000 --- a/packages/phone-number-privacy/signer/src/key-management/key-provider.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { rootLogger } from '@celo/phone-number-privacy-common' -import config, { SupportedKeystore } from '../config' -import { AWSKeyProvider } from './aws-key-provider' -import { AzureKeyProvider } from './azure-key-provider' -import { GoogleKeyProvider } from './google-key-provider' -import { KeyProvider } from './key-provider-base' -import { MockKeyProvider } from './mock-key-provider' - -const logger = rootLogger() - -let keyProvider: KeyProvider - -export async function initKeyProvider() { - logger.info('Initializing keystore') - const type = config.keystore.type - if (type === SupportedKeystore.AzureKeyVault) { - logger.info('Using Azure key vault') - keyProvider = new AzureKeyProvider() - } else if (type === SupportedKeystore.GoogleSecretManager) { - logger.info('Using Google Secret Manager') - keyProvider = new GoogleKeyProvider() - } else if (type === SupportedKeystore.AWSSecretManager) { - logger.info('Using AWS Secret Manager') - keyProvider = new AWSKeyProvider() - } else if (type === SupportedKeystore.MockSecretManager) { - logger.info('Using Mock Secret Manager') - keyProvider = new MockKeyProvider() - } else { - throw new Error('Valid keystore type must be provided') - } - logger.info('Fetching key') - await keyProvider.fetchPrivateKeyFromStore() - logger.info('Done fetching key. Key provider initialized successfully.') -} - -export function getKeyProvider() { - if (!keyProvider) { - throw new Error('Key provider has not been properly initialized') - } - - return keyProvider -} diff --git a/packages/phone-number-privacy/signer/src/key-management/mock-key-provider.ts b/packages/phone-number-privacy/signer/src/key-management/mock-key-provider.ts deleted file mode 100644 index a5b2d64f0c3..00000000000 --- a/packages/phone-number-privacy/signer/src/key-management/mock-key-provider.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { DEV_PRIVATE_KEY } from '../config' -import { KeyProviderBase } from './key-provider-base' - -export class MockKeyProvider extends KeyProviderBase { - public async fetchPrivateKeyFromStore() { - this.setPrivateKey(DEV_PRIVATE_KEY) - } -} diff --git a/packages/phone-number-privacy/signer/src/migrations/20210421212301_create-indices.ts b/packages/phone-number-privacy/signer/src/migrations/20210421212301_create-indices.ts deleted file mode 100644 index 1f80052c00f..00000000000 --- a/packages/phone-number-privacy/signer/src/migrations/20210421212301_create-indices.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as Knex from 'knex' -import { ACCOUNTS_COLUMNS, ACCOUNTS_TABLE } from '../database/models/account' - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(ACCOUNTS_TABLE))) { - throw new Error('Unexpected error: Could not find ACCOUNTS_TABLE') - } - return knex.schema.alterTable(ACCOUNTS_TABLE, (t) => { - t.index(ACCOUNTS_COLUMNS.address) - }) -} - -export async function down(knex: Knex): Promise { - return knex.schema.alterTable(ACCOUNTS_TABLE, (t) => { - t.dropIndex(ACCOUNTS_COLUMNS.address) - }) -} diff --git a/packages/phone-number-privacy/signer/src/migrations/20210921173354_create-domain-state.ts b/packages/phone-number-privacy/signer/src/migrations/20210921173354_create-domain-state.ts deleted file mode 100644 index 2e8719c7430..00000000000 --- a/packages/phone-number-privacy/signer/src/migrations/20210921173354_create-domain-state.ts +++ /dev/null @@ -1,20 +0,0 @@ -import * as Knex from 'knex' - -import { DOMAINS_STATES_COLUMNS, DOMAINS_STATES_TABLE } from '../database/models/domainState' - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(DOMAINS_STATES_TABLE))) { - return knex.schema.createTable(DOMAINS_STATES_TABLE, (t) => { - t.string(DOMAINS_STATES_COLUMNS.domainHash).notNullable().primary() - t.integer(DOMAINS_STATES_COLUMNS.counter).nullable() - t.boolean(DOMAINS_STATES_COLUMNS.disabled).notNullable().defaultTo(false) - t.integer(DOMAINS_STATES_COLUMNS.timer).nullable() - }) - } - - return null -} - -export async function down(knex: Knex): Promise { - return knex.schema.dropTable(DOMAINS_STATES_TABLE) -} diff --git a/packages/phone-number-privacy/signer/src/pnp/endpoints/quota/action.ts b/packages/phone-number-privacy/signer/src/pnp/endpoints/quota/action.ts new file mode 100644 index 00000000000..cb114f53858 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/pnp/endpoints/quota/action.ts @@ -0,0 +1,43 @@ +import { timeout } from '@celo/base' +import { + ErrorMessage, + LegacyPnpQuotaRequest, + PnpQuotaRequest, +} from '@celo/phone-number-privacy-common' +import { Action } from '../../../common/action' +import { SignerConfig } from '../../../config' +import { PnpQuotaService } from '../../services/quota' +import { PnpSession } from '../../session' +import { PnpQuotaIO } from './io' +import { LegacyPnpQuotaIO } from './io.legacy' + +export class PnpQuotaAction implements Action { + constructor( + readonly config: SignerConfig, + readonly quota: PnpQuotaService, + readonly io: PnpQuotaIO | LegacyPnpQuotaIO + ) {} + + public async perform( + session: PnpSession, + timeoutError: symbol + ): Promise { + const quotaStatus = await timeout( + () => this.quota.getQuotaStatus(session), + [], + this.config.timeout, + timeoutError + ) + if (quotaStatus.performedQueryCount > -1 && quotaStatus.totalQuota > -1) { + this.io.sendSuccess(200, session.response, quotaStatus, session.errors) + return + } + this.io.sendFailure( + quotaStatus.performedQueryCount === -1 + ? ErrorMessage.FAILURE_TO_GET_PERFORMED_QUERY_COUNT + : ErrorMessage.FAILURE_TO_GET_TOTAL_QUOTA, + 500, + session.response + ) + } +} diff --git a/packages/phone-number-privacy/signer/src/pnp/endpoints/quota/io.legacy.ts b/packages/phone-number-privacy/signer/src/pnp/endpoints/quota/io.legacy.ts new file mode 100644 index 00000000000..752c77f4c80 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/pnp/endpoints/quota/io.legacy.ts @@ -0,0 +1,103 @@ +import { ContractKit } from '@celo/contractkit' +import { + authenticateUser, + ErrorType, + hasValidAccountParam, + identifierIsValidIfExists, + isBodyReasonablySized, + LegacyPnpQuotaRequest, + LegacyPnpQuotaRequestSchema, + PnpQuotaResponse, + PnpQuotaResponseFailure, + PnpQuotaResponseSuccess, + PnpQuotaStatus, + send, + SignerEndpoint, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import { Request, Response } from 'express' +import { IO } from '../../../common/io' +import { Counters } from '../../../common/metrics' +import { getVersion } from '../../../config' +import { PnpSession } from '../../session' + +export class LegacyPnpQuotaIO extends IO { + readonly endpoint = SignerEndpoint.LEGACY_PNP_QUOTA + + constructor( + readonly enabled: boolean, + readonly shouldFailOpen: boolean, + readonly kit: ContractKit + ) { + super(enabled) + } + + async init( + request: Request<{}, {}, unknown>, + response: Response + ): Promise | null> { + const warnings: ErrorType[] = [] + if (!super.inputChecks(request, response)) { + return null + } + if (!(await this.authenticate(request, warnings, response.locals.logger))) { + this.sendFailure(WarningMessage.UNAUTHENTICATED_USER, 401, response) + return null + } + const session = new PnpSession(request, response) + session.errors.push(...warnings) + return session + } + + validate(request: Request<{}, {}, unknown>): request is Request<{}, {}, LegacyPnpQuotaRequest> { + return ( + LegacyPnpQuotaRequestSchema.is(request.body) && + hasValidAccountParam(request.body) && + identifierIsValidIfExists(request.body) && + isBodyReasonablySized(request.body) + ) + } + + async authenticate( + request: Request<{}, {}, LegacyPnpQuotaRequest>, + warnings: ErrorType[], + logger: Logger + ): Promise { + return authenticateUser(request, this.kit, logger, this.shouldFailOpen, warnings) + } + + sendSuccess( + status: number, + response: Response, + quotaStatus: PnpQuotaStatus, + warnings: string[] + ) { + send( + response, + { + success: true, + version: getVersion(), + ...quotaStatus, + warnings, + }, + status, + response.locals.logger + ) + Counters.responses.labels(this.endpoint, status.toString()).inc() + } + + sendFailure(error: ErrorType, status: number, response: Response) { + send( + response, + { + success: false, + version: getVersion(), + error, + }, + status, + response.locals.logger + ) + Counters.responses.labels(this.endpoint, status.toString()).inc() + } +} diff --git a/packages/phone-number-privacy/signer/src/pnp/endpoints/quota/io.ts b/packages/phone-number-privacy/signer/src/pnp/endpoints/quota/io.ts new file mode 100644 index 00000000000..37da899992c --- /dev/null +++ b/packages/phone-number-privacy/signer/src/pnp/endpoints/quota/io.ts @@ -0,0 +1,101 @@ +import { ContractKit } from '@celo/contractkit' +import { + authenticateUser, + ErrorType, + hasValidAccountParam, + isBodyReasonablySized, + PnpQuotaRequest, + PnpQuotaRequestSchema, + PnpQuotaResponse, + PnpQuotaResponseFailure, + PnpQuotaResponseSuccess, + PnpQuotaStatus, + send, + SignerEndpoint, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import { Request, Response } from 'express' +import { IO } from '../../../common/io' +import { Counters } from '../../../common/metrics' +import { getVersion } from '../../../config' +import { PnpSession } from '../../session' + +export class PnpQuotaIO extends IO { + readonly endpoint = SignerEndpoint.PNP_QUOTA + + constructor( + readonly enabled: boolean, + readonly shouldFailOpen: boolean, + readonly kit: ContractKit + ) { + super(enabled) + } + + async init( + request: Request<{}, {}, unknown>, + response: Response + ): Promise | null> { + const warnings: ErrorType[] = [] + if (!super.inputChecks(request, response)) { + return null + } + if (!(await this.authenticate(request, warnings, response.locals.logger))) { + this.sendFailure(WarningMessage.UNAUTHENTICATED_USER, 401, response) + return null + } + const session = new PnpSession(request, response) + session.errors.push(...warnings) + return session + } + + validate(request: Request<{}, {}, unknown>): request is Request<{}, {}, PnpQuotaRequest> { + return ( + PnpQuotaRequestSchema.is(request.body) && + hasValidAccountParam(request.body) && + isBodyReasonablySized(request.body) + ) + } + + async authenticate( + request: Request<{}, {}, PnpQuotaRequest>, + warnings: ErrorType[], + logger: Logger + ): Promise { + return authenticateUser(request, this.kit, logger, this.shouldFailOpen, warnings) + } + + sendSuccess( + status: number, + response: Response, + quotaStatus: PnpQuotaStatus, + warnings: string[] + ) { + send( + response, + { + success: true, + version: getVersion(), + ...quotaStatus, + warnings, + }, + status, + response.locals.logger + ) + Counters.responses.labels(this.endpoint, status.toString()).inc() + } + + sendFailure(error: ErrorType, status: number, response: Response) { + send( + response, + { + success: false, + version: getVersion(), + error, + }, + status, + response.locals.logger + ) + Counters.responses.labels(this.endpoint, status.toString()).inc() + } +} diff --git a/packages/phone-number-privacy/signer/src/pnp/endpoints/sign/action.legacy.ts b/packages/phone-number-privacy/signer/src/pnp/endpoints/sign/action.legacy.ts new file mode 100644 index 00000000000..642ad9781cd --- /dev/null +++ b/packages/phone-number-privacy/signer/src/pnp/endpoints/sign/action.legacy.ts @@ -0,0 +1,21 @@ +import { Knex } from 'knex' +import { REQUESTS_TABLE } from '../../../common/database/models/request' +import { KeyProvider } from '../../../common/key-management/key-provider-base' +import { SignerConfig } from '../../../config' +import { PnpQuotaService } from '../../services/quota' +import { PnpSignAction } from './action' +import { LegacyPnpSignIO } from './io.legacy' + +export class LegacyPnpSignAction extends PnpSignAction { + protected readonly requestsTable = REQUESTS_TABLE.LEGACY + + constructor( + readonly db: Knex, + readonly config: SignerConfig, + readonly quota: PnpQuotaService, + readonly keyProvider: KeyProvider, + readonly io: LegacyPnpSignIO + ) { + super(db, config, quota, keyProvider, io) + } +} diff --git a/packages/phone-number-privacy/signer/src/pnp/endpoints/sign/action.onchain.ts b/packages/phone-number-privacy/signer/src/pnp/endpoints/sign/action.onchain.ts new file mode 100644 index 00000000000..5cd93b41962 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/pnp/endpoints/sign/action.onchain.ts @@ -0,0 +1,21 @@ +import { Knex } from 'knex' +import { REQUESTS_TABLE } from '../../../common/database/models/request' +import { KeyProvider } from '../../../common/key-management/key-provider-base' +import { SignerConfig } from '../../../config' +import { PnpQuotaService } from '../../services/quota' +import { PnpSignAction } from './action' +import { PnpSignIO } from './io' + +export class OnChainPnpSignAction extends PnpSignAction { + protected readonly requestsTable = REQUESTS_TABLE.ONCHAIN + + constructor( + readonly db: Knex, + readonly config: SignerConfig, + readonly quota: PnpQuotaService, + readonly keyProvider: KeyProvider, + readonly io: PnpSignIO + ) { + super(db, config, quota, keyProvider, io) + } +} diff --git a/packages/phone-number-privacy/signer/src/pnp/endpoints/sign/action.ts b/packages/phone-number-privacy/signer/src/pnp/endpoints/sign/action.ts new file mode 100644 index 00000000000..52d54dd8663 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/pnp/endpoints/sign/action.ts @@ -0,0 +1,159 @@ +import { timeout } from '@celo/base' +import { + ErrorMessage, + getRequestKeyVersion, + LegacySignMessageRequest, + SignMessageRequest, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { Knex } from 'knex' +import { Action, Session } from '../../../common/action' +import { computeBlindedSignature } from '../../../common/bls/bls-cryptography-client' +import { REQUESTS_TABLE } from '../../../common/database/models/request' +import { getRequestExists } from '../../../common/database/wrappers/request' +import { DefaultKeyName, Key, KeyProvider } from '../../../common/key-management/key-provider-base' +import { Counters } from '../../../common/metrics' +import { SignerConfig } from '../../../config' +import { PnpQuotaService } from '../../services/quota' +import { PnpSession } from '../../session' +import { PnpSignIO } from './io' +import { LegacyPnpSignIO } from './io.legacy' + +export abstract class PnpSignAction + implements Action { + protected abstract readonly requestsTable: REQUESTS_TABLE + + constructor( + readonly db: Knex, + readonly config: SignerConfig, + readonly quota: PnpQuotaService, + readonly keyProvider: KeyProvider, + readonly io: PnpSignIO | LegacyPnpSignIO + ) {} + + public async perform( + session: PnpSession, + timeoutError: symbol + ): Promise { + // Compute quota lookup, update, and signing within transaction + // so that these occur atomically and rollback on error. + await this.db.transaction(async (trx) => { + const pnpSignHandler = async () => { + const quotaStatus = await this.quota.getQuotaStatus(session, trx) + + let isDuplicateRequest = false + try { + isDuplicateRequest = await getRequestExists( + this.db, + this.requestsTable, + session.request.body.account, + session.request.body.blindedQueryPhoneNumber, + session.logger, + trx + ) + } catch (err) { + session.logger.error(err, 'Failed to check if request already exists in db') + } + + if (isDuplicateRequest) { + Counters.duplicateRequests.inc() + session.logger.info( + 'Request already exists in db. Will service request without charging quota.' + ) + session.errors.push(WarningMessage.DUPLICATE_REQUEST_TO_GET_PARTIAL_SIG) + } else { + // In the case of a database connection failure, performedQueryCount will be -1 + if (quotaStatus.performedQueryCount === -1) { + this.io.sendFailure( + ErrorMessage.DATABASE_GET_FAILURE, + 500, + session.response, + quotaStatus + ) + return + } + // In the case of a blockchain connection failure, totalQuota will be -1 + if (quotaStatus.totalQuota === -1) { + if (this.io.shouldFailOpen) { + // We fail open and service requests on full-node errors to not block the user. + // Error messages are stored in the session and included along with the signature in the response. + quotaStatus.totalQuota = Number.MAX_SAFE_INTEGER + session.logger.warn( + { warning: ErrorMessage.FAILURE_TO_GET_TOTAL_QUOTA }, + ErrorMessage.FAILING_OPEN + ) + Counters.requestsFailingOpen.inc() + } else { + session.logger.warn( + { warning: ErrorMessage.FAILURE_TO_GET_TOTAL_QUOTA }, + ErrorMessage.FAILING_CLOSED + ) + Counters.requestsFailingClosed.inc() + this.io.sendFailure(ErrorMessage.FULL_NODE_ERROR, 500, session.response, quotaStatus) + return + } + } + + // TODO(after 2.0.0) add more specific error messages on DB and key version + // https://github.com/celo-org/celo-monorepo/issues/9882 + // quotaStatus is updated in place; throws on failure to update + const { sufficient } = await this.quota.checkAndUpdateQuotaStatus( + quotaStatus, + session, + trx + ) + if (!sufficient) { + this.io.sendFailure(WarningMessage.EXCEEDED_QUOTA, 403, session.response, quotaStatus) + return + } + } + + const key: Key = { + version: + getRequestKeyVersion(session.request, session.logger) ?? + this.config.keystore.keys.phoneNumberPrivacy.latest, + name: DefaultKeyName.PHONE_NUMBER_PRIVACY, + } + + try { + const signature = await this.sign( + session.request.body.blindedQueryPhoneNumber, + key, + session + ) + this.io.sendSuccess(200, session.response, key, signature, quotaStatus, session.errors) + return + } catch (err) { + session.logger.error({ err }) + quotaStatus.performedQueryCount-- + this.io.sendFailure( + ErrorMessage.SIGNATURE_COMPUTATION_FAILURE, + 500, + session.response, + quotaStatus + ) + // Note that errors thrown after rollback will have no effect, hence doing this last + await trx.rollback() + return + } + } + await timeout(pnpSignHandler, [], this.config.timeout, timeoutError) + }) + } + + private async sign( + blindedMessage: string, + key: Key, + session: Session + ): Promise { + let privateKey: string + try { + privateKey = await this.keyProvider.getPrivateKeyOrFetchFromStore(key) + } catch (err) { + session.logger.info({ key }, 'Requested key version not supported') + session.logger.error(err) + throw new Error(WarningMessage.INVALID_KEY_VERSION_REQUEST) + } + return computeBlindedSignature(blindedMessage, privateKey, session.logger) + } +} diff --git a/packages/phone-number-privacy/signer/src/pnp/endpoints/sign/io.legacy.ts b/packages/phone-number-privacy/signer/src/pnp/endpoints/sign/io.legacy.ts new file mode 100644 index 00000000000..fd5b7ae9c1e --- /dev/null +++ b/packages/phone-number-privacy/signer/src/pnp/endpoints/sign/io.legacy.ts @@ -0,0 +1,125 @@ +import { ContractKit } from '@celo/contractkit' +import { + authenticateUser, + ErrorType, + hasValidAccountParam, + hasValidBlindedPhoneNumberParam, + identifierIsValidIfExists, + isBodyReasonablySized, + KEY_VERSION_HEADER, + LegacySignMessageRequest, + LegacySignMessageRequestSchema, + PnpQuotaStatus, + requestHasValidKeyVersion, + send, + SignerEndpoint, + SignMessageResponse, + SignMessageResponseFailure, + SignMessageResponseSuccess, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import { Request, Response } from 'express' +import { IO } from '../../../common/io' +import { Key } from '../../../common/key-management/key-provider-base' +import { Counters } from '../../../common/metrics' +import { getVersion } from '../../../config' +import { PnpSession } from '../../session' + +export class LegacyPnpSignIO extends IO { + readonly endpoint = SignerEndpoint.LEGACY_PNP_SIGN + + constructor( + readonly enabled: boolean, + readonly shouldFailOpen: boolean, + readonly kit: ContractKit + ) { + super(enabled) + } + + async init( + request: Request<{}, {}, unknown>, + response: Response + ): Promise | null> { + const logger = response.locals.logger + const warnings: ErrorType[] = [] + if (!super.inputChecks(request, response)) { + return null + } + if (!requestHasValidKeyVersion(request, logger)) { + this.sendFailure(WarningMessage.INVALID_KEY_VERSION_REQUEST, 400, response) + return null + } + if (!(await this.authenticate(request, warnings, logger))) { + this.sendFailure(WarningMessage.UNAUTHENTICATED_USER, 401, response) + return null + } + const session = new PnpSession(request, response) + session.errors.push(...warnings) + return session + } + + validate( + request: Request<{}, {}, unknown> + ): request is Request<{}, {}, LegacySignMessageRequest> { + return ( + LegacySignMessageRequestSchema.is(request.body) && + hasValidAccountParam(request.body) && + hasValidBlindedPhoneNumberParam(request.body) && + identifierIsValidIfExists(request.body) && + isBodyReasonablySized(request.body) + ) + } + + async authenticate( + request: Request<{}, {}, LegacySignMessageRequest>, + warnings: ErrorType[], + logger: Logger + ): Promise { + return authenticateUser(request, this.kit, logger, this.shouldFailOpen, warnings) + } + + sendSuccess( + status: number, + response: Response, + key: Key, + signature: string, + quotaStatus: PnpQuotaStatus, + warnings: string[] + ) { + response.set(KEY_VERSION_HEADER, key.version.toString()) + send( + response, + { + success: true, + version: getVersion(), + signature, + ...quotaStatus, + warnings, + }, + status, + response.locals.logger + ) + Counters.responses.labels(this.endpoint, status.toString()).inc() + } + + sendFailure( + error: string, + status: number, + response: Response, + quotaStatus?: PnpQuotaStatus + ) { + send( + response, + { + success: false, + version: getVersion(), + error, + ...quotaStatus, + }, + status, + response.locals.logger + ) + Counters.responses.labels(this.endpoint, status.toString()).inc() + } +} diff --git a/packages/phone-number-privacy/signer/src/pnp/endpoints/sign/io.ts b/packages/phone-number-privacy/signer/src/pnp/endpoints/sign/io.ts new file mode 100644 index 00000000000..87b82ee8610 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/pnp/endpoints/sign/io.ts @@ -0,0 +1,121 @@ +import { ContractKit } from '@celo/contractkit' +import { + authenticateUser, + ErrorType, + hasValidAccountParam, + hasValidBlindedPhoneNumberParam, + isBodyReasonablySized, + KEY_VERSION_HEADER, + PnpQuotaStatus, + requestHasValidKeyVersion, + send, + SignerEndpoint, + SignMessageRequest, + SignMessageRequestSchema, + SignMessageResponse, + SignMessageResponseFailure, + SignMessageResponseSuccess, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import { Request, Response } from 'express' +import { IO } from '../../../common/io' +import { Key } from '../../../common/key-management/key-provider-base' +import { Counters } from '../../../common/metrics' +import { getVersion } from '../../../config' +import { PnpSession } from '../../session' + +export class PnpSignIO extends IO { + readonly endpoint = SignerEndpoint.PNP_SIGN + + constructor( + readonly enabled: boolean, + readonly shouldFailOpen: boolean, + readonly kit: ContractKit + ) { + super(enabled) + } + + async init( + request: Request<{}, {}, unknown>, + response: Response + ): Promise | null> { + const logger = response.locals.logger + const warnings: ErrorType[] = [] + if (!super.inputChecks(request, response)) { + return null + } + if (!requestHasValidKeyVersion(request, logger)) { + this.sendFailure(WarningMessage.INVALID_KEY_VERSION_REQUEST, 400, response) + return null + } + if (!(await this.authenticate(request, warnings, logger))) { + this.sendFailure(WarningMessage.UNAUTHENTICATED_USER, 401, response) + return null + } + const session = new PnpSession(request, response) + session.errors.push(...warnings) + return session + } + + validate(request: Request<{}, {}, unknown>): request is Request<{}, {}, SignMessageRequest> { + return ( + SignMessageRequestSchema.is(request.body) && + hasValidAccountParam(request.body) && + hasValidBlindedPhoneNumberParam(request.body) && + isBodyReasonablySized(request.body) + ) + } + + async authenticate( + request: Request<{}, {}, SignMessageRequest>, + warnings: ErrorType[], + logger: Logger + ): Promise { + return authenticateUser(request, this.kit, logger, this.shouldFailOpen, warnings) + } + + sendSuccess( + status: number, + response: Response, + key: Key, + signature: string, + quotaStatus: PnpQuotaStatus, + warnings: string[] + ) { + response.set(KEY_VERSION_HEADER, key.version.toString()) + send( + response, + { + success: true, + version: getVersion(), + signature, + ...quotaStatus, + warnings, + }, + status, + response.locals.logger + ) + Counters.responses.labels(this.endpoint, status.toString()).inc() + } + + sendFailure( + error: string, + status: number, + response: Response, + quotaStatus?: PnpQuotaStatus + ) { + send( + response, + { + success: false, + version: getVersion(), + error, + ...quotaStatus, + }, + status, + response.locals.logger + ) + Counters.responses.labels(this.endpoint, status.toString()).inc() + } +} diff --git a/packages/phone-number-privacy/signer/src/pnp/services/quota.legacy.ts b/packages/phone-number-privacy/signer/src/pnp/services/quota.legacy.ts new file mode 100644 index 00000000000..d06e3463c21 --- /dev/null +++ b/packages/phone-number-privacy/signer/src/pnp/services/quota.legacy.ts @@ -0,0 +1,282 @@ +import { NULL_ADDRESS } from '@celo/base' +import { StableToken } from '@celo/contractkit' +import { + ErrorMessage, + isVerified, + LegacyPnpQuotaRequest, + LegacySignMessageRequest, +} from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import { ACCOUNTS_TABLE } from '../../common/database/models/account' +import { REQUESTS_TABLE } from '../../common/database/models/request' +import { Counters, Histograms, meter } from '../../common/metrics' +import { QuotaService } from '../../common/quota' +import { + getCeloBalance, + getStableTokenBalance, + getTransactionCount, + getWalletAddress, +} from '../../common/web3/contracts' +import { config } from '../../config' +import { PnpSession } from '../session' +import { PnpQuotaService } from './quota' + +export class LegacyPnpQuotaService + extends PnpQuotaService + implements QuotaService { + protected readonly requestsTable = REQUESTS_TABLE.LEGACY + protected readonly accountsTable = ACCOUNTS_TABLE.LEGACY + + protected async getWalletAddressAndIsVerified( + session: PnpSession + ): Promise<{ walletAddress: string; isAccountVerified: boolean }> { + const { account, hashedPhoneNumber } = session.request.body + const [walletAddressResult, isVerifiedResult] = await meter( + (_session: PnpSession) => + Promise.allSettled([ + getWalletAddress(this.kit, session.logger, account, session.request.url), + hashedPhoneNumber + ? isVerified(account, hashedPhoneNumber, this.kit, session.logger) + : Promise.resolve(false), + ]), + [session], + (err: any) => { + throw err + }, + Histograms.getRemainingQueryCountInstrumentation, + ['getWalletAddressAndIsVerified', session.request.url] + ) + let hadFullNodeError = false, + isAccountVerified = false, + walletAddress = NULL_ADDRESS + if (walletAddressResult.status === 'fulfilled') { + walletAddress = walletAddressResult.value + } else { + session.logger.error(walletAddressResult.reason) + hadFullNodeError = true + } + if (isVerifiedResult.status === 'fulfilled') { + isAccountVerified = isVerifiedResult.value + } else { + session.logger.error(isVerifiedResult.reason) + hadFullNodeError = true + } + if (hadFullNodeError) { + session.errors.push(ErrorMessage.FULL_NODE_ERROR) + } + + if (account.toLowerCase() === walletAddress.toLowerCase()) { + session.logger.debug('walletAddress is the same as accountAddress') + walletAddress = NULL_ADDRESS // So we don't double count quota + } + + return { isAccountVerified, walletAddress } + } + + protected async getBalances( + session: PnpSession, + ...addresses: string[] + ) { + const [ + cUSDAccountBalanceResult, + cEURAccountBalanceResult, + celoAccountBalanceResult, + ] = await meter( + (logger: Logger, ..._addresses: string[]) => + Promise.allSettled([ + getStableTokenBalance( + this.kit, + StableToken.cUSD, + logger, + session.request.url, + ..._addresses + ), + getStableTokenBalance( + this.kit, + StableToken.cEUR, + logger, + session.request.url, + ..._addresses + ), + getCeloBalance(this.kit, logger, session.request.url, ..._addresses), + ]), + [session.logger, ...addresses], + (err: any) => { + throw err + }, + Histograms.getRemainingQueryCountInstrumentation, + ['getBalances', session.request.url] + ) + + let hadFullNodeError = false + let cUSDAccountBalance, cEURAccountBalance, celoAccountBalance + if (cUSDAccountBalanceResult.status === 'fulfilled') { + cUSDAccountBalance = cUSDAccountBalanceResult.value + } else { + session.logger.error(cUSDAccountBalanceResult.reason) + hadFullNodeError = true + } + if (cEURAccountBalanceResult.status === 'fulfilled') { + cEURAccountBalance = cEURAccountBalanceResult.value + } else { + session.logger.error(cEURAccountBalanceResult.reason) + hadFullNodeError = true + } + if (celoAccountBalanceResult.status === 'fulfilled') { + celoAccountBalance = celoAccountBalanceResult.value + } else { + session.logger.error(celoAccountBalanceResult.reason) + hadFullNodeError = true + } + if (hadFullNodeError) { + session.errors.push(ErrorMessage.FULL_NODE_ERROR) + } + + return { cUSDAccountBalance, cEURAccountBalance, celoAccountBalance } + } + + /* + * Calculates how many queries the caller has unlocked based on the algorithm + * unverifiedQueryCount + verifiedQueryCount + (queryPerTransaction * transactionCount) + * If the caller is not verified, they must have a minimum balance to get the unverifiedQueryMax. + */ + protected async getTotalQuotaWithoutMeter( + session: PnpSession + ): Promise { + const { + unverifiedQueryMax, + additionalVerifiedQueryMax, + queryPerTransaction, + minDollarBalance, + minEuroBalance, + minCeloBalance, + } = config.quota + + const { account } = session.request.body + + const { walletAddress, isAccountVerified } = await this.getWalletAddressAndIsVerified(session) + + if (walletAddress !== NULL_ADDRESS) { + Counters.requestsWithWalletAddress.inc() + } + + const transactionCount = await getTransactionCount( + this.kit, + session.logger, + session.request.url, + account, + walletAddress + ) + session.logger.debug({ account, transactionCount }) + + if (isAccountVerified) { + Counters.requestsWithVerifiedAccount.inc() + session.logger.debug({ account }, 'Account is verified') + return this.calculateQuotaForVerifiedAccount( + account, + unverifiedQueryMax, + additionalVerifiedQueryMax, + queryPerTransaction, + transactionCount, + session.logger + ) + } + + session.logger.debug({ account }, 'Account is not verified. Checking if min balance is met.') + + const { cUSDAccountBalance, cEURAccountBalance, celoAccountBalance } = await this.getBalances( + session, + account, + walletAddress + ) + + // Min balance can be in either cUSD, cEUR or CELO + if ( + cUSDAccountBalance?.isGreaterThanOrEqualTo(minDollarBalance) || + cEURAccountBalance?.isGreaterThanOrEqualTo(minEuroBalance) || + celoAccountBalance?.isGreaterThanOrEqualTo(minCeloBalance) + ) { + Counters.requestsWithUnverifiedAccountWithMinBalance.inc() + session.logger.debug( + { + account, + cUSDAccountBalance, + cEURAccountBalance, + celoAccountBalance, + minDollarBalance, + minEuroBalance, + minCeloBalance, + }, + 'Account is not verified but meets min balance' + ) + + return this.calculateQuotaForUnverifiedAccountWithMinBalance( + account, + unverifiedQueryMax, + queryPerTransaction, + transactionCount, + session.logger + ) + } + + session.logger.debug({ account }, 'Account is not verified and does not meet min balance') + + const quota = 0 + + session.logger.trace({ + account, + cUSDAccountBalance, + cEURAccountBalance, + celoAccountBalance, + minDollarBalance, + minEuroBalance, + minCeloBalance, + quota, + }) + + return quota + } + + private calculateQuotaForVerifiedAccount( + account: string, + unverifiedQueryMax: number, + additionalVerifiedQueryMax: number, + queryPerTransaction: number, + transactionCount: number, + logger: Logger + ): number { + const quota = + unverifiedQueryMax + additionalVerifiedQueryMax + queryPerTransaction * transactionCount + + logger.trace({ + account, + unverifiedQueryMax, + additionalVerifiedQueryMax, + queryPerTransaction, + transactionCount, + quota, + }) + + return quota + } + + private calculateQuotaForUnverifiedAccountWithMinBalance( + account: string, + unverifiedQueryMax: number, + queryPerTransaction: number, + transactionCount: number, + logger: Logger + ): number { + const quota = unverifiedQueryMax + queryPerTransaction * transactionCount + + logger.trace({ + account, + unverifiedQueryMax, + queryPerTransaction, + transactionCount, + quota, + }) + + return quota + } +} diff --git a/packages/phone-number-privacy/signer/src/pnp/services/quota.onchain.ts b/packages/phone-number-privacy/signer/src/pnp/services/quota.onchain.ts new file mode 100644 index 00000000000..37352ea354b --- /dev/null +++ b/packages/phone-number-privacy/signer/src/pnp/services/quota.onchain.ts @@ -0,0 +1,38 @@ +import { PnpQuotaRequest, SignMessageRequest } from '@celo/phone-number-privacy-common' +import BigNumber from 'bignumber.js' +import { ACCOUNTS_TABLE } from '../../common/database/models/account' +import { REQUESTS_TABLE } from '../../common/database/models/request' +import { QuotaService } from '../../common/quota' +import { getOnChainOdisPayments } from '../../common/web3/contracts' +import { config } from '../../config' +import { PnpSession } from '../session' +import { PnpQuotaService } from './quota' + +export class OnChainPnpQuotaService + extends PnpQuotaService + implements QuotaService { + protected readonly requestsTable = REQUESTS_TABLE.ONCHAIN + protected readonly accountsTable = ACCOUNTS_TABLE.ONCHAIN + /* + * Calculates how many queries the caller has unlocked based on the total + * amount of funds paid to the OdisPayments.sol contract on-chain. + */ + protected async getTotalQuotaWithoutMeter( + session: PnpSession + ): Promise { + const { queryPriceInCUSD } = config.quota + const { account } = session.request.body + const totalPaidInWei = await getOnChainOdisPayments( + this.kit, + session.logger, + account, + session.request.url + ) + const totalQuota = totalPaidInWei + .div(queryPriceInCUSD.times(new BigNumber(1e18))) + .integerValue(BigNumber.ROUND_DOWN) + // If any account hits an overflow here, we need to redesign how + // quota/queries are computed anyways. + return totalQuota.toNumber() + } +} diff --git a/packages/phone-number-privacy/signer/src/pnp/services/quota.ts b/packages/phone-number-privacy/signer/src/pnp/services/quota.ts new file mode 100644 index 00000000000..85d595b494b --- /dev/null +++ b/packages/phone-number-privacy/signer/src/pnp/services/quota.ts @@ -0,0 +1,156 @@ +import { ContractKit } from '@celo/contractkit' +import { + ErrorMessage, + PnpQuotaRequest, + PnpQuotaStatus, + SignMessageRequest, +} from '@celo/phone-number-privacy-common' +import { Knex } from 'knex' +import { ACCOUNTS_TABLE } from '../../common/database/models/account' +import { REQUESTS_TABLE } from '../../common/database/models/request' +import { getPerformedQueryCount, incrementQueryCount } from '../../common/database/wrappers/account' +import { storeRequest } from '../../common/database/wrappers/request' +import { Counters, Histograms, meter } from '../../common/metrics' +import { OdisQuotaStatusResult, QuotaService } from '../../common/quota' +import { getBlockNumber } from '../../common/web3/contracts' +import { config } from '../../config' +import { PnpSession } from '../session' + +export abstract class PnpQuotaService + implements QuotaService { + protected abstract readonly requestsTable: REQUESTS_TABLE + protected abstract readonly accountsTable: ACCOUNTS_TABLE + + constructor(readonly db: Knex, readonly kit: ContractKit) {} + + public async checkAndUpdateQuotaStatus( + state: PnpQuotaStatus, + session: PnpSession, + trx: Knex.Transaction + ): Promise> { + const remainingQuota = state.totalQuota - state.performedQueryCount + Histograms.userRemainingQuotaAtRequest.labels(session.request.url).observe(remainingQuota) + let sufficient = remainingQuota > 0 + if (!sufficient) { + session.logger.warn({ ...state }, 'No remaining quota') + if (this.bypassQuotaForE2ETesting(session.request.body)) { + Counters.testQuotaBypassedRequests.inc() + session.logger.info(session.request.body, 'Request will bypass quota check for e2e testing') + sufficient = true + } + } else { + await Promise.all([ + storeRequest( + this.db, + this.requestsTable, + session.request.body.account, + session.request.body.blindedQueryPhoneNumber, + session.logger, + trx + ), + incrementQueryCount( + this.db, + this.accountsTable, + session.request.body.account, + session.logger, + trx + ), + ]) + state.performedQueryCount++ + } + return { sufficient, state } + } + + public async getQuotaStatus( + session: PnpSession, + trx?: Knex.Transaction + ): Promise { + const { account } = session.request.body + const [performedQueryCountResult, totalQuotaResult, blockNumberResult] = await meter( + (_session: PnpSession) => + Promise.allSettled([ + getPerformedQueryCount(this.db, this.accountsTable, account, session.logger, trx), + this.getTotalQuota(_session), + getBlockNumber(this.kit), + ]), + [session], + (err: any) => { + throw err + }, + Histograms.getRemainingQueryCountInstrumentation, + ['getQuotaStatus', session.request.url] + ) + + const quotaStatus: PnpQuotaStatus = { + // TODO(future) consider making totalQuota,performedQueryCount undefined + totalQuota: -1, + performedQueryCount: -1, + blockNumber: undefined, + } + if (performedQueryCountResult.status === 'fulfilled') { + quotaStatus.performedQueryCount = performedQueryCountResult.value + } else { + session.logger.error( + { err: performedQueryCountResult.reason }, + ErrorMessage.FAILURE_TO_GET_PERFORMED_QUERY_COUNT + ) + session.errors.push( + ErrorMessage.DATABASE_GET_FAILURE, + ErrorMessage.FAILURE_TO_GET_PERFORMED_QUERY_COUNT + ) + } + let hadFullNodeError = false + if (totalQuotaResult.status === 'fulfilled') { + quotaStatus.totalQuota = totalQuotaResult.value + } else { + session.logger.error( + { err: totalQuotaResult.reason }, + ErrorMessage.FAILURE_TO_GET_TOTAL_QUOTA + ) + hadFullNodeError = true + session.errors.push(ErrorMessage.FAILURE_TO_GET_TOTAL_QUOTA) + } + if (blockNumberResult.status === 'fulfilled') { + quotaStatus.blockNumber = blockNumberResult.value + } else { + session.logger.error( + { err: blockNumberResult.reason }, + ErrorMessage.FAILURE_TO_GET_BLOCK_NUMBER + ) + hadFullNodeError = true + session.errors.push(ErrorMessage.FAILURE_TO_GET_BLOCK_NUMBER) + } + if (hadFullNodeError) { + session.errors.push(ErrorMessage.FULL_NODE_ERROR) + } + + return quotaStatus + } + + protected async getTotalQuota( + session: PnpSession + ): Promise { + return meter( + this.getTotalQuotaWithoutMeter.bind(this), + [session], + (err: any) => { + throw err + }, + Histograms.getRemainingQueryCountInstrumentation, + ['getTotalQuota', session.request.url] + ) + } + + /* + * Calculates how many queries the caller has unlocked; + * must be implemented by subclasses. + */ + protected abstract getTotalQuotaWithoutMeter( + session: PnpSession + ): Promise + + private bypassQuotaForE2ETesting(requestBody: SignMessageRequest): boolean { + const sessionID = Number(requestBody.sessionID) + return !Number.isNaN(sessionID) && sessionID % 100 < config.test_quota_bypass_percentage + } +} diff --git a/packages/phone-number-privacy/signer/src/pnp/session.ts b/packages/phone-number-privacy/signer/src/pnp/session.ts new file mode 100644 index 00000000000..a6d80c764ae --- /dev/null +++ b/packages/phone-number-privacy/signer/src/pnp/session.ts @@ -0,0 +1,19 @@ +import { + ErrorType, + PhoneNumberPrivacyRequest, + PhoneNumberPrivacyResponse, +} from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import { Request, Response } from 'express' + +export class PnpSession { + readonly logger: Logger + readonly errors: ErrorType[] = [] + + public constructor( + readonly request: Request<{}, {}, R>, + readonly response: Response> + ) { + this.logger = response.locals.logger + } +} diff --git a/packages/phone-number-privacy/signer/src/server.ts b/packages/phone-number-privacy/signer/src/server.ts index 6d82be563fb..1a8398fab04 100644 --- a/packages/phone-number-privacy/signer/src/server.ts +++ b/packages/phone-number-privacy/signer/src/server.ts @@ -1,51 +1,170 @@ -import { timeout } from '@celo/base' -import { Endpoint, loggerMiddleware, rootLogger as logger } from '@celo/phone-number-privacy-common' +import { ContractKit } from '@celo/contractkit' +import { + ErrorMessage, + getContractKit, + loggerMiddleware, + rootLogger, + SignerEndpoint, +} from '@celo/phone-number-privacy-common' import Logger from 'bunyan' import express, { Request, Response } from 'express' import fs from 'fs' import https from 'https' +import { Knex } from 'knex' import * as PromClient from 'prom-client' -import { Counters, Histograms } from './common/metrics' -import config, { getVersion } from './config' -import { handleGetBlindedMessagePartialSig } from './signing/get-partial-signature' -import { handleGetQuota } from './signing/query-quota' +import { Controller } from './common/controller' +import { KeyProvider } from './common/key-management/key-provider-base' +import { Counters } from './common/metrics' +import { getVersion, SignerConfig } from './config' +import { DomainDisableAction } from './domain/endpoints/disable/action' +import { DomainDisableIO } from './domain/endpoints/disable/io' +import { DomainQuotaAction } from './domain/endpoints/quota/action' +import { DomainQuotaIO } from './domain/endpoints/quota/io' +import { DomainSignAction } from './domain/endpoints/sign/action' +import { DomainSignIO } from './domain/endpoints/sign/io' +import { DomainQuotaService } from './domain/services/quota' +import { PnpQuotaAction } from './pnp/endpoints/quota/action' +import { PnpQuotaIO } from './pnp/endpoints/quota/io' +import { LegacyPnpQuotaIO } from './pnp/endpoints/quota/io.legacy' +import { LegacyPnpSignAction } from './pnp/endpoints/sign/action.legacy' +import { OnChainPnpSignAction } from './pnp/endpoints/sign/action.onchain' +import { PnpSignIO } from './pnp/endpoints/sign/io' +import { LegacyPnpSignIO } from './pnp/endpoints/sign/io.legacy' +import { LegacyPnpQuotaService } from './pnp/services/quota.legacy' +import { OnChainPnpQuotaService } from './pnp/services/quota.onchain' require('events').EventEmitter.defaultMaxListeners = 15 -export function createServer() { - // const domainService = new DomainService(new DomainAuthService(), new DomainQuotaService()) +export function startSigner( + config: SignerConfig, + db: Knex, + keyProvider: KeyProvider, + kit?: ContractKit +) { + const logger = rootLogger(config.serviceName) + + kit = kit ?? getContractKit(config.blockchain) - logger().info('Creating express server') + logger.info('Creating signer express server') const app = express() - app.use(express.json({ limit: '0.2mb' }), loggerMiddleware) + app.use(express.json({ limit: '0.2mb' }), loggerMiddleware(config.serviceName)) - app.get(Endpoint.STATUS, (_req, res) => { + app.get(SignerEndpoint.STATUS, (_req, res) => { res.status(200).json({ version: getVersion(), }) }) - app.get(Endpoint.METRICS, (_req, res) => { + app.get(SignerEndpoint.METRICS, (_req, res) => { res.send(PromClient.register.metrics()) }) - const addMeteredEndpoint = ( - endpoint: Endpoint, - handler: (req: Request, res: Response) => Promise, - method: 'post' | 'get' = 'post' + const addEndpoint = ( + endpoint: SignerEndpoint, + handler: (req: Request, res: Response) => Promise ) => - app[method](endpoint, async (req, res) => { - await callAndMeterLatency(endpoint, handler, req, res) + app.post(endpoint, async (req, res) => { + const childLogger: Logger = res.locals.logger + try { + await handler(req, res) + } catch (err: any) { + // Handle any errors that otherwise managed to escape the proper handlers + childLogger.error(ErrorMessage.CAUGHT_ERROR_IN_ENDPOINT_HANDLER) + childLogger.error(err) + Counters.errorsCaughtInEndpointHandler.inc() + if (!res.headersSent) { + childLogger.info('Responding with error in outer endpoint handler') + res.status(500).json({ + success: false, + error: ErrorMessage.UNKNOWN_ERROR, + }) + } else { + // Getting to this error likely indicates that the `perform` process + // does not terminate after sending a response, and then throws an error. + childLogger.error(ErrorMessage.ERROR_AFTER_RESPONSE_SENT) + Counters.errorsThrownAfterResponseSent.inc() + } + } }) - // EG. curl -v "http://localhost:8080/getBlindedMessagePartialSig" -H "Authorization: 0xdaf63ea42a092e69b2001db3826bc81dc859bffa4d51ce8943fddc8ccfcf6b2b1f55d64e4612e7c028791528796f5a62c1d2865b184b664589696a08c83fc62a00" -d '{"hashedPhoneNumber":"0x5f6e88c3f724b3a09d3194c0514426494955eff7127c29654e48a361a19b4b96","blindedQueryPhoneNumber":"n/I9srniwEHm5o6t3y0tTUB5fn7xjxRrLP1F/i8ORCdqV++WWiaAzUo3GA2UNHiB","account":"0x588e4b68193001e4d10928660aB4165b813717C0"}' -H 'Content-Type: application/json' - addMeteredEndpoint(Endpoint.GET_BLINDED_MESSAGE_PARTIAL_SIG, handleGetBlindedMessagePartialSig) - addMeteredEndpoint(Endpoint.GET_QUOTA, handleGetQuota) - // addMeteredEndpoint(Endpoints.DOMAIN_QUOTA_STATUS, domainService.handleGetDomainQuotaStatus) - // addMeteredEndpoint(Endpoints.DOMAIN_SIGN, domainService.handleGetDomainRestrictedSignature) - // addMeteredEndpoint(Endpoints.DISABLE_DOMAIN, domainService.handleDisableDomain) + const pnpQuotaService = new OnChainPnpQuotaService(db, kit) + const legacyPnpQuotaService = new LegacyPnpQuotaService(db, kit) + const domainQuotaService = new DomainQuotaService(db) + + const pnpQuota = new Controller( + new PnpQuotaAction( + config, + pnpQuotaService, + new PnpQuotaIO( + config.api.phoneNumberPrivacy.enabled, + config.api.phoneNumberPrivacy.shouldFailOpen, // TODO (https://github.com/celo-org/celo-monorepo/issues/9862) consider refactoring config to make the code cleaner + kit + ) + ) + ) + const pnpSign = new Controller( + new OnChainPnpSignAction( + db, + config, + pnpQuotaService, + keyProvider, + new PnpSignIO( + config.api.phoneNumberPrivacy.enabled, + config.api.phoneNumberPrivacy.shouldFailOpen, + kit + ) + ) + ) + const legacyPnpSign = new Controller( + new LegacyPnpSignAction( + db, + config, + legacyPnpQuotaService, + keyProvider, + new LegacyPnpSignIO( + config.api.legacyPhoneNumberPrivacy.enabled, + config.api.legacyPhoneNumberPrivacy.shouldFailOpen, + kit + ) + ) + ) + const legacyPnpQuota = new Controller( + new PnpQuotaAction( + config, + legacyPnpQuotaService, + new LegacyPnpQuotaIO( + config.api.legacyPhoneNumberPrivacy.enabled, + config.api.legacyPhoneNumberPrivacy.shouldFailOpen, + kit + ) + ) + ) + const domainQuota = new Controller( + new DomainQuotaAction(config, domainQuotaService, new DomainQuotaIO(config.api.domains.enabled)) + ) + const domainSign = new Controller( + new DomainSignAction( + db, + config, + domainQuotaService, + keyProvider, + new DomainSignIO(config.api.domains.enabled) + ) + ) + const domainDisable = new Controller( + new DomainDisableAction(db, config, new DomainDisableIO(config.api.domains.enabled)) + ) + logger.info('Right before adding meteredSignerEndpoints') + addEndpoint(SignerEndpoint.PNP_SIGN, pnpSign.handle.bind(pnpSign)) + addEndpoint(SignerEndpoint.PNP_QUOTA, pnpQuota.handle.bind(pnpQuota)) + addEndpoint(SignerEndpoint.DOMAIN_QUOTA_STATUS, domainQuota.handle.bind(domainQuota)) + addEndpoint(SignerEndpoint.DOMAIN_SIGN, domainSign.handle.bind(domainSign)) + addEndpoint(SignerEndpoint.DISABLE_DOMAIN, domainDisable.handle.bind(domainDisable)) + + addEndpoint(SignerEndpoint.LEGACY_PNP_SIGN, legacyPnpSign.handle.bind(legacyPnpSign)) + addEndpoint(SignerEndpoint.LEGACY_PNP_QUOTA, legacyPnpQuota.handle.bind(legacyPnpQuota)) - const sslOptions = getSslOptions() + const sslOptions = getSslOptions(config) if (sslOptions) { return https.createServer(sslOptions, app) } else { @@ -53,35 +172,17 @@ export function createServer() { } } -async function callAndMeterLatency( - endpoint: Endpoint, - handler: (req: Request, res: Response) => Promise, - req: Request, - res: Response -) { - const childLogger: Logger = res.locals.logger - const end = Histograms.responseLatency.labels(endpoint).startTimer() - const timeoutRes = Symbol() - await timeout(handler, [req, res], config.timeout, timeoutRes) - .catch((error: any) => { - if (error === timeoutRes) { - Counters.timeouts.inc() - childLogger.warn(`Timed out after ${config.timeout}ms`) - } - }) - .finally(end) -} - -function getSslOptions() { +function getSslOptions(config: SignerConfig) { + const logger = rootLogger(config.serviceName) const { sslKeyPath, sslCertPath } = config.server if (!sslKeyPath || !sslCertPath) { - logger().info('No SSL configs specified') + logger.info('No SSL configs specified') return null } if (!fs.existsSync(sslKeyPath) || !fs.existsSync(sslCertPath)) { - logger().error('SSL cert files not found') + logger.error('SSL cert files not found') return null } diff --git a/packages/phone-number-privacy/signer/src/signing/get-partial-signature.ts b/packages/phone-number-privacy/signer/src/signing/get-partial-signature.ts deleted file mode 100644 index 92c44d504be..00000000000 --- a/packages/phone-number-privacy/signer/src/signing/get-partial-signature.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { - authenticateUser, - Endpoint, - ErrorMessage, - GetBlindedMessageSigRequest, - hasValidAccountParam, - hasValidBlindedPhoneNumberParam, - identifierIsValidIfExists, - isBodyReasonablySized, - SignMessageResponse, - SignMessageResponseFailure, - WarningMessage, -} from '@celo/phone-number-privacy-common' -import Logger from 'bunyan' -import { Request, Response } from 'express' -import allSettled from 'promise.allsettled' -import { computeBlindedSignature } from '../bls/bls-cryptography-client' -import { respondWithError } from '../common/error-utils' -import { Counters, Histograms } from '../common/metrics' -import config, { getVersion } from '../config' -import { incrementQueryCount } from '../database/wrappers/account' -import { getRequestExists, storeRequest } from '../database/wrappers/request' -import { getKeyProvider } from '../key-management/key-provider' -import { getBlockNumber, getContractKit } from '../web3/contracts' -import { getRemainingQueryCount } from './query-quota' - -allSettled.shim() - -export type GetBlindedMessagePartialSigRequest = GetBlindedMessageSigRequest - -export async function handleGetBlindedMessagePartialSig( - request: Request<{}, {}, GetBlindedMessagePartialSigRequest>, - response: Response -) { - Counters.requests.labels(Endpoint.GET_BLINDED_MESSAGE_PARTIAL_SIG).inc() - - const logger: Logger = response.locals.logger - logger.info({ request: request.body }, 'Request received') - logger.debug('Begin handleGetBlindedMessagePartialSig') - - try { - if (!isValidGetSignatureInput(request.body)) { - respondWithError( - Endpoint.GET_BLINDED_MESSAGE_PARTIAL_SIG, - response, - 400, - WarningMessage.INVALID_INPUT - ) - return - } - - const meterAuthenticateUser = Histograms.getBlindedSigInstrumentation - .labels('authenticateUser') - .startTimer() - if ( - !(await authenticateUser(request, getContractKit(), logger).finally(meterAuthenticateUser)) - ) { - respondWithError( - Endpoint.GET_BLINDED_MESSAGE_PARTIAL_SIG, - response, - 401, - WarningMessage.UNAUTHENTICATED_USER - ) - return - } - - const { account, blindedQueryPhoneNumber, hashedPhoneNumber } = request.body - - const errorMsgs: string[] = [] - // In the case of a blockchain connection failure, don't block user - // but set the error status accordingly - // - const meterGetQueryCountAndBlockNumber = Histograms.getBlindedSigInstrumentation - .labels('getQueryCountAndBlockNumber') - .startTimer() - const [_queryCount, _blockNumber] = await Promise.allSettled([ - // Note: The database read of the user's performedQueryCount - // included here resolves to 0 on error - getRemainingQueryCount(logger, account, hashedPhoneNumber), - getBlockNumber(), - ]).finally(meterGetQueryCountAndBlockNumber) - - let totalQuota = -1 - let performedQueryCount = -1 - let blockNumber = -1 - let hadBlockchainError = false - if (_queryCount.status === 'fulfilled') { - performedQueryCount = _queryCount.value.performedQueryCount - totalQuota = _queryCount.value.totalQuota - } else { - logger.error(_queryCount.reason) - hadBlockchainError = true - } - if (_blockNumber.status === 'fulfilled') { - blockNumber = _blockNumber.value - } else { - logger.error(_blockNumber.reason) - hadBlockchainError = true - } - - if (hadBlockchainError) { - errorMsgs.push(ErrorMessage.CONTRACT_GET_FAILURE) - } - - if (_queryCount.status === 'fulfilled' && performedQueryCount >= totalQuota) { - logger.debug('No remaining query count') - if (bypassQuotaForTesting(request.body)) { - Counters.testQuotaBypassedRequests.inc() - logger.info({ request: request.body }, 'Request will bypass quota check for testing') - } else { - respondWithError( - Endpoint.GET_BLINDED_MESSAGE_PARTIAL_SIG, - response, - 403, - WarningMessage.EXCEEDED_QUOTA, - performedQueryCount, - totalQuota, - blockNumber - ) - return - } - } - - const meterGenerateSignature = Histograms.getBlindedSigInstrumentation - .labels('generateSignature') - .startTimer() - let keyProvider - let privateKey - let signature - try { - keyProvider = getKeyProvider() - privateKey = keyProvider.getPrivateKey() - signature = computeBlindedSignature(blindedQueryPhoneNumber, privateKey, logger) - } catch (err) { - meterGenerateSignature() - throw err - } - meterGenerateSignature() - - if (await getRequestExists(request.body, logger)) { - Counters.duplicateRequests.inc() - logger.debug( - 'Signature request already exists in db. Will not store request or increment query count.' - ) - errorMsgs.push(WarningMessage.DUPLICATE_REQUEST_TO_GET_PARTIAL_SIG) - } else { - const meterDbWriteOps = Histograms.getBlindedSigInstrumentation - .labels('dbWriteOps') - .startTimer() - const [requestStored, queryCountIncremented] = await Promise.all([ - storeRequest(request.body, logger), - incrementQueryCount(account, logger), - ]).finally(meterDbWriteOps) - if (!requestStored) { - logger.debug('Did not store request.') - errorMsgs.push(ErrorMessage.FAILURE_TO_STORE_REQUEST) - } - if (!queryCountIncremented) { - logger.debug('Did not increment query count.') - errorMsgs.push(ErrorMessage.FAILURE_TO_INCREMENT_QUERY_COUNT) - } else { - performedQueryCount++ - } - } - - let signMessageResponse: SignMessageResponse - const signMessageResponseSuccess: SignMessageResponse = { - success: !errorMsgs.length, - signature, - version: getVersion(), - performedQueryCount, - totalQuota, - blockNumber, - } - if (errorMsgs.length) { - const signMessageResponseFailure = signMessageResponseSuccess as SignMessageResponseFailure - signMessageResponseFailure.error = errorMsgs.join(', ') - signMessageResponse = signMessageResponseFailure - } else { - signMessageResponse = signMessageResponseSuccess - } - Counters.responses.labels(Endpoint.GET_BLINDED_MESSAGE_PARTIAL_SIG, '200').inc() - logger.info({ response: signMessageResponse }, 'Signature retrieval success') - response.json(signMessageResponse) - } catch (err) { - logger.error('Failed to get signature') - logger.error(err) - respondWithError( - Endpoint.GET_BLINDED_MESSAGE_PARTIAL_SIG, - response, - 500, - ErrorMessage.UNKNOWN_ERROR - ) - } -} - -function isValidGetSignatureInput(requestBody: GetBlindedMessagePartialSigRequest): boolean { - return ( - hasValidAccountParam(requestBody) && - hasValidBlindedPhoneNumberParam(requestBody) && - identifierIsValidIfExists(requestBody) && - isBodyReasonablySized(requestBody) - ) -} - -function bypassQuotaForTesting(requestBody: GetBlindedMessagePartialSigRequest) { - const sessionID = Number(requestBody.sessionID) - return sessionID && sessionID % 100 < config.test_quota_bypass_percentage -} diff --git a/packages/phone-number-privacy/signer/src/signing/query-quota.ts b/packages/phone-number-privacy/signer/src/signing/query-quota.ts deleted file mode 100644 index b4c18b3108a..00000000000 --- a/packages/phone-number-privacy/signer/src/signing/query-quota.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { retryAsyncWithBackOffAndTimeout } from '@celo/base' -import { NULL_ADDRESS, StableToken } from '@celo/contractkit' -import { - authenticateUser, - Endpoint, - ErrorMessage, - FULL_NODE_TIMEOUT_IN_MS, - GetQuotaRequest, - hasValidAccountParam, - identifierIsValidIfExists, - isBodyReasonablySized, - isVerified, - RETRY_COUNT, - RETRY_DELAY_IN_MS, - WarningMessage, -} from '@celo/phone-number-privacy-common' -import { BigNumber } from 'bignumber.js' -import Logger from 'bunyan' -import { Request, Response } from 'express' -import allSettled from 'promise.allsettled' -import { respondWithError } from '../common/error-utils' -import { Counters, Histograms, Labels } from '../common/metrics' -import config, { getVersion } from '../config' -import { getPerformedQueryCount } from '../database/wrappers/account' -import { getContractKit } from '../web3/contracts' - -allSettled.shim() - -export async function handleGetQuota( - request: Request<{}, {}, GetQuotaRequest>, - response: Response -) { - Counters.requests.labels(Endpoint.GET_QUOTA).inc() - const logger: Logger = response.locals.logger - logger.info({ request: request.body }, 'Request received') - logger.debug('Begin handleGetQuota') - try { - if (!isValidGetQuotaInput(request.body)) { - respondWithError(Endpoint.GET_QUOTA, response, 400, WarningMessage.INVALID_INPUT) - return - } - if (!(await authenticateUser(request, getContractKit() as any, logger))) { - respondWithError(Endpoint.GET_QUOTA, response, 401, WarningMessage.UNAUTHENTICATED_USER) - return - } - - const { account, hashedPhoneNumber } = request.body - - const queryCount = await getRemainingQueryCount(logger, account, hashedPhoneNumber) - - const queryQuotaResponse = { - success: true, - version: getVersion(), - performedQueryCount: queryCount.performedQueryCount, - totalQuota: queryCount.totalQuota, - } - - Counters.responses.labels(Endpoint.GET_QUOTA, '200').inc() - logger.info({ response: queryQuotaResponse }, 'Query quota retrieval success') - response.status(200).json(queryQuotaResponse) - } catch (err) { - logger.error('Failed to get user quota') - logger.error(err) - respondWithError(Endpoint.GET_QUOTA, response, 500, ErrorMessage.DATABASE_GET_FAILURE) - } -} - -function isValidGetQuotaInput(requestBody: GetQuotaRequest): boolean { - return ( - hasValidAccountParam(requestBody) && - identifierIsValidIfExists(requestBody) && - isBodyReasonablySized(requestBody) - ) -} - -/* - * Returns the number of queries already performed and the calculated query quota. - */ -export async function getRemainingQueryCount( - logger: Logger, - account: string, - hashedPhoneNumber?: string -): Promise<{ performedQueryCount: number; totalQuota: number }> { - logger.debug({ account }, 'Retrieving remaining query count') - const meterGetRemainingQueryCount = Histograms.getRemainingQueryCountInstrumentation - .labels('getRemainingQueryCount') - .startTimer() - const [totalQuota, performedQueryCount] = await Promise.all([ - getQueryQuota(logger, account, hashedPhoneNumber), - getPerformedQueryCount(account, logger), - ]).finally(meterGetRemainingQueryCount) - Histograms.userRemainingQuotaAtRequest.observe(totalQuota - performedQueryCount) - return { performedQueryCount, totalQuota } -} - -async function getQueryQuota(logger: Logger, account: string, hashedPhoneNumber?: string) { - const getQueryQuotaMeter = Histograms.getRemainingQueryCountInstrumentation - .labels('getQueryQuota') - .startTimer() - return _getQueryQuota(logger, account, hashedPhoneNumber).finally(getQueryQuotaMeter) -} - -/* - * Calculates how many queries the caller has unlocked based on the algorithm - * unverifiedQueryCount + verifiedQueryCount + (queryPerTransaction * transactionCount) - * If the caller is not verified, they must have a minimum balance to get the unverifiedQueryMax. - */ -async function _getQueryQuota(logger: Logger, account: string, hashedPhoneNumber?: string) { - const getWalletAddressAndIsVerifiedMeter = Histograms.getRemainingQueryCountInstrumentation - .labels('getWalletAddressAndIsVerified') - .startTimer() - const [_walletAddress, _isAccountVerified] = await Promise.allSettled([ - getWalletAddress(logger, account), - new Promise((resolve) => - resolve( - hashedPhoneNumber - ? isVerified(account, hashedPhoneNumber, getContractKit() as any, logger) - : false - ) - ), - ]).finally(getWalletAddressAndIsVerifiedMeter) - - let walletAddress = _walletAddress.status === 'fulfilled' ? _walletAddress.value : NULL_ADDRESS - const isAccountVerified = - _isAccountVerified.status === 'fulfilled' ? _isAccountVerified.value : false - - logger.debug({ account, walletAddress }, 'begin getQueryQuota') - - if (account.toLowerCase() === walletAddress.toLowerCase()) { - logger.debug('walletAddress is the same as accountAddress') - walletAddress = NULL_ADDRESS - } - - if (walletAddress !== NULL_ADDRESS) { - Counters.requestsWithWalletAddress.inc() - } - - if (isAccountVerified) { - Counters.requestsWithVerifiedAccount.inc() - logger.debug({ account }, 'Account is verified') - const transactionCount = await getTransactionCount(logger, account, walletAddress) - const quota = - config.quota.unverifiedQueryMax + - config.quota.additionalVerifiedQueryMax + - config.quota.queryPerTransaction * transactionCount - - logger.trace({ - unverifiedQueryMax: config.quota.unverifiedQueryMax, - additionalVerifiedQueryMax: config.quota.additionalVerifiedQueryMax, - queryPerTransaction: config.quota.queryPerTransaction, - transactionCount, - quota, - }) - - return quota - } - - const getBalancesMeter = Histograms.getRemainingQueryCountInstrumentation - .labels('balances') - .startTimer() - let cUSDAccountBalance = new BigNumber(0) - let cEURAccountBalance = new BigNumber(0) - let celoAccountBalance = new BigNumber(0) - - await Promise.all([ - new Promise((resolve) => { - resolve(getStableTokenBalance(StableToken.cUSD, logger, account, walletAddress)) - }), - new Promise((resolve) => { - resolve(getStableTokenBalance(StableToken.cEUR, logger, account, walletAddress)) - }), - new Promise((resolve) => { - resolve(getCeloBalance(logger, account, walletAddress)) - }), - ]) - .then((values) => { - cUSDAccountBalance = values[0] as BigNumber - cEURAccountBalance = values[1] as BigNumber - celoAccountBalance = values[2] as BigNumber - }) - .finally(getBalancesMeter) - - // Min balance can be in either cUSD, cEUR or CELO - if ( - cUSDAccountBalance.isGreaterThanOrEqualTo(config.quota.minDollarBalance) || - cEURAccountBalance.isGreaterThanOrEqualTo(config.quota.minEuroBalance) || - celoAccountBalance.isGreaterThanOrEqualTo(config.quota.minCeloBalance) - ) { - Counters.requestsWithUnverifiedAccountWithMinBalance.inc() - logger.debug( - { - account, - cUSDAccountBalance, - cEURAccountBalance, - celoAccountBalance, - minDollarBalance: config.quota.minDollarBalance, - minEuroBalance: config.quota.minEuroBalance, - minCeloBalance: config.quota.minCeloBalance, - }, - 'Account is not verified but meets min balance' - ) - const transactionCount = await getTransactionCount(logger, account, walletAddress) - - const quota = - config.quota.unverifiedQueryMax + config.quota.queryPerTransaction * transactionCount - - logger.trace({ - unverifiedQueryMax: config.quota.unverifiedQueryMax, - queryPerTransaction: config.quota.queryPerTransaction, - transactionCount, - quota, - }) - return quota - } - - logger.trace({ - account, - cUSDAccountBalance, - cEURAccountBalance, - celoAccountBalance, - minDollarBalance: config.quota.minDollarBalance, - minEuroBalance: config.quota.minEuroBalance, - minCeloBalance: config.quota.minCeloBalance, - quota: 0, - }) - logger.debug({ account }, 'Account is not verified and does not meet min balance') - return 0 -} - -export async function getTransactionCount(logger: Logger, ...addresses: string[]): Promise { - const getTransactionCountMeter = Histograms.getRemainingQueryCountInstrumentation - .labels('getTransactionCount') - .startTimer() - const res = Promise.all( - addresses - .filter((address) => address !== NULL_ADDRESS) - .map((address) => - retryAsyncWithBackOffAndTimeout( - () => getContractKit().connection.getTransactionCount(address), - RETRY_COUNT, - [], - RETRY_DELAY_IN_MS, - undefined, - FULL_NODE_TIMEOUT_IN_MS - ).catch((err) => { - Counters.blockchainErrors.labels(Labels.read).inc() - throw err - }) - ) - ) - .then((values) => { - logger.trace({ addresses, txCounts: values }, 'Fetched txCounts for addresses') - return values.reduce((a, b) => a + b) - }) - .finally(getTransactionCountMeter) - return res -} - -export async function getStableTokenBalance( - stableToken: StableToken, - logger: Logger, - ...addresses: string[] -): Promise { - return Promise.all( - addresses - .filter((address) => address !== NULL_ADDRESS) - .map((address) => - retryAsyncWithBackOffAndTimeout( - async () => - (await getContractKit().contracts.getStableToken(stableToken)).balanceOf(address), - RETRY_COUNT, - [], - RETRY_DELAY_IN_MS, - undefined, - FULL_NODE_TIMEOUT_IN_MS - ).catch((err) => { - Counters.blockchainErrors.labels(Labels.read).inc() - throw err - }) - ) - ).then((values) => { - logger.trace( - { addresses, balances: values.map((bn) => bn.toString()) }, - `Fetched ${stableToken} balances for addresses` - ) - return values.reduce((a, b) => a.plus(b)) - }) -} - -export async function getCeloBalance(logger: Logger, ...addresses: string[]): Promise { - return Promise.all( - addresses - .filter((address) => address !== NULL_ADDRESS) - .map((address) => - retryAsyncWithBackOffAndTimeout( - async () => (await getContractKit().contracts.getGoldToken()).balanceOf(address), - RETRY_COUNT, - [], - RETRY_DELAY_IN_MS, - undefined, - FULL_NODE_TIMEOUT_IN_MS - ).catch((err) => { - Counters.blockchainErrors.labels(Labels.read).inc() - throw err - }) - ) - ).then((values) => { - logger.trace( - { addresses, balances: values.map((bn) => bn.toString()) }, - 'Fetched celo balances for addresses' - ) - return values.reduce((a, b) => a.plus(b)) - }) -} - -export async function getWalletAddress(logger: Logger, account: string): Promise { - const getWalletAddressMeter = Histograms.getRemainingQueryCountInstrumentation - .labels('getWalletAddress') - .startTimer() - return retryAsyncWithBackOffAndTimeout( - async () => (await getContractKit().contracts.getAccounts()).getWalletAddress(account), - RETRY_COUNT, - [], - RETRY_DELAY_IN_MS, - undefined, - FULL_NODE_TIMEOUT_IN_MS - ) - .catch((err: any) => { - logger.error({ account }, 'failed to get wallet address for account') - logger.error(err) - Counters.blockchainErrors.labels(Labels.read).inc() - return NULL_ADDRESS - }) - .finally(getWalletAddressMeter) -} diff --git a/packages/phone-number-privacy/signer/src/web3/contracts.ts b/packages/phone-number-privacy/signer/src/web3/contracts.ts deleted file mode 100644 index 668e5c7fbbb..00000000000 --- a/packages/phone-number-privacy/signer/src/web3/contracts.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { retryAsyncWithBackOffAndTimeout } from '@celo/base' -import { ContractKit, newKit, newKitWithApiKey } from '@celo/contractkit' -import { - FULL_NODE_TIMEOUT_IN_MS, - RETRY_COUNT, - RETRY_DELAY_IN_MS, -} from '@celo/phone-number-privacy-common' -import { Counters, Histograms, Labels } from '../common/metrics' -import config from '../config' - -const contractKit = config.blockchain.apiKey - ? newKitWithApiKey(config.blockchain.provider, config.blockchain.apiKey) - : newKit(config.blockchain.provider) - -export function getContractKit(): ContractKit { - return contractKit -} - -export async function getBlockNumber(): Promise { - const getBlockNumberMeter = Histograms.getBlindedSigInstrumentation - .labels('getBlockNumber') - .startTimer() - const res = retryAsyncWithBackOffAndTimeout( - () => getContractKit().connection.getBlockNumber(), - RETRY_COUNT, - [], - RETRY_DELAY_IN_MS, - undefined, - FULL_NODE_TIMEOUT_IN_MS - ) - .catch((err) => { - Counters.blockchainErrors.labels(Labels.read).inc() - throw err - }) - .finally(getBlockNumberMeter) - - return res -} diff --git a/packages/phone-number-privacy/signer/test/domain/domain.test.ts b/packages/phone-number-privacy/signer/test/domain/domain.test.ts deleted file mode 100644 index cf046eca2f7..00000000000 --- a/packages/phone-number-privacy/signer/test/domain/domain.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { WasmBlsBlindingClient } from '@celo/identity/lib/odis/bls-blinding-client' -import { - DomainIdentifiers, - DomainRestrictedSignatureRequest, - DomainQuotaStatusRequest, - DisableDomainRequest, - genSessionID, - SequentialDelayDomain, - rootLogger, -} from '@celo/phone-number-privacy-common' -import { defined, noBool, noString } from '@celo/utils/lib/sign-typed-data-utils' -import { LocalWallet } from '@celo/wallet-local' -import { Request, Response } from 'express' -import { anyNumber, instance, mock, reset, verify, when } from 'ts-mockito' -import config, { DEV_PUBLIC_KEY, SupportedDatabase, SupportedKeystore } from '../../src/config' -import { closeDatabase, initDatabase } from '../../src/database/database' -import { DomainService } from '../../src/domain/domain.service' -import { DomainAuthService } from '../../src/domain/auth/domainAuth.service' -import { DomainQuotaService } from '../../src/domain/quota/domainQuota.service' -import { initKeyProvider } from '../../src/key-management/key-provider' - -// We will be using a Sqlite in-memory database for tests. -config.db.type = SupportedDatabase.Sqlite -config.keystore.type = SupportedKeystore.MockSecretManager - -describe('domainService', () => { - const requestMock = mock() - const request = instance(requestMock) - - const responseMock = mock() - const response = instance(responseMock) - - // TODO(victor): Use the real auth service by default, once implemented, for this test. - const authServiceMock = mock(DomainAuthService) - const authService = instance(authServiceMock) - - const domainService = new DomainService(authService, new DomainQuotaService()) - - const wallet = new LocalWallet() - wallet.addAccount('0x00000000000000000000000000000000000000000000000000000000deadbeef') - const walletAddress = wallet.getAccounts()[0]! - - const authenticatedDomain: SequentialDelayDomain = { - name: DomainIdentifiers.SequentialDelay, - version: '1', - stages: [{ delay: 0, resetTimer: noBool, batchSize: defined(2), repetitions: defined(10) }], - address: defined(walletAddress), - salt: noString, - } - - const signatureRequest = async (): Promise< - DomainRestrictedSignatureRequest - > => { - const blsBlindingClient = new WasmBlsBlindingClient(DEV_PUBLIC_KEY) - - return { - domain: authenticatedDomain, - options: { - signature: noString, - nonce: defined(0), - }, - blindedMessage: await blsBlindingClient.blindMessage('test message'), - sessionID: defined(genSessionID()), - } - } - - const quotaRequest = (): DomainQuotaStatusRequest => ({ - domain: authenticatedDomain, - options: { - signature: noString, - nonce: defined(0), - }, - sessionID: defined(genSessionID()), - }) - - const disableRequest = (): DisableDomainRequest => ({ - domain: authenticatedDomain, - options: { - signature: noString, - nonce: defined(0), - }, - sessionID: defined(genSessionID()), - }) - - beforeAll(async () => { - response.locals = { logger: rootLogger() } - await initKeyProvider() - }) - - beforeEach(async () => { - // Create a new in-memory database for each test. - await initDatabase() - reset(authServiceMock) - reset(responseMock) - reset(requestMock) - - // Allows for call chaining after setting the status. - when(responseMock.status(anyNumber())).thenReturn(response) - }) - - afterEach(async () => { - // Close and destroy the in-memory database. - // Note: If tests start to be too slow, this could be replaced with more complicated logic to - // reset the database state without destroying and recreting it for each test. - await closeDatabase() - }) - - describe('.handleDisableDomain', () => { - it('Should respond with 200 on valid request', async () => { - when(authServiceMock.authCheck()).thenReturn(true) - - when(requestMock.body).thenReturn(disableRequest()) - await domainService.handleDisableDomain(request, response) - - verify(responseMock.status(200)).once() - }) - - it('Should respond with 200 on repeated valid requests', async () => { - when(authServiceMock.authCheck()).thenReturn(true) - - when(requestMock.body).thenReturn(disableRequest()) - await domainService.handleDisableDomain(request, response) - - when(requestMock.body).thenReturn(disableRequest()) - await domainService.handleDisableDomain(request, response) - - verify(responseMock.status(200)).twice() - }) - - it('Should respond with 403 on failed auth', async () => { - when(authServiceMock.authCheck()).thenReturn(false) - - when(requestMock.body).thenReturn(disableRequest()) - await domainService.handleDisableDomain(request, response) - - verify(responseMock.status(403)).once() - }) - - it('Should respond with 400 on unknown domain', async () => { - when(authServiceMock.authCheck()).thenReturn(true) - - const req = disableRequest() - when(requestMock.body).thenReturn({ - ...req, - domain: { ...req.domain, name: 'UnknownDomain' }, - }) - await domainService.handleDisableDomain(request, response) - - verify(responseMock.status(400)).once() - }) - }) - - describe('.handleGetDomainQuotaStatus', () => { - it('Should respond with 200 on valid request', async () => { - when(authServiceMock.authCheck()).thenReturn(true) - - when(requestMock.body).thenReturn(quotaRequest()) - await domainService.handleGetDomainQuotaStatus(request, response) - - verify(responseMock.status(200)).once() - }) - }) - - describe('.handleGetDomainRestrictedSignature', () => { - it('Should respond with 200 on valid request', async () => { - when(authServiceMock.authCheck()).thenReturn(true) - - when(requestMock.body).thenReturn(await signatureRequest()) - await domainService.handleGetDomainRestrictedSignature(request, response) - - verify(responseMock.status(200)).once() - }) - }) -}) diff --git a/packages/phone-number-privacy/signer/test/end-to-end/domain/domain.service.test.ts b/packages/phone-number-privacy/signer/test/end-to-end/domain/domain.service.test.ts new file mode 100644 index 00000000000..411598c0e06 --- /dev/null +++ b/packages/phone-number-privacy/signer/test/end-to-end/domain/domain.service.test.ts @@ -0,0 +1,86 @@ +import { + Domain, + DomainOptions, + SequentialDelayDomain, + SignerEndpoint as Endpoint, +} from '@celo/phone-number-privacy-common' +import { ACCOUNT_ADDRESS2 } from '@celo/phone-number-privacy-common/lib/test/values' +import { defined, noBool, noString } from '@celo/utils/lib/sign-typed-data-utils' +import 'isomorphic-fetch' +import { contractKit } from '../../../../combiner/test/end-to-end/resources' + +const ODIS_SIGNER = process.env.ODIS_SIGNER_SERVICE_URL +describe('Domain Service tests', () => { + describe('Disable domain tests', () => { + it('Should answer 404 for unknown domain', async () => { + const seqDomain: Domain = { + name: 'wrong domain', + version: '1', + } + const options: DomainOptions = {} + const response = await postDisableMessage(seqDomain, options) + expect(response.status).toBe(404) + }) + + it('Should answer 200 for known domain', async () => { + const authenticatedDomain: SequentialDelayDomain = { + name: 'ODIS Sequential Delay Domain', + version: '1', + stages: [{ delay: 0, resetTimer: noBool, batchSize: defined(2), repetitions: defined(10) }], + address: defined(ACCOUNT_ADDRESS2), + salt: noString, + } + const signature = await contractKit.connection.sign( + JSON.stringify(authenticatedDomain), + ACCOUNT_ADDRESS2 + ) + const options = { + signature: defined(signature), + nonce: defined(0), + } + const response = await postDisableMessage(authenticatedDomain, options) + expect(response.status).toBe(200) + }) + + it('Should answer 200 for multiple requests', async () => { + const authenticatedDomain: SequentialDelayDomain = { + name: 'ODIS Sequential Delay Domain', + version: '1', + stages: [{ delay: 0, resetTimer: noBool, batchSize: defined(2), repetitions: defined(10) }], + address: defined(ACCOUNT_ADDRESS2), + salt: noString, + } + const signature = await contractKit.connection.sign( + JSON.stringify(authenticatedDomain), + ACCOUNT_ADDRESS2 + ) + const options = { + signature: defined(signature), + nonce: defined(0), + } + const response = await postDisableMessage(authenticatedDomain, options) + expect(response.status).toBe(200) + + const response2 = await postDisableMessage(authenticatedDomain, options) + expect(response2.status).toBe(200) + }) + + async function postDisableMessage(domain: Domain, options: DomainOptions): Promise { + const body = JSON.stringify({ + domain, + options, + }) + + const res = await fetch(ODIS_SIGNER + Endpoint.DISABLE_DOMAIN, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: 'ignore', + }, + body, + }) + return res + } + }) +}) diff --git a/packages/phone-number-privacy/signer/test/end-to-end/get-blinded-sig.test.ts b/packages/phone-number-privacy/signer/test/end-to-end/get-blinded-sig.test.ts index e7ce1ca5c05..ec2a39188a2 100644 --- a/packages/phone-number-privacy/signer/test/end-to-end/get-blinded-sig.test.ts +++ b/packages/phone-number-privacy/signer/test/end-to-end/get-blinded-sig.test.ts @@ -1,8 +1,9 @@ import { newKitFromWeb3 } from '@celo/contractkit' import { - Endpoints, - GetQuotaResponse, + KEY_VERSION_HEADER, + PnpQuotaResponse, rootLogger as logger, + SignerEndpoint, SignMessageResponseFailure, SignMessageResponseSuccess, TestUtils, @@ -14,8 +15,8 @@ import threshold_bls from 'blind-threshold-bls' import { randomBytes } from 'crypto' import 'isomorphic-fetch' import Web3 from 'web3' -import config, { getVersion } from '../../src/config' -import { getWalletAddress } from '../../src/signing/query-quota' +import { config, getVersion } from '../../src/config' +import { getWalletAddress } from '../../src/services/web3/contracts' require('dotenv').config() @@ -33,6 +34,7 @@ const { replenishQuota, registerWalletAddress } = TestUtils.Utils const ODIS_SIGNER = process.env.ODIS_SIGNER_SERVICE_URL const ODIS_PUBLIC_POLYNOMIAL = process.env.ODIS_PUBLIC_POLYNOMIAL as string +const ODIS_KEY_VERSION = (process.env.ODIS_KEY_VERSION || 1) as string const SIGN_MESSAGE_ENDPOINT = '/getBlindedMessagePartialSig' const GET_QUOTA_ENDPOINT = '/getQuota' @@ -55,10 +57,11 @@ describe('Running against a deployed service', () => { console.log('FORNO_URL: ' + DEFAULT_FORNO_URL) console.log('ODIS_SIGNER: ' + ODIS_SIGNER) console.log('ODIS_PUBLIC_POLYNOMIAL: ' + ODIS_PUBLIC_POLYNOMIAL) + console.log('ODIS_KEY_VERSION:' + ODIS_KEY_VERSION) }) it('Service is deployed at correct version', async () => { - const response = await fetch(ODIS_SIGNER + Endpoints.STATUS, { method: 'GET' }) + const response = await fetch(ODIS_SIGNER + SignerEndpoint.STATUS, { method: 'GET' }) const body = await response.json() // This checks against local package.json version, change if necessary expect(body.version).toBe(getVersion()) @@ -201,9 +204,11 @@ describe('Running against a deployed service', () => { it('Check that accounts are set up correctly', async () => { expect(await getQuota(ACCOUNT_ADDRESS2, IDENTIFIER)).toBeLessThan(initialQuota) - expect(await getWalletAddress(logger(), ACCOUNT_ADDRESS3)).toBe(ACCOUNT_ADDRESS2) + expect(await getWalletAddress(contractkit, logger(), ACCOUNT_ADDRESS3)).toBe(ACCOUNT_ADDRESS2) }) + // Note: Use this test to check the signers' key configuration. Modify .env to try out different + // key/version combinations it('Returns sig when querying succeeds with unused request', async () => { await replenishQuota(ACCOUNT_ADDRESS2, contractkit) const blindedPhoneNumber = getRandomBlindedPhoneNumber() @@ -215,9 +220,11 @@ describe('Running against a deployed service', () => { const data = await response.text() const signResponse = JSON.parse(data) as SignerResponse expect(signResponse.success).toBeTruthy() - const sigBuffer = Buffer.from(signResponse.signature as string, 'base64') - const isValid = isValidSignature(sigBuffer, blindedPhoneNumber, ODIS_PUBLIC_POLYNOMIAL) - expect(isValid).toBeTruthy() + if (signResponse.success) { + const sigBuffer = Buffer.from(signResponse.signature as string, 'base64') + const isValid = isValidSignature(sigBuffer, blindedPhoneNumber, ODIS_PUBLIC_POLYNOMIAL) + expect(isValid).toBeTruthy() + } }) it('Returns count when querying with unused request increments query count', async () => { @@ -244,7 +251,7 @@ async function getQuota( authHeader?: string ): Promise { const res = await queryQuotaEndpoint(account, hashedPhoneNumber, authHeader) - return res.totalQuota + return res.success ? res.totalQuota ?? 0 : 0 } async function getQueryCount( @@ -253,14 +260,14 @@ async function getQueryCount( authHeader?: string ): Promise { const res = await queryQuotaEndpoint(account, hashedPhoneNumber, authHeader) - return res.performedQueryCount + return res.success ? res.performedQueryCount ?? 0 : 0 } async function queryQuotaEndpoint( account: string, hashedPhoneNumber?: string, authHeader?: string -): Promise { +): Promise { const body = JSON.stringify({ account, hashedPhoneNumber, @@ -285,7 +292,8 @@ async function postToSignMessage( base64BlindedMessage: string, account: string, timestamp?: number, - authHeader?: string + authHeader?: string, + keyVersion: string = ODIS_KEY_VERSION ): Promise { const body = JSON.stringify({ hashedPhoneNumber: IDENTIFIER, @@ -302,6 +310,7 @@ async function postToSignMessage( Accept: 'application/json', 'Content-Type': 'application/json', Authorization: authorization, + [KEY_VERSION_HEADER]: keyVersion, }, body, }) diff --git a/packages/phone-number-privacy/signer/test/index.test.ts b/packages/phone-number-privacy/signer/test/index.test.ts deleted file mode 100644 index 05dcb7b2b16..00000000000 --- a/packages/phone-number-privacy/signer/test/index.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { authenticateUser } from '@celo/phone-number-privacy-common' -import BigNumber from 'bignumber.js' -import request from 'supertest' -import { ErrorMessage, WarningMessage } from '../../common/src/interfaces/error-utils' -import { - ContractRetrieval, - createMockAccounts, - createMockAttestation, - createMockContractKit, - createMockToken, - createMockWeb3, -} from '../../common/src/test/utils' -import { BLINDED_PHONE_NUMBER } from '../../common/src/test/values' -import { computeBlindedSignature } from '../src/bls/bls-cryptography-client' -import { DEV_PRIVATE_KEY, getVersion } from '../src/config' -import { incrementQueryCount } from '../src/database/wrappers/account' -import { getRequestExists, storeRequest } from '../src/database/wrappers/request' -import { getKeyProvider } from '../src/key-management/key-provider' -import { createServer } from '../src/server' -import { getRemainingQueryCount, getWalletAddress } from '../src/signing/query-quota' -import { getBlockNumber, getContractKit } from '../src/web3/contracts' - -const BLS_SIGNATURE = '0Uj+qoAu7ASMVvm6hvcUGx2eO/cmNdyEgGn0mSoZH8/dujrC1++SZ1N6IP6v2I8A' - -jest.setTimeout(10000) - -jest.mock('@celo/phone-number-privacy-common', () => ({ - ...jest.requireActual('@celo/phone-number-privacy-common'), - authenticateUser: jest.fn(), -})) -const mockAuthenticateUser = authenticateUser as jest.Mock - -jest.mock('../src/signing/query-quota') -const mockGetRemainingQueryCount = getRemainingQueryCount as jest.Mock -const mockGetWalletAddress = getWalletAddress as jest.Mock - -jest.mock('../src/key-management/key-provider') -const mockGetKeyProvider = getKeyProvider as jest.Mock - -jest.mock('../src/bls/bls-cryptography-client') -const mockComputeBlindedSignature = computeBlindedSignature as jest.Mock - -jest.mock('../src/database/wrappers/account') -const mockIncrementQueryCount = incrementQueryCount as jest.Mock - -jest.mock('../src/database/wrappers/request') -const mockStoreRequest = storeRequest as jest.Mock -const mockGetRequestExists = getRequestExists as jest.Mock - -jest.mock('../src/web3/contracts') -const mockGetBlockNumber = getBlockNumber as jest.Mock -const mockGetContractKit = getContractKit as jest.Mock - -describe(`POST /getBlindedMessageSignature endpoint`, () => { - const app = createServer() - - beforeEach(() => { - const mockContractKit = createMockContractKit( - { - [ContractRetrieval.getAttestations]: createMockAttestation(3, 3), - [ContractRetrieval.getStableToken]: createMockToken(new BigNumber(200000000000000000)), - [ContractRetrieval.getGoldToken]: createMockToken(new BigNumber(200000000000000000)), - [ContractRetrieval.getAccounts]: createMockAccounts('0x0'), - }, - createMockWeb3(0) - ) - mockGetContractKit.mockImplementation(() => mockContractKit) - mockAuthenticateUser.mockResolvedValue(true) - mockGetKeyProvider.mockReturnValue({ getPrivateKey: jest.fn(() => DEV_PRIVATE_KEY) }) - mockComputeBlindedSignature.mockReturnValue(BLS_SIGNATURE) - mockIncrementQueryCount.mockReturnValue(true) - mockStoreRequest.mockReturnValue(true) - mockGetRequestExists.mockReturnValue(false) - mockGetWalletAddress.mockResolvedValue('0x0') - }) - - const validRequest = { - blindedQueryPhoneNumber: BLINDED_PHONE_NUMBER, - hashedPhoneNumber: '0x5f6e88c3f724b3a09d3194c0514426494955eff7127c29654e48a361a19b4b96', - account: '0x78dc5D2D739606d31509C31d654056A45185ECb6', - } - - describe('with valid input', () => { - it('provides signature', (done) => { - mockGetRemainingQueryCount.mockResolvedValue({ performedQueryCount: 0, totalQuota: 10 }) - mockGetBlockNumber.mockResolvedValue(10000) - request(app) - .post('/getBlindedMessagePartialSig') - .send(validRequest) - .expect('Content-Type', /json/) - .expect( - 200, - { - success: true, - signature: BLS_SIGNATURE, - version: getVersion(), - performedQueryCount: 1, - totalQuota: 10, - blockNumber: 10000, - }, - done - ) - }) - // Backwards compatibility check - it('provides signature w/ expired timestamp', (done) => { - mockGetRemainingQueryCount.mockResolvedValue({ performedQueryCount: 0, totalQuota: 10 }) - mockGetBlockNumber.mockResolvedValue(10000) - request(app) - .post('/getBlindedMessagePartialSig') - .send({ ...validRequest, timestamp: Date.now() - 10 * 60 * 1000 }) // 10 minutes ago - .expect('Content-Type', /json/) - .expect( - 200, - { - success: true, - signature: BLS_SIGNATURE, - version: getVersion(), - performedQueryCount: 1, - totalQuota: 10, - blockNumber: 10000, - }, - done - ) - }) - it('returns 403 on query count 0', (done) => { - mockGetRemainingQueryCount.mockResolvedValue({ performedQueryCount: 10, totalQuota: 10 }) - request(app) - .post('/getBlindedMessagePartialSig') - .send(validRequest) - .expect('Content-Type', /json/) - .expect(403, done) - }) - // We don't want to block the user on DB or blockchain query failure - it('returns 200 on DB query failure', (done) => { - mockGetRemainingQueryCount.mockRejectedValue(undefined) - request(app) - .post('/getBlindedMessagePartialSig') - .send(validRequest) - .expect('Content-Type', /json/) - .expect(200, done) - }) - it('returns 500 on bls error', (done) => { - mockGetRemainingQueryCount.mockResolvedValue({ performedQueryCount: 0, totalQuota: 10 }) - mockComputeBlindedSignature.mockImplementation(() => { - throw Error() - }) - request(app) - .post('/getBlindedMessagePartialSig') - .send(validRequest) - .expect('Content-Type', /json/) - .expect(500, done) - }) - it('returns 200 with warning on replayed request', (done) => { - mockGetRemainingQueryCount.mockResolvedValue({ performedQueryCount: 0, totalQuota: 10 }) - mockGetRequestExists.mockReturnValue(true) - request(app) - .post('/getBlindedMessagePartialSig') - .send(validRequest) - .expect('Content-Type', /json/) - .expect( - 200, - { - success: false, - signature: BLS_SIGNATURE, - version: getVersion(), - performedQueryCount: 0, - error: WarningMessage.DUPLICATE_REQUEST_TO_GET_PARTIAL_SIG, - totalQuota: 10, - blockNumber: 10000, - }, - done - ) - }) - it('returns 200 with warning on failure to increment query count', (done) => { - mockGetRemainingQueryCount.mockResolvedValue({ performedQueryCount: 0, totalQuota: 10 }) - mockIncrementQueryCount.mockReturnValue(false) - request(app) - .post('/getBlindedMessagePartialSig') - .send(validRequest) - .expect('Content-Type', /json/) - .expect( - 200, - { - success: false, - signature: BLS_SIGNATURE, - version: getVersion(), - performedQueryCount: 0, - error: ErrorMessage.FAILURE_TO_INCREMENT_QUERY_COUNT, - totalQuota: 10, - blockNumber: 10000, - }, - done - ) - }) - it('returns 200 with warning on failure to store request', (done) => { - mockGetRemainingQueryCount.mockResolvedValue({ performedQueryCount: 0, totalQuota: 10 }) - mockStoreRequest.mockReturnValue(false) - request(app) - .post('/getBlindedMessagePartialSig') - .send(validRequest) - .expect('Content-Type', /json/) - .expect( - 200, - { - success: false, - signature: BLS_SIGNATURE, - version: getVersion(), - performedQueryCount: 1, - error: ErrorMessage.FAILURE_TO_STORE_REQUEST, - totalQuota: 10, - blockNumber: 10000, - }, - done - ) - }) - }) - describe('with invalid input', () => { - it('invalid address returns 400', (done) => { - const mockRequestData = { - ...validRequest, - account: 'd31509C31d654056A45185ECb6', - } - - request(app).post('/getBlindedMessagePartialSig').send(mockRequestData).expect(400, done) - }) - - it('invalid hashedPhoneNumber returns 400', (done) => { - const mockRequestData = { - ...validRequest, - hashedPhoneNumber: '+1234567890', - } - - request(app).post('/getBlindedMessagePartialSig').send(mockRequestData).expect(400, done) - }) - - it('invalid blinded phone number returns 400', (done) => { - const mockRequestData = { - ...validRequest, - blindedQueryPhoneNumber: '1234567890', - } - - request(app).post('/getBlindedMessagePartialSig').send(mockRequestData).expect(400, done) - }) - }) -}) diff --git a/packages/phone-number-privacy/signer/test/integration/domain.test.ts b/packages/phone-number-privacy/signer/test/integration/domain.test.ts new file mode 100644 index 00000000000..7d6ad2fa2a4 --- /dev/null +++ b/packages/phone-number-privacy/signer/test/integration/domain.test.ts @@ -0,0 +1,1044 @@ +import { + DisableDomainRequest, + disableDomainRequestEIP712, + DisableDomainResponse, + DisableDomainResponseSuccess, + domainHash, + DomainIdentifiers, + DomainQuotaStatusRequest, + domainQuotaStatusRequestEIP712, + DomainQuotaStatusResponse, + DomainRequestTypeTag, + DomainRestrictedSignatureRequest, + domainRestrictedSignatureRequestEIP712, + DomainRestrictedSignatureResponse, + ErrorMessage, + genSessionID, + KEY_VERSION_HEADER, + rootLogger, + SequentialDelayDomain, + SequentialDelayStage, + SignerEndpoint, + TestUtils, + ThresholdPoprfClient, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { defined, noBool, noNumber, noString } from '@celo/utils/lib/sign-typed-data-utils' +import { LocalWallet } from '@celo/wallet-local' +import { Knex } from 'knex' +import request from 'supertest' +import { initDatabase } from '../../src/common/database/database' +import { countAndThrowDBError } from '../../src/common/database/utils' +import { + createEmptyDomainStateRecord, + getDomainStateRecord, +} from '../../src/common/database/wrappers/domain-state' +import { initKeyProvider } from '../../src/common/key-management/key-provider' +import { KeyProvider } from '../../src/common/key-management/key-provider-base' +import { config, getVersion, SupportedDatabase, SupportedKeystore } from '../../src/config' +import { startSigner } from '../../src/server' + +jest.setTimeout(20000) + +describe('domain', () => { + const wallet = new LocalWallet() + wallet.addAccount('0x00000000000000000000000000000000000000000000000000000000deadbeef') + const walletAddress = wallet.getAccounts()[0]! + + const expectedVersion = getVersion() + + const domainStages = (): SequentialDelayStage[] => [ + { delay: 0, resetTimer: noBool, batchSize: defined(2), repetitions: defined(10) }, + ] + + const authenticatedDomain = (_stages?: SequentialDelayStage[]): SequentialDelayDomain => ({ + name: DomainIdentifiers.SequentialDelay, + version: '1', + stages: _stages ?? domainStages(), + address: defined(walletAddress), + salt: defined('himalayanPink'), + }) + + const signatureRequest = async ( + _domain?: SequentialDelayDomain, + _nonce?: number, + keyVersion: number = config.keystore.keys.domains.latest + ): Promise<[DomainRestrictedSignatureRequest, ThresholdPoprfClient]> => { + const domain = _domain ?? authenticatedDomain() + const thresholdPoprfClient = new ThresholdPoprfClient( + Buffer.from(TestUtils.Values.DOMAINS_THRESHOLD_DEV_PUBKEYS[keyVersion - 1], 'base64'), + Buffer.from(TestUtils.Values.DOMAINS_THRESHOLD_DEV_POLYNOMIALS[keyVersion - 1], 'hex'), + domainHash(domain), + Buffer.from('test message', 'utf8') + ) + + const req: DomainRestrictedSignatureRequest = { + type: DomainRequestTypeTag.SIGN, + domain: domain, + options: { + signature: noString, + nonce: defined(_nonce ?? 0), + }, + blindedMessage: thresholdPoprfClient.blindedMessage.toString('base64'), + sessionID: defined(genSessionID()), + } + req.options.signature = defined( + await wallet.signTypedData(walletAddress, domainRestrictedSignatureRequestEIP712(req)) + ) + return [req, thresholdPoprfClient] + } + + const quotaRequest = async (): Promise> => { + const req: DomainQuotaStatusRequest = { + type: DomainRequestTypeTag.QUOTA, + domain: authenticatedDomain(), + options: { + signature: noString, + nonce: noNumber, + }, + sessionID: defined(genSessionID()), + } + req.options.signature = defined( + await wallet.signTypedData(walletAddress, domainQuotaStatusRequestEIP712(req)) + ) + return req + } + + // Build and sign an example disable domain request. + const disableRequest = async (): Promise> => { + const req: DisableDomainRequest = { + type: DomainRequestTypeTag.DISABLE, + domain: authenticatedDomain(), + options: { + signature: noString, + nonce: noNumber, + }, + sessionID: defined(genSessionID()), + } + req.options.signature = defined( + await wallet.signTypedData(walletAddress, disableDomainRequestEIP712(req)) + ) + return req + } + + let keyProvider: KeyProvider + let app: any + let db: Knex + + // create deep copy + const _config: typeof config = JSON.parse(JSON.stringify(config)) + _config.db.type = SupportedDatabase.Sqlite + _config.keystore.type = SupportedKeystore.MOCK_SECRET_MANAGER + _config.api.domains.enabled = true + + beforeAll(async () => { + keyProvider = await initKeyProvider(_config) + }) + + beforeEach(async () => { + // Create a new in-memory database for each test. + db = await initDatabase(_config) + app = startSigner(_config, db, keyProvider) + }) + + afterEach(async () => { + // Close and destroy the in-memory database. + // Note: If tests start to be too slow, this could be replaced with more complicated logic to + // reset the database state without destroying and recreating it for each test. + + await db?.destroy() + }) + + describe(`${SignerEndpoint.STATUS}`, () => { + it('Should return 200 and correct version', async () => { + const res = await request(app).get(SignerEndpoint.STATUS) + expect(res.status).toBe(200) + expect(res.body.version).toBe(expectedVersion) + }) + }) + + describe(`${SignerEndpoint.DISABLE_DOMAIN}`, () => { + it('Should respond with 200 on valid request', async () => { + const res = await request(app) + .post(SignerEndpoint.DISABLE_DOMAIN) + .send(await disableRequest()) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + status: { + disabled: true, + counter: 0, + timer: 0, + now: res.body.status.now, + }, + }) + }) + + it('Should respond with 200 on repeated valid requests', async () => { + const req = await disableRequest() + const res1 = await request(app).post(SignerEndpoint.DISABLE_DOMAIN).send(req) + expect(res1.status).toBe(200) + const expectedResponse: DisableDomainResponseSuccess = { + success: true, + version: res1.body.version, + status: { + disabled: true, + counter: 0, + timer: 0, + now: res1.body.status.now, + }, + } + expect(res1.body).toStrictEqual(expectedResponse) + const res2 = await request(app).post(SignerEndpoint.DISABLE_DOMAIN).send(req) + expect(res2.status).toBe(200) + // Avoid flakiness due to mismatching times between res1 & res2 + expectedResponse.status.now = res2.body.status.now + expect(res2.body).toStrictEqual(expectedResponse) + }) + + it('Should respond with 200 on extra request fields', async () => { + const req = await disableRequest() + // @ts-ignore Intentionally adding an extra field to the request type + req.options.extraField = noString + + const res = await request(app).post(SignerEndpoint.DISABLE_DOMAIN).send(req) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + status: { + disabled: true, + counter: 0, + timer: 0, + now: res.body.status.now, + }, + }) + }) + + it('Should respond with 400 on missing request fields', async () => { + const badRequest = await disableRequest() + // @ts-ignore Intentionally deleting required field + delete badRequest.domain.version + + const res = await request(app).post(SignerEndpoint.DISABLE_DOMAIN).send(badRequest) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on unknown domain', async () => { + // Create a request with an invalid domain identifier. + const unknownRequest = await disableRequest() + // @ts-ignore UnknownDomain is (intentionally) not a valid domain identifier. + unknownRequest.domain.name = 'UnknownDomain' + + const res = await request(app).post(SignerEndpoint.DISABLE_DOMAIN).send(unknownRequest) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on bad encoding', async () => { + const badRequest1 = await disableRequest() + // @ts-ignore Intentionally not JSON + badRequest1.domain = 'Freddy' + + const res1 = await request(app).post(SignerEndpoint.DISABLE_DOMAIN).send(badRequest1) + + expect(res1.status).toBe(400) + expect(res1.body).toStrictEqual({ + success: false, + version: res1.body.version, + error: WarningMessage.INVALID_INPUT, + }) + + const badRequest2 = '' + + const res2 = await request(app).post(SignerEndpoint.DISABLE_DOMAIN).send(badRequest2) + + expect(res2.status).toBe(400) + expect(res2.body).toStrictEqual({ + success: false, + version: res2.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 401 on failed auth', async () => { + // Create a manipulated request, which will have a bad signature. + const badRequest = await disableRequest() + badRequest.domain.salt = defined('badSalt') + + const res = await request(app).post(SignerEndpoint.DISABLE_DOMAIN).send(badRequest) + + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + + it('Should respond with 503 on disabled api', async () => { + const configWithApiDisabled: typeof _config = JSON.parse(JSON.stringify(_config)) + configWithApiDisabled.api.domains.enabled = false + const appWithApiDisabled = startSigner(configWithApiDisabled, db, keyProvider) + + const req = await disableRequest() + + const res = await request(appWithApiDisabled).post(SignerEndpoint.DISABLE_DOMAIN).send(req) + + expect(res.status).toBe(503) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.API_UNAVAILABLE, + }) + }) + + describe('functionality in case of errors', () => { + it('Should respond with 500 on DB insertDomainStateRecord failure', async () => { + const req = await disableRequest() + const spy = jest + .spyOn( + jest.requireActual('../../src/common/database/wrappers/domain-state'), + 'insertDomainStateRecord' + ) + .mockImplementationOnce(() => { + // Handle errors in the same way as in insertDomainStateRecord + countAndThrowDBError( + new Error(), + rootLogger(_config.serviceName), + ErrorMessage.DATABASE_INSERT_FAILURE + ) + }) + const res = await request(app).post(SignerEndpoint.DISABLE_DOMAIN).send(req) + spy.mockRestore() + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: ErrorMessage.DATABASE_INSERT_FAILURE, + }) + expect(await getDomainStateRecord(db, req.domain, rootLogger(_config.serviceName))).toBe( + null + ) + }) + + it('Should respond with 500 on signer timeout', async () => { + const testTimeoutMS = 0 + const delay = 200 + + const configWithShortTimeout = JSON.parse(JSON.stringify(_config)) + configWithShortTimeout.timeout = testTimeoutMS + const appWithShortTimeout = startSigner(configWithShortTimeout, db, keyProvider) + + const req = await disableRequest() + const spy = jest + .spyOn( + jest.requireActual('../../src/common/database/wrappers/domain-state'), + 'getDomainStateRecord' + ) + .mockImplementationOnce(async () => { + await new Promise((resolve) => setTimeout(resolve, testTimeoutMS + delay)) + return null + }) + + const res = await request(appWithShortTimeout).post(SignerEndpoint.DISABLE_DOMAIN).send(req) + spy.mockRestore() + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + error: ErrorMessage.TIMEOUT_FROM_SIGNER, + version: expectedVersion, + }) + // Allow time for non-killed processes to finish + await new Promise((resolve) => setTimeout(resolve, delay)) + // Check that DB state was not updated on timeout + expect(await getDomainStateRecord(db, req.domain, rootLogger(_config.serviceName))).toBe( + null + ) + }) + }) + }) + + describe(`${SignerEndpoint.DOMAIN_QUOTA_STATUS}`, () => { + it('Should respond with 200 on valid request', async () => { + const res = await request(app) + .post(SignerEndpoint.DOMAIN_QUOTA_STATUS) + .send(await quotaRequest()) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + status: { disabled: false, counter: 0, timer: 0, now: res.body.status.now }, + }) + }) + + it('Should respond with 200 on repeated valid requests', async () => { + const res1 = await request(app) + .post(SignerEndpoint.DOMAIN_QUOTA_STATUS) + .send(await quotaRequest()) + expect(res1.status).toBe(200) + expect(res1.body).toStrictEqual({ + success: true, + version: res1.body.version, + status: { disabled: false, counter: 0, timer: 0, now: res1.body.status.now }, + }) + + const res2 = await request(app) + .post(SignerEndpoint.DOMAIN_QUOTA_STATUS) + .send(await quotaRequest()) + expect(res2.status).toBe(200) + expect(res2.body).toStrictEqual({ + success: true, + version: res2.body.version, + status: { disabled: false, counter: 0, timer: 0, now: res2.body.status.now }, + }) + }) + + it('Should respond with 200 on extra request fields', async () => { + const req = await quotaRequest() + // @ts-ignore Intentionally adding an extra field to the request type + req.options.extraField = noString + + const res = await request(app).post(SignerEndpoint.DOMAIN_QUOTA_STATUS).send(req) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + status: { disabled: false, counter: 0, timer: 0, now: res.body.status.now }, + }) + }) + + it('Should respond with 400 on missing request fields', async () => { + const badRequest = await quotaRequest() + // @ts-ignore Intentionally deleting required field + delete badRequest.domain.version + + const res = await request(app).post(SignerEndpoint.DOMAIN_QUOTA_STATUS).send(badRequest) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on unknown domain', async () => { + // Create a request with an invalid domain identifier. + const unknownRequest = await quotaRequest() + // @ts-ignore UnknownDomain is (intentionally) not a valid domain identifier. + unknownRequest.domain.name = 'UnknownDomain' + + const res = await request(app).post(SignerEndpoint.DOMAIN_QUOTA_STATUS).send(unknownRequest) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on bad encoding', async () => { + const badRequest1 = await quotaRequest() + // @ts-ignore Intentionally not JSON + badRequest1.domain = 'Freddy' + + const res1 = await request(app).post(SignerEndpoint.DOMAIN_QUOTA_STATUS).send(badRequest1) + + expect(res1.status).toBe(400) + expect(res1.body).toStrictEqual({ + success: false, + version: res1.body.version, + error: WarningMessage.INVALID_INPUT, + }) + + const badRequest2 = '' + + const res2 = await request(app).post(SignerEndpoint.DOMAIN_QUOTA_STATUS).send(badRequest2) + + expect(res2.status).toBe(400) + expect(res2.body).toStrictEqual({ + success: false, + version: res2.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 401 on failed auth', async () => { + // Create a manipulated request, which will have a bad signature. + const badRequest = await quotaRequest() + badRequest.domain.salt = defined('badSalt') + + const res = await request(app).post(SignerEndpoint.DOMAIN_QUOTA_STATUS).send(badRequest) + + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + + it('Should respond with 503 on disabled api', async () => { + const configWithApiDisabled: typeof _config = JSON.parse(JSON.stringify(_config)) + configWithApiDisabled.api.domains.enabled = false + const appWithApiDisabled = startSigner(configWithApiDisabled, db, keyProvider) + + const req = await quotaRequest() + + const res = await request(appWithApiDisabled) + .post(SignerEndpoint.DOMAIN_QUOTA_STATUS) + .send(req) + + expect(res.status).toBe(503) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.API_UNAVAILABLE, + }) + }) + + describe('functionality in case of errors', () => { + it('Should respond with 500 on DB getDomainStateRecordOrEmpty failure', async () => { + const req = await quotaRequest() + // Mocking getDomainStateRecord directly but requiring the real version of + // getDomainStateRecordOrEmpty does not easily work, + // which is why we mock the outer call here & use the countAndThrowDBError + // helper to get as close as possible to testing a real error. + const spy = jest + .spyOn( + jest.requireActual('../../src/common/database/wrappers/domain-state'), + 'getDomainStateRecordOrEmpty' + ) + .mockImplementationOnce(() => { + countAndThrowDBError( + new Error(), + rootLogger(_config.serviceName), + ErrorMessage.DATABASE_GET_FAILURE + ) + }) + const res = await request(app).post(SignerEndpoint.DOMAIN_QUOTA_STATUS).send(req) + spy.mockRestore() + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: ErrorMessage.DATABASE_GET_FAILURE, + }) + expect(await getDomainStateRecord(db, req.domain, rootLogger(_config.serviceName))).toBe( + null + ) + }) + }) + + it('Should respond with 500 on signer timeout', async () => { + const testTimeoutMS = 0 + const delay = 200 + + const configWithShortTimeout = JSON.parse(JSON.stringify(_config)) + configWithShortTimeout.timeout = testTimeoutMS + const appWithShortTimeout = startSigner(configWithShortTimeout, db, keyProvider) + + const req = await quotaRequest() + const spy = jest + .spyOn( + jest.requireActual('../../src/common/database/wrappers/domain-state'), + 'getDomainStateRecordOrEmpty' + ) + .mockImplementationOnce(async () => { + await new Promise((resolve) => setTimeout(resolve, testTimeoutMS + delay)) + return createEmptyDomainStateRecord(req.domain) + }) + + const res = await request(appWithShortTimeout) + .post(SignerEndpoint.DOMAIN_QUOTA_STATUS) + .send(req) + + spy.mockRestore() + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + error: ErrorMessage.TIMEOUT_FROM_SIGNER, + version: expectedVersion, + }) + // Allow time for non-killed processes to finish + await new Promise((resolve) => setTimeout(resolve, delay)) + }) + }) + + describe(`${SignerEndpoint.DOMAIN_SIGN}`, () => { + const expectedEvals: string[] = [ + 'AQAAALSWngdNIqyApv+AGj50OJxj9fSFPjvGlNZ+oAMykmgfVZd0o59MugofDPrBrUm9AFrr2uPXxKwL6PR2Uo3ch2jfOhRBE9amUTQV9U2gV8b1fFy2uFqnaT6ahV/GE956Aa4n8hiyRD36YL62YELtmGnNo4ODMl98ovirR6BoWp0yOhm42vq2SVRh3O69GYmHAd35Q/jYH9cOXnpNyUf1Pw4WmcbsTc+kwVe+226QJYMGtqafMIFR2AGnTiZji5SOAM7TTCDfZWKj+vtvrlFs3nSRI7AKFBzyx6KIyboljHvtBjhA1EGEzrqEJHLLU+iFASY3vqctLoONWcn6t8puaT5g4bmL3WqHiP+pF/0paLrHyQlxt3NJBcgWXv4GWMh7APDNFXpQ9O/skdlBPED433vMj7ZjXnybkdq7LFuMOua5rY8MEuTtoWizBtoErzBnADb5kWQCYgog94pCuYOYxCoK/+cl2DxVVnt0tarkG4mGK2BY+N6cwHhhYppED3GJAP70+R/nrjWhTp2xwKOd/uJByi/9ORHU16USrVsgka+LrGl/fy3P6BEtoK7cu6JfAXcx54Ojo0eUVsD5W6iHfrgllFk3jSgAvWUJ3I3IG8pTPuKX5eO6c9yL4/PVDY4/AAEkyf5vTG5f5dRd9akptsnVz2dmegSTAcj4md0gDugXzLEitduXF5lsqH6BFo9/AWDTgx2JzBSnSr8HSgSWk0ZKni2UIl1F1Eyb+CN45+5TQDiDv1fsl/0tumMikom1AA==', + 'AQAAAFd54JZz5xv1zf7+U8ZpjfLQQ4kZr5jl8R0/nWUqTcQyiO25awk09nh3elLvd6VkAOxnY0oiHASh7uzDJsi/XuDBJrp1oKoZ85eoCP90/RzGTuGDsHEmKtgDk/lAesQvAeGPjrhyjVleFcdnFq+czoT3aoOrYhdAin2lGU6nWAehbJwUyj5uvI+uEfcvk0KGAVbhZzC1L7jjGaLj+3SmhMfP71kqS5DeaSuyzu0byXum548HT1NRoO96icdfDtUBAfSw/FDzGfrFYf6WdUAObehnGt49AMpkVtGaMOsnETPL/nlbMizK4vMas+NlwyqgAZdCQaGX/FixHYpTDJ6NlBvWHwryoSJFse9XVLg5OizlEYh2ASLxFsZKqMCs7c6TAOYhwSWBeIkJCb0PQBsEMk2/vvnTY5RDAcBYW8aJ118IX/hcO9gO+lLathT+CKlDADGMQTVn1wqFapYZ677Gcsb4JDhUOaQjJCYu7JXlcyhLOe3/0AwI1LjI+ClA+TupADL+qQWTxyfcCfBb9XdcD6klzzFNs0aUfEwgjDyBUVbGrMBSQyE8ErDJYII7j1J4AeacepfWb54q2SLFBIIaoQiWhgeh527QOVbDBYQy0KutYBxEdp/RmJ3vIJL/u/PBAB/5gedsqPzRxIH3yTYoHX5U0bOL6XTmZVNaNZ9rnXn58IXiWORNudbwdA4DGZMOAPUtu1ze8sTUE/PsjNzVPNwhwJz8cQbt4tGC3luRUctkx26K+nZgn8GkCQV9AtByAQ==', + 'AQAAADri1djYjhPpolB6aRwD6ptKRz4EGNAYWba0TYfM/TgQxeoSTupAfJLIJdYEWAEdATyCz9VWW7lC1InwUUCq7vARCWClyAoBQ8LdMAi9bYFy4MrEj+urzTmUgmZL1r39AJOhT9H+SuGv1uBET1Hv49aWZReTo0NhK4U6Oh6y8tHou+P3LC155ZZHLRrmcyGDARnOhBs25CHMjrvkLwcLsJNnK7Y0QXO4/6YEVTBBcsN+F/BGLgtP5GaiPdtDXuYEAFhZW+a0pZIlUzaYZaiXFMQ6pJJbCsMJTK+khfWBSAFuVVkG2wIKTGiTqOkw+o8SAeooTBoO0NJJZcpP+jY++zRziX+X7fyixmBlcStbmVU4gwA1kG/4kvEsrIh+kEygAWvxw/2JZcIDZRRAkhHu+uZwSflSwFFW8omtI36t7YmYmOMpXxTHFNdJyh2mMS29AP7dzScfrKa4NObq/UN85PjIvBTR5otWCFrsT0gNSDnEiGO6cXFIHMexyPRTLYSpAVJra/z283B8DjejVN1qyQFRi9upU5M1vxVLJo5y48IDJM8q+ZKDvokwY2icPxewAJZ2OtyGW55weDMTousWVEJoJ9oBiaXCb/ZOROJ8+Oyv8cR4Xbc8AZV3Ec4tusAcAFYoE7YCOwkSj7Beq7B3p16bfFcso8nA3GgGXx16qTCmEeCCS4alWFPE73AHlWknAaetWLlMMZIw6SURpkwSoALXe8DkvelkROc/uFlo2wypEswzLVW/dYpbHrU0U92OAQ==', + ] + const expectedEval = expectedEvals[_config.keystore.keys.domains.latest - 1] + + it('Should respond with 200 on valid request', async () => { + const [req, thresholdPoprfClient] = await signatureRequest() + + const res = await request(app).post(SignerEndpoint.DOMAIN_SIGN).send(req) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: res.body.signature, + status: { + disabled: false, + counter: 1, + timer: res.body.status.timer, + now: res.body.status.now, + }, + }) + const evaluation = thresholdPoprfClient.unblindPartialResponse( + Buffer.from(res.body.signature, 'base64') + ) + expect(evaluation.toString('base64')).toEqual(expectedEval) + expect(res.get(KEY_VERSION_HEADER)).toEqual(_config.keystore.keys.domains.latest.toString()) + }) + + for (let i = 1; i <= 3; i++) { + it(`Should respond with 200 on valid request with key version header ${i}`, async () => { + const [req, thresholdPoprfClient] = await signatureRequest(undefined, undefined, i) + + const res = await request(app) + .post(SignerEndpoint.DOMAIN_SIGN) + .set(KEY_VERSION_HEADER, i.toString()) + .send(req) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: res.body.signature, + status: { + disabled: false, + counter: 1, + timer: res.body.status.timer, + now: res.body.status.now, + }, + }) + const evaluation = thresholdPoprfClient.unblindPartialResponse( + Buffer.from(res.body.signature, 'base64') + ) + expect(evaluation.toString('base64')).toEqual(expectedEvals[i - 1]) + expect(res.get(KEY_VERSION_HEADER)).toEqual(i.toString()) + }) + } + + it('Should respond with 200 on repeated valid requests with nonce updated', async () => { + const [req, thresholdPoprfClient] = await signatureRequest() + + const res1 = await request(app).post(SignerEndpoint.DOMAIN_SIGN).send(req) + + expect(res1.status).toBe(200) + expect(res1.body).toStrictEqual({ + success: true, + version: res1.body.version, + signature: res1.body.signature, + status: { + disabled: false, + counter: 1, + timer: res1.body.status.timer, + now: res1.body.status.now, + }, + }) + const eval1 = thresholdPoprfClient.unblindPartialResponse( + Buffer.from(res1.body.signature, 'base64') + ) + expect(eval1.toString('base64')).toEqual(expectedEval) + + // submit identical request with nonce set to 1 + req.options.nonce = defined(1) + // This is how + req.options.signature = noString + req.options.signature = defined( + await wallet.signTypedData(walletAddress, domainRestrictedSignatureRequestEIP712(req)) + ) + const res2 = await request(app).post(SignerEndpoint.DOMAIN_SIGN).send(req) + expect(res2.status).toBe(200) + expect(res2.body).toStrictEqual({ + success: true, + version: res2.body.version, + signature: res2.body.signature, + status: { + disabled: false, + counter: 2, + timer: res2.body.status.timer, + now: res2.body.status.now, + }, + }) + const eval2 = thresholdPoprfClient.unblindPartialResponse( + Buffer.from(res2.body.signature, 'base64') + ) + expect(eval2).toEqual(eval1) + }) + + it('Should respond with 200 if nonce > domainState', async () => { + const [req, thresholdPoprfClient] = await signatureRequest(undefined, 2) + const res = await request(app).post(SignerEndpoint.DOMAIN_SIGN).send(req) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: res.body.signature, + status: { + disabled: false, + counter: 1, // counter gets incremented, not set to nonce value + timer: res.body.status.timer, + now: res.body.status.now, + }, + }) + const evaluation = thresholdPoprfClient.unblindPartialResponse( + Buffer.from(res.body.signature, 'base64') + ) + expect(evaluation.toString('base64')).toEqual(expectedEval) + }) + + it('Should respond with 200 on extra request fields', async () => { + const [req, thresholdPoprfClient] = await signatureRequest() + // @ts-ignore Intentionally adding an extra field to the request type + req.options.extraField = noString + + const res = await request(app).post(SignerEndpoint.DOMAIN_SIGN).send(req) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: res.body.signature, + status: { + disabled: false, + counter: 1, + timer: res.body.status.timer, + now: res.body.status.now, + }, + }) + const evaluation = thresholdPoprfClient.unblindPartialResponse( + Buffer.from(res.body.signature, 'base64') + ) + expect(evaluation.toString('base64')).toEqual(expectedEval) + }) + + it('Should respond with 400 on missing request fields', async () => { + const [badRequest, _] = await signatureRequest() + // @ts-ignore Intentionally deleting required field + delete badRequest.domain.version + + const res = await request(app).post(SignerEndpoint.DOMAIN_SIGN).send(badRequest) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on unknown domain', async () => { + // Create a request with an invalid domain identifier. + const [unknownRequest, _] = await signatureRequest() + // @ts-ignore UnknownDomain is (intentionally) not a valid domain identifier. + unknownRequest.domain.name = 'UnknownDomain' + + const res = await request(app).post(SignerEndpoint.DOMAIN_SIGN).send(unknownRequest) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on bad encoding', async () => { + const [badRequest1, _] = await signatureRequest() + // @ts-ignore Intentionally not JSON + badRequest1.domain = 'Freddy' + + const res1 = await request(app).post(SignerEndpoint.DOMAIN_SIGN).send(badRequest1) + + expect(res1.status).toBe(400) + expect(res1.body).toStrictEqual({ + success: false, + version: res1.body.version, + error: WarningMessage.INVALID_INPUT, + }) + + const badRequest2 = '' + + const res2 = await request(app).post(SignerEndpoint.DOMAIN_SIGN).send(badRequest2) + + expect(res2.status).toBe(400) + expect(res2.body).toStrictEqual({ + success: false, + version: res2.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on invalid key version', async () => { + const [badRequest, _] = await signatureRequest() + + const res = await request(app) + .post(SignerEndpoint.DOMAIN_SIGN) + .set(KEY_VERSION_HEADER, 'a') + .send(badRequest) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_KEY_VERSION_REQUEST, + }) + }) + + it('Should respond with 401 on failed auth', async () => { + // Create a manipulated request, which will have a bad signature. + const [badRequest, _] = await signatureRequest() + badRequest.domain.salt = defined('badSalt') + + const res = await request(app).post(SignerEndpoint.DOMAIN_SIGN).send(badRequest) + + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + + it('Should respond 401 on invalid nonce', async () => { + // Request must be sent first since nonce check is >= 0 + const [req1, _] = await signatureRequest() + const res1 = await request(app).post(SignerEndpoint.DOMAIN_SIGN).send(req1) + expect(res1.status).toBe(200) + expect(res1.body).toStrictEqual({ + success: true, + version: res1.body.version, + signature: res1.body.signature, + status: { + disabled: false, + counter: 1, + timer: res1.body.status.timer, + now: res1.body.status.now, + }, + }) + const res2 = await request(app).post(SignerEndpoint.DOMAIN_SIGN).send(req1) + expect(res2.status).toBe(401) + + expect(res2.body).toStrictEqual({ + success: false, + version: res2.body.version, + error: WarningMessage.INVALID_NONCE, + status: { + disabled: false, + counter: 1, + timer: res1.body.status.timer, // Timer should be same as from first request + now: res2.body.status.now, + }, + }) + }) + + it('Should respond with 429 on out of quota', async () => { + const noQuotaDomain = authenticatedDomain([ + { delay: 0, resetTimer: noBool, batchSize: defined(0), repetitions: defined(0) }, + ]) + const [badRequest, _] = await signatureRequest(noQuotaDomain) + + const res = await request(app).post(SignerEndpoint.DOMAIN_SIGN).send(badRequest) + + expect(res.status).toBe(429) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.EXCEEDED_QUOTA, + status: { + disabled: false, + counter: 0, + timer: 0, + now: res.body.status.now, + }, + }) + }) + + it('Should respond with 429 on request too early', async () => { + // This domain won't accept requests until ~10 seconds after test execution + const noQuotaDomain = authenticatedDomain([ + { + delay: Math.floor(Date.now() / 1000) + 10, + resetTimer: noBool, + batchSize: defined(2), + repetitions: defined(1), + }, + ]) + const [badRequest, _] = await signatureRequest(noQuotaDomain) + + const res = await request(app).post(SignerEndpoint.DOMAIN_SIGN).send(badRequest) + + expect(res.status).toBe(429) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.EXCEEDED_QUOTA, + status: { + disabled: false, + counter: 0, + timer: 0, + now: res.body.status.now, + }, + }) + }) + + it('Should respond with 500 on unsupported key version', async () => { + const [req, _] = await signatureRequest(undefined, undefined, 4) + + const res = await request(app) + .post(SignerEndpoint.DOMAIN_SIGN) + .set(KEY_VERSION_HEADER, '4') + .send(req) + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_KEY_VERSION_REQUEST, + }) + }) + + it('Should respond with 503 on disabled api', async () => { + const configWithApiDisabled: typeof _config = JSON.parse(JSON.stringify(_config)) + configWithApiDisabled.api.domains.enabled = false + const appWithApiDisabled = startSigner(configWithApiDisabled, db, keyProvider) + + const [req, _] = await signatureRequest() + + const res = await request(appWithApiDisabled).post(SignerEndpoint.DOMAIN_SIGN).send(req) + + expect(res.status).toBe(503) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.API_UNAVAILABLE, + }) + }) + + describe('functionality in case of errors', () => { + it('Should respond with 500 on DB getDomainStateRecord query failure', async () => { + const [req, _] = await signatureRequest() + // Mocking getDomainStateRecord directly but requiring the real version of + // getDomainStateRecordOrEmpty does not easily work, + // which is why we mock the outer call here & use the countAndThrowDBError + // helper to get as close as possible to testing a real error. + const spy = jest + .spyOn( + jest.requireActual('../../src/common/database/wrappers/domain-state'), + 'getDomainStateRecordOrEmpty' + ) + .mockImplementationOnce(() => { + countAndThrowDBError( + new Error(), + rootLogger(_config.serviceName), + ErrorMessage.DATABASE_GET_FAILURE + ) + }) + const res = await request(app).post(SignerEndpoint.DOMAIN_SIGN).send(req) + spy.mockRestore() + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: ErrorMessage.DATABASE_GET_FAILURE, + }) + expect(await getDomainStateRecord(db, req.domain, rootLogger(_config.serviceName))).toBe( + null + ) + }) + + it('Should respond with 500 on DB updateDomainStateRecord failure', async () => { + const [req, _] = await signatureRequest() + // Same as above (re: getDomainStateRecord, but with insertDomainStateRecord) + // which is why we mock the outer call here & use the countAndThrowDBError + // helper to get as close as possible to testing a real error. + const spy = jest + .spyOn( + jest.requireActual('../../src/common/database/wrappers/domain-state'), + 'updateDomainStateRecord' + ) + .mockImplementationOnce(() => { + countAndThrowDBError( + new Error(), + rootLogger(_config.serviceName), + ErrorMessage.DATABASE_UPDATE_FAILURE + ) + }) + const res = await request(app).post(SignerEndpoint.DOMAIN_SIGN).send(req) + spy.mockRestore() + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: ErrorMessage.DATABASE_UPDATE_FAILURE, + }) + expect(await getDomainStateRecord(db, req.domain, rootLogger(_config.serviceName))).toBe( + null + ) + }) + + it('Should respond with 500 on signer timeout', async () => { + const [req, _] = await signatureRequest() + const testTimeoutMS = 0 + const delay = 200 + + const spy = jest + .spyOn( + jest.requireActual('../../src/common/database/wrappers/domain-state'), + 'getDomainStateRecordOrEmpty' + ) + .mockImplementationOnce(async () => { + await new Promise((resolve) => setTimeout(resolve, testTimeoutMS + delay)) + return createEmptyDomainStateRecord(req.domain) + }) + + const configWithShortTimeout = JSON.parse(JSON.stringify(_config)) + configWithShortTimeout.timeout = testTimeoutMS + const appWithShortTimeout = startSigner(configWithShortTimeout, db, keyProvider) + + const res = await request(appWithShortTimeout).post(SignerEndpoint.DOMAIN_SIGN).send(req) + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + error: ErrorMessage.TIMEOUT_FROM_SIGNER, + version: expectedVersion, + }) + spy.mockRestore() + // Allow time for non-killed processes to finish + await new Promise((resolve) => setTimeout(resolve, delay)) + // Check that DB state was not updated on timeout + expect(await getDomainStateRecord(db, req.domain, rootLogger(_config.serviceName))).toBe( + null + ) + }) + }) + }) +}) diff --git a/packages/phone-number-privacy/signer/test/integration/legacypnp.test.ts b/packages/phone-number-privacy/signer/test/integration/legacypnp.test.ts new file mode 100644 index 00000000000..03f0a3efd2f --- /dev/null +++ b/packages/phone-number-privacy/signer/test/integration/legacypnp.test.ts @@ -0,0 +1,1766 @@ +import { AttestationsStatus } from '@celo/base' +import { newKit, StableToken } from '@celo/contractkit' +import { + AuthenticationMethod, + ErrorMessage, + KEY_VERSION_HEADER, + PhoneNumberPrivacyRequest, + PnpQuotaResponseFailure, + PnpQuotaResponseSuccess, + rootLogger, + SignerEndpoint, + SignMessageResponseFailure, + SignMessageResponseSuccess, + TestUtils, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { + createMockOdisPayments, + getPnpSignRequest, +} from '@celo/phone-number-privacy-common/lib/test/utils' +import BigNumber from 'bignumber.js' +import { Knex } from 'knex' +import request from 'supertest' +import { initDatabase } from '../../src/common/database/database' +import { ACCOUNTS_TABLE } from '../../src/common/database/models/account' +import { REQUESTS_TABLE } from '../../src/common/database/models/request' +import { countAndThrowDBError } from '../../src/common/database/utils' +import { + getPerformedQueryCount, + incrementQueryCount, +} from '../../src/common/database/wrappers/account' +import { getRequestExists } from '../../src/common/database/wrappers/request' +import { initKeyProvider } from '../../src/common/key-management/key-provider' +import { KeyProvider } from '../../src/common/key-management/key-provider-base' +import { config, getVersion, SupportedDatabase, SupportedKeystore } from '../../src/config' +import { startSigner } from '../../src/server' + +const { + ContractRetrieval, + createMockContractKit, + createMockAccounts, + createMockToken, + createMockWeb3, + getLegacyPnpQuotaRequest, + getPnpRequestAuthorization, + createMockAttestation, + getLegacyPnpSignRequest, +} = TestUtils.Utils +const { + IDENTIFIER, + PRIVATE_KEY1, + ACCOUNT_ADDRESS1, + mockAccount, + BLINDED_PHONE_NUMBER, + DEK_PRIVATE_KEY, + DEK_PUBLIC_KEY, +} = TestUtils.Values + +jest.setTimeout(20000) + +const testBlockNumber = 1000000 + +const mockBalanceOfCUSD = jest.fn() +const mockBalanceOfCEUR = jest.fn() +const mockBalanceOfCELO = jest.fn() +const mockGetVerifiedStatus = jest.fn() +const mockGetWalletAddress = jest.fn() +const mockGetDataEncryptionKey = jest.fn() +const mockOdisPaymentsTotalPaidCUSD = jest.fn() + +const mockContractKit = createMockContractKit( + { + // getWalletAddress stays constant across all old query-quota.test.ts unit tests + [ContractRetrieval.getAccounts]: createMockAccounts( + mockGetWalletAddress, + mockGetDataEncryptionKey + ), + [ContractRetrieval.getStableToken]: jest.fn(), + [ContractRetrieval.getGoldToken]: createMockToken(mockBalanceOfCELO), + [ContractRetrieval.getAttestations]: createMockAttestation(mockGetVerifiedStatus), + [ContractRetrieval.getOdisPayments]: createMockOdisPayments(mockOdisPaymentsTotalPaidCUSD), + }, + createMockWeb3(5, testBlockNumber) +) + +// Necessary for distinguishing between mocked stable tokens +mockContractKit.contracts[ContractRetrieval.getStableToken] = jest.fn( + (stableToken: StableToken) => { + switch (stableToken) { + case StableToken.cUSD: + return createMockToken(mockBalanceOfCUSD) + case StableToken.cEUR: + return createMockToken(mockBalanceOfCEUR) + default: + return createMockToken(jest.fn().mockReturnValue(new BigNumber(0))) + } + } +) + +jest.mock('@celo/contractkit', () => ({ + ...jest.requireActual('@celo/contractkit'), + newKit: jest.fn().mockImplementation(() => mockContractKit), +})) + +// Indexes correspond to keyVersion - 1 +const expectedSignatures: string[] = [ + 'MAAAAAAAAACEVdw1ULDwAiTcZuPnZxHHh38PNa+/g997JgV10QnEq9yeuLxbM9l7vk0EAicV7IAAAAAA', + 'MAAAAAAAAAAmUJY0s9p7fMfs7GIoSiGJoObAN8ZpA7kRqeC9j/Q23TBrG3Jtxc8xWibhNVZhbYEAAAAA', + 'MAAAAAAAAAC4aBbzhHvt6l/b+8F7cILmWxZZ5Q7S6R4RZ/IgZR7Pfb9B1Wg9fsDybgxVTSv5BYEAAAAA', +] + +describe('legacyPNP', () => { + let keyProvider: KeyProvider + let app: any + let db: Knex + + const expectedVersion = getVersion() + + // create deep copy + const _config: typeof config = JSON.parse(JSON.stringify(config)) + _config.db.type = SupportedDatabase.Sqlite + _config.keystore.type = SupportedKeystore.MOCK_SECRET_MANAGER + _config.api.legacyPhoneNumberPrivacy.enabled = true + + const expectedSignature = expectedSignatures[_config.keystore.keys.phoneNumberPrivacy.latest - 1] + + beforeAll(async () => { + keyProvider = await initKeyProvider(_config) + }) + + beforeEach(async () => { + // Create a new in-memory database for each test. + db = await initDatabase(_config) + app = startSigner(_config, db, keyProvider, newKit('dummyKit')) + }) + + afterEach(async () => { + // Close and destroy the in-memory database. + // Note: If tests start to be too slow, this could be replaced with more complicated logic to + // reset the database state without destroying and recreating it for each test. + await db?.destroy() + }) + + describe(`${SignerEndpoint.STATUS}`, () => { + it('Should return 200 and correct version', async () => { + const res = await request(app).get(SignerEndpoint.STATUS) + expect(res.status).toBe(200) + expect(res.body.version).toBe(expectedVersion) + }) + }) + + const zeroBalance = new BigNumber(0) + const twentyCents = new BigNumber(200000000000000000) + + type legacyPnpQuotaCalculationTestCase = { + it: string + account: string + performedQueryCount: number + transactionCount: number + balanceCUSD: BigNumber + balanceCEUR: BigNumber + balanceCELO: BigNumber + isVerified: boolean + identifier: string | undefined + expectedPerformedQueryCount: number + expectedTotalQuota: number + } // To be re-used against both the signature and quota endpoints + const quotaCalculationTestCases: legacyPnpQuotaCalculationTestCase[] = [ + { + it: 'should calculate correct quota for verified account', + account: ACCOUNT_ADDRESS1, + performedQueryCount: 2, + transactionCount: 5, + balanceCUSD: zeroBalance, + balanceCEUR: zeroBalance, + balanceCELO: zeroBalance, + isVerified: true, + identifier: IDENTIFIER, + expectedPerformedQueryCount: 2, + expectedTotalQuota: 60, + }, + { + it: 'should calculate correct quota for unverified account with no transactions or balance', + account: ACCOUNT_ADDRESS1, + performedQueryCount: 0, + transactionCount: 0, + balanceCUSD: zeroBalance, + balanceCEUR: zeroBalance, + balanceCELO: zeroBalance, + isVerified: false, + identifier: IDENTIFIER, + expectedPerformedQueryCount: 0, + expectedTotalQuota: 0, + }, + { + it: 'should calculate correct quota for unverified account with balance but no transactions', + account: ACCOUNT_ADDRESS1, + performedQueryCount: 1, + transactionCount: 0, + balanceCUSD: twentyCents, + balanceCEUR: twentyCents, + balanceCELO: twentyCents, + isVerified: false, + identifier: IDENTIFIER, + expectedPerformedQueryCount: 1, + expectedTotalQuota: 10, + }, + { + it: 'should calculate correct quota for verified account with many txs and balance', + account: ACCOUNT_ADDRESS1, + performedQueryCount: 10, + transactionCount: 100, + balanceCUSD: twentyCents, + balanceCEUR: twentyCents, + balanceCELO: twentyCents, + isVerified: true, + identifier: IDENTIFIER, + expectedPerformedQueryCount: 10, + expectedTotalQuota: 440, + }, + { + it: 'should calculate correct quota for unverified account with many txs and balance', + account: ACCOUNT_ADDRESS1, + performedQueryCount: 0, + transactionCount: 100, + balanceCUSD: twentyCents, + balanceCEUR: twentyCents, + balanceCELO: twentyCents, + isVerified: false, + identifier: IDENTIFIER, + expectedPerformedQueryCount: 0, + expectedTotalQuota: 410, + }, + { + it: 'should calculate correct quota for unverified account without any balance (with txs)', + account: ACCOUNT_ADDRESS1, + performedQueryCount: 0, + transactionCount: 100, + balanceCUSD: zeroBalance, + balanceCEUR: zeroBalance, + balanceCELO: zeroBalance, + isVerified: false, + identifier: IDENTIFIER, + expectedPerformedQueryCount: 0, + expectedTotalQuota: 0, + }, + { + it: 'should calculate correct quota for unverified account with only cUSD balance (no txs)', + account: ACCOUNT_ADDRESS1, + performedQueryCount: 1, + transactionCount: 0, + balanceCUSD: twentyCents, + balanceCEUR: zeroBalance, + balanceCELO: zeroBalance, + isVerified: false, + identifier: IDENTIFIER, + expectedPerformedQueryCount: 1, + expectedTotalQuota: 10, + }, + { + it: 'should calculate correct quota for unverified account with only cEUR balance (no txs)', + account: ACCOUNT_ADDRESS1, + performedQueryCount: 1, + transactionCount: 0, + balanceCUSD: zeroBalance, + balanceCEUR: twentyCents, + balanceCELO: zeroBalance, + isVerified: false, + identifier: IDENTIFIER, + expectedPerformedQueryCount: 1, + expectedTotalQuota: 10, + }, + { + it: 'should calculate correct quota for unverified account with only CELO balance (no txs)', + account: ACCOUNT_ADDRESS1, + performedQueryCount: 1, + transactionCount: 0, + balanceCUSD: zeroBalance, + balanceCEUR: zeroBalance, + balanceCELO: twentyCents, + isVerified: false, + identifier: IDENTIFIER, + expectedPerformedQueryCount: 1, + expectedTotalQuota: 10, + }, + { + it: + 'should calculate correct quota for account with min balance when no phone number hash is provided', + account: ACCOUNT_ADDRESS1, + performedQueryCount: 1, + transactionCount: 0, + balanceCUSD: twentyCents, + balanceCEUR: twentyCents, + balanceCELO: twentyCents, + isVerified: false, + identifier: IDENTIFIER, + expectedPerformedQueryCount: 1, + expectedTotalQuota: 10, + }, + ] + + const prepMocks = async ( + account: string, + performedQueryCount: number, + transactionCount: number, + isVerified: boolean, + balanceCUSD: BigNumber, + balanceCEUR: BigNumber, + balanceCELO: BigNumber, + dekPubKey: string = DEK_PUBLIC_KEY, + walletAddress: string = mockAccount + ) => { + ;[ + mockContractKit.connection.getTransactionCount, + mockGetVerifiedStatus, + mockBalanceOfCUSD, + mockBalanceOfCEUR, + mockBalanceOfCELO, + mockGetWalletAddress, + mockGetDataEncryptionKey, + ].forEach((mockFn) => mockFn.mockReset()) + + await db.transaction(async (trx) => { + for (let i = 0; i < performedQueryCount; i++) { + await incrementQueryCount( + db, + ACCOUNTS_TABLE.LEGACY, + account, + rootLogger(_config.serviceName), + trx + ) + } + }) + + mockContractKit.connection.getTransactionCount.mockReturnValue(transactionCount) + mockGetVerifiedStatus.mockReturnValue( + // only the isVerified value below matters + { isVerified, completed: 1, total: 1, numAttestationsRemaining: 1 } + ) + mockBalanceOfCUSD.mockReturnValue(balanceCUSD) + mockBalanceOfCEUR.mockReturnValue(balanceCEUR) + mockBalanceOfCELO.mockReturnValue(balanceCELO) + mockGetWalletAddress.mockReturnValue(walletAddress) + mockGetDataEncryptionKey.mockReturnValue(dekPubKey) + } + + const sendRequest = async ( + req: PhoneNumberPrivacyRequest, + authorization: string, + endpoint: SignerEndpoint, + keyVersionHeader?: string, + signerApp: any = app + ) => { + const _req = request(signerApp).post(endpoint).set('Authorization', authorization) + + if (keyVersionHeader !== undefined) { + _req.set(KEY_VERSION_HEADER, keyVersionHeader) + } + + return _req.send(req) + } + + describe(`${SignerEndpoint.LEGACY_PNP_QUOTA}`, () => { + describe('quota calculation logic', () => { + const runLegacyQuotaTestCase = async (testCase: legacyPnpQuotaCalculationTestCase) => { + await prepMocks( + testCase.account, + testCase.performedQueryCount, + testCase.transactionCount, + testCase.isVerified, + testCase.balanceCUSD, + testCase.balanceCEUR, + testCase.balanceCELO + ) + + const req = getLegacyPnpQuotaRequest( + testCase.account, + AuthenticationMethod.WALLET_KEY, + testCase.identifier + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_QUOTA) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + performedQueryCount: testCase.expectedPerformedQueryCount, + totalQuota: testCase.expectedTotalQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + } + + quotaCalculationTestCases.forEach((testCase) => { + it(testCase.it, async () => { + await runLegacyQuotaTestCase(testCase) + }) + }) + }) + + describe('endpoint functionality', () => { + // Use values from 'unverified account with no transactions' logic test case + const performedQueryCount = 1 + const expectedQuota = 10 + + beforeEach(async () => { + await prepMocks( + ACCOUNT_ADDRESS1, + performedQueryCount, + 0, + false, + twentyCents, + twentyCents, + twentyCents + ) + }) + + it('Should respond with 200 on valid request', async () => { + const req = getLegacyPnpQuotaRequest(ACCOUNT_ADDRESS1, IDENTIFIER) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + + const res = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_QUOTA) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + performedQueryCount: performedQueryCount, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + + it('Should respond with 200 on valid request when authenticated with DEK', async () => { + const req = getLegacyPnpQuotaRequest( + ACCOUNT_ADDRESS1, + AuthenticationMethod.ENCRYPTION_KEY, + IDENTIFIER + ) + const authorization = getPnpRequestAuthorization(req, DEK_PRIVATE_KEY) + + const res = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_QUOTA) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + performedQueryCount: performedQueryCount, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + + it('Should respond with 200 on repeated valid requests', async () => { + const req = getLegacyPnpQuotaRequest(ACCOUNT_ADDRESS1, IDENTIFIER) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + + const res1 = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_QUOTA) + expect(res1.status).toBe(200) + expect(res1.body).toStrictEqual({ + success: true, + version: res1.body.version, + performedQueryCount: performedQueryCount, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + const res2 = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_QUOTA) + expect(res2.status).toBe(200) + expect(res2.body).toStrictEqual(res1.body) + }) + + it('Should respond with 200 on extra request fields', async () => { + const req = getLegacyPnpQuotaRequest(ACCOUNT_ADDRESS1, IDENTIFIER) + // @ts-ignore Intentionally adding an extra field to the request type + req.extraField = 'dummyString' + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_QUOTA) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + performedQueryCount: performedQueryCount, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + + it('Should respond with 200 if performedQueryCount is greater than totalQuota', async () => { + const expectedRemainingQuota = expectedQuota - performedQueryCount + await db.transaction(async (trx) => { + for (let i = 0; i <= expectedRemainingQuota; i++) { + await incrementQueryCount( + db, + ACCOUNTS_TABLE.LEGACY, + ACCOUNT_ADDRESS1, + rootLogger(_config.serviceName), + trx + ) + } + }) + const req = getLegacyPnpQuotaRequest(ACCOUNT_ADDRESS1) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_QUOTA) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + performedQueryCount: expectedQuota + 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + + it('Should respond with 400 on missing request fields', async () => { + const badRequest = getLegacyPnpQuotaRequest(ACCOUNT_ADDRESS1, IDENTIFIER) + // @ts-ignore Intentionally deleting required field + delete badRequest.account + const authorization = getPnpRequestAuthorization(badRequest, PRIVATE_KEY1) + const res = await sendRequest(badRequest, authorization, SignerEndpoint.LEGACY_PNP_QUOTA) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 401 on failed WALLET_KEY auth', async () => { + const badRequest = getLegacyPnpQuotaRequest( + ACCOUNT_ADDRESS1, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const differentPk = '0x00000000000000000000000000000000000000000000000000000000ddddbbbb' + const authorization = getPnpRequestAuthorization(badRequest, differentPk) + const res = await sendRequest(badRequest, authorization, SignerEndpoint.LEGACY_PNP_QUOTA) + + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + + it('Should respond with 401 on failed DEK auth', async () => { + const badRequest = getLegacyPnpQuotaRequest( + ACCOUNT_ADDRESS1, + AuthenticationMethod.ENCRYPTION_KEY, + IDENTIFIER + ) + const differentPk = '0x00000000000000000000000000000000000000000000000000000000ddddbbbb' + const authorization = getPnpRequestAuthorization(badRequest, differentPk) + const res = await sendRequest(badRequest, authorization, SignerEndpoint.LEGACY_PNP_QUOTA) + + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + + it('Should respond with 503 on disabled api', async () => { + const configWithApiDisabled: typeof _config = JSON.parse(JSON.stringify(_config)) + configWithApiDisabled.api.legacyPhoneNumberPrivacy.enabled = false + const appWithApiDisabled = startSigner(configWithApiDisabled, db, keyProvider) + const req = getLegacyPnpQuotaRequest(ACCOUNT_ADDRESS1, IDENTIFIER) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest( + req, + authorization, + SignerEndpoint.LEGACY_PNP_QUOTA, + undefined, + appWithApiDisabled + ) + expect.assertions(2) + expect(res.status).toBe(503) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.API_UNAVAILABLE, + }) + }) + + describe('functionality in case of errors', () => { + it('Should respond with 200 on failure to fetch DEK when shouldFailOpen is true', async () => { + const configWithFailOpenEnabled: typeof _config = JSON.parse(JSON.stringify(_config)) + configWithFailOpenEnabled.api.legacyPhoneNumberPrivacy.shouldFailOpen = true + const appWithFailOpenEnabled = startSigner(configWithFailOpenEnabled, db, keyProvider) + mockGetDataEncryptionKey.mockImplementation(() => { + throw new Error() + }) + + const req = getLegacyPnpQuotaRequest( + ACCOUNT_ADDRESS1, + AuthenticationMethod.ENCRYPTION_KEY, + IDENTIFIER + ) + + // NOT the dek private key, so authentication would fail if getDataEncryptionKey succeeded + const differentPk = '0x00000000000000000000000000000000000000000000000000000000ddddbbbb' + const authorization = getPnpRequestAuthorization(req, differentPk) + const res = await sendRequest( + req, + authorization, + SignerEndpoint.LEGACY_PNP_QUOTA, + '1', + appWithFailOpenEnabled + ) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + performedQueryCount: performedQueryCount, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [ErrorMessage.FAILURE_TO_GET_DEK, ErrorMessage.FAILING_OPEN], + }) + }) + + it('Should respond with 500 on DB performedQueryCount query failure', async () => { + const spy = jest + .spyOn( + jest.requireActual('../../src/common/database/wrappers/account'), + 'getPerformedQueryCount' + ) + .mockRejectedValueOnce(new Error()) + + const req = getLegacyPnpQuotaRequest(ACCOUNT_ADDRESS1, IDENTIFIER) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_QUOTA) + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: ErrorMessage.FAILURE_TO_GET_PERFORMED_QUERY_COUNT, + }) + + spy.mockRestore() + }) + + it('Should respond with 500 on blockchain totalQuota query failure', async () => { + mockContractKit.connection.getTransactionCount.mockRejectedValue(new Error()) + + const req = getLegacyPnpQuotaRequest(ACCOUNT_ADDRESS1, IDENTIFIER) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_QUOTA) + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: ErrorMessage.FAILURE_TO_GET_TOTAL_QUOTA, + }) + }) + + it('Should respond with 500 on signer timeout', async () => { + const testTimeoutMS = 0 + const delay = 100 + const spy = jest + .spyOn( + jest.requireActual('../../src/common/database/wrappers/account'), + 'getPerformedQueryCount' + ) + .mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, testTimeoutMS + delay)) + return expectedQuota + }) + + const configWithShortTimeout = JSON.parse(JSON.stringify(_config)) + configWithShortTimeout.timeout = testTimeoutMS + const appWithShortTimeout = startSigner( + configWithShortTimeout, + db, + keyProvider, + newKit('dummyKit') + ) + const req = getLegacyPnpQuotaRequest(ACCOUNT_ADDRESS1) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest( + req, + authorization, + SignerEndpoint.LEGACY_PNP_QUOTA, + undefined, + appWithShortTimeout + ) + // Ensure that this is restored before test can fail on assertions + // to prevent failures in other tests + spy.mockRestore() + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + error: ErrorMessage.TIMEOUT_FROM_SIGNER, + version: expectedVersion, + }) + // Allow time for non-killed processes to finish + await new Promise((resolve) => setTimeout(resolve, delay)) + }) + }) + }) + }) + + describe(`${SignerEndpoint.LEGACY_PNP_SIGN}`, () => { + describe('quota calculation logic', () => { + const runLegacyPnpSignQuotaTestCase = async (testCase: legacyPnpQuotaCalculationTestCase) => { + await prepMocks( + testCase.account, + testCase.performedQueryCount, + testCase.transactionCount, + testCase.isVerified, + testCase.balanceCUSD, + testCase.balanceCEUR, + testCase.balanceCELO + ) + + const req = getLegacyPnpSignRequest( + testCase.account, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + testCase.identifier + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_SIGN) + + const { expectedPerformedQueryCount, expectedTotalQuota } = testCase + const shouldSucceed = expectedPerformedQueryCount < expectedTotalQuota + + if (shouldSucceed) { + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: expectedPerformedQueryCount + 1, // incremented for signature request + totalQuota: expectedTotalQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + } else { + expect(res.status).toBe(403) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + performedQueryCount: expectedPerformedQueryCount, + totalQuota: expectedTotalQuota, + blockNumber: testBlockNumber, + error: WarningMessage.EXCEEDED_QUOTA, + }) + } + } + + quotaCalculationTestCases.forEach((testCase) => { + it(testCase.it, async () => { + await runLegacyPnpSignQuotaTestCase(testCase) + }) + }) + }) + + describe('endpoint functionality', () => { + // Use values from 'unverified account with balance but no transactions' logic test case + const performedQueryCount = 1 + const expectedQuota = 10 + + beforeEach(async () => { + await prepMocks( + ACCOUNT_ADDRESS1, + performedQueryCount, + 0, + false, + twentyCents, + twentyCents, + twentyCents + ) + }) + + it('Should respond with 200 on valid request', async () => { + const req = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_SIGN) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: expectedSignature, + performedQueryCount: performedQueryCount + 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + expect(res.get(KEY_VERSION_HEADER)).toEqual( + _config.keystore.keys.phoneNumberPrivacy.latest.toString() + ) + }) + + it('Should respond with 200 on valid request when authenticated with DEK', async () => { + const req = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.ENCRYPTION_KEY, + IDENTIFIER + ) + const authorization = getPnpRequestAuthorization(req, DEK_PRIVATE_KEY) + const res = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_SIGN) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: expectedSignature, + performedQueryCount: performedQueryCount + 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + + for (let i = 1; i <= 3; i++) { + it(`Should respond with 200 on valid request with key version header ${i}`, async () => { + const req = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest( + req, + authorization, + SignerEndpoint.LEGACY_PNP_SIGN, + i.toString() + ) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: expectedSignatures[i - 1], + performedQueryCount: performedQueryCount + 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + expect(res.get(KEY_VERSION_HEADER)).toEqual(i.toString()) + }) + } + + it('Should respond with 200 and warning on repeated valid requests', async () => { + const req = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res1 = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_SIGN) + expect(res1.status).toBe(200) + expect(res1.body).toStrictEqual({ + success: true, + version: res1.body.version, + signature: expectedSignature, + performedQueryCount: performedQueryCount + 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + const res2 = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_SIGN) + expect(res2.status).toBe(200) + res1.body.warnings.push(WarningMessage.DUPLICATE_REQUEST_TO_GET_PARTIAL_SIG) + expect(res2.body).toStrictEqual(res1.body) + }) + + it('Should respond with 200 on extra request fields', async () => { + const req = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + // @ts-ignore Intentionally adding an extra field to the request type + req.extraField = 'dummyString' + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_SIGN) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: expectedSignature, + performedQueryCount: performedQueryCount + 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + + it('Should respond with 400 on missing request fields', async () => { + const badRequest = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + // @ts-ignore Intentionally deleting required field + delete badRequest.account + const authorization = getPnpRequestAuthorization(badRequest, PRIVATE_KEY1) + const res = await sendRequest(badRequest, authorization, SignerEndpoint.LEGACY_PNP_SIGN) + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on invalid key version', async () => { + const badRequest = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const authorization = getPnpRequestAuthorization(badRequest, PRIVATE_KEY1) + const res = await sendRequest( + badRequest, + authorization, + SignerEndpoint.LEGACY_PNP_SIGN, + 'a' + ) + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_KEY_VERSION_REQUEST, + }) + }) + + it('Should respond with 400 on invalid identifier', async () => { + const badRequest = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + '+1234567890' + ) + const authorization = getPnpRequestAuthorization(badRequest, PRIVATE_KEY1) + const res = await sendRequest(badRequest, authorization, SignerEndpoint.LEGACY_PNP_SIGN) + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on invalid blinded message', async () => { + const badRequest = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + '+1234567890', + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const authorization = getPnpRequestAuthorization(badRequest, PRIVATE_KEY1) + const res = await sendRequest(badRequest, authorization, SignerEndpoint.LEGACY_PNP_SIGN) + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on invalid address', async () => { + const badRequest = getLegacyPnpSignRequest( + '0xnotanaddress', + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const authorization = getPnpRequestAuthorization(badRequest, PRIVATE_KEY1) + const res = await sendRequest(badRequest, authorization, SignerEndpoint.LEGACY_PNP_SIGN) + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 401 on failed WALLET_KEY auth', async () => { + const badRequest = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const differentPk = '0x00000000000000000000000000000000000000000000000000000000ddddbbbb' + const authorization = getPnpRequestAuthorization(badRequest, differentPk) + const res = await sendRequest(badRequest, authorization, SignerEndpoint.LEGACY_PNP_SIGN) + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + + it('Should respond with 401 on failed DEK auth', async () => { + const badRequest = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.ENCRYPTION_KEY, + IDENTIFIER + ) + const differentPk = '0x00000000000000000000000000000000000000000000000000000000ddddbbbb' + const authorization = getPnpRequestAuthorization(badRequest, differentPk) + const res = await sendRequest(badRequest, authorization, SignerEndpoint.LEGACY_PNP_SIGN) + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + + it('Should respond with 403 on out of quota', async () => { + // deplete user's quota + const remainingQuota = expectedQuota - performedQueryCount + await db.transaction(async (trx) => { + for (let i = 0; i < remainingQuota; i++) { + await incrementQueryCount( + db, + ACCOUNTS_TABLE.LEGACY, + ACCOUNT_ADDRESS1, + rootLogger(_config.serviceName), + trx + ) + } + }) + const req = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_SIGN) + expect(res.status).toBe(403) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + performedQueryCount: expectedQuota, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + error: WarningMessage.EXCEEDED_QUOTA, + }) + }) + + it('Should respond with 403 if totalQuota and performedQueryCount are zero', async () => { + await prepMocks(ACCOUNT_ADDRESS1, 0, 0, false, zeroBalance, zeroBalance, zeroBalance) + + const spy = jest // for convenience so we don't have to refactor or reset the db just for this test + .spyOn( + jest.requireActual('../../src/common/database/wrappers/account'), + 'getPerformedQueryCount' + ) + .mockResolvedValueOnce(0) + + const req = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_SIGN) + expect(res.status).toBe(403) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + performedQueryCount: 0, + totalQuota: 0, + blockNumber: testBlockNumber, + error: WarningMessage.EXCEEDED_QUOTA, + }) + + spy.mockRestore() + }) + + it('Should respond with 403 if performedQueryCount is greater than totalQuota', async () => { + const expectedRemainingQuota = expectedQuota - performedQueryCount + await db.transaction(async (trx) => { + for (let i = 0; i <= expectedRemainingQuota; i++) { + await incrementQueryCount( + db, + ACCOUNTS_TABLE.LEGACY, + ACCOUNT_ADDRESS1, + rootLogger(_config.serviceName), + trx + ) + } + }) + + // It is possible to reach this state due to our fail-open logic + + const req = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_SIGN) + expect(res.status).toBe(403) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + performedQueryCount: expectedQuota + 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + error: WarningMessage.EXCEEDED_QUOTA, + }) + }) + + it('Should respond with 500 on unsupported key version', async () => { + const badRequest = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(badRequest, PRIVATE_KEY1) + const res = await sendRequest( + badRequest, + authorization, + SignerEndpoint.LEGACY_PNP_SIGN, + '4' + ) + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + performedQueryCount: performedQueryCount, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + error: ErrorMessage.SIGNATURE_COMPUTATION_FAILURE, + }) + }) + + it('Should respond with 503 on disabled api', async () => { + const configWithApiDisabled: typeof _config = JSON.parse(JSON.stringify(_config)) + configWithApiDisabled.api.legacyPhoneNumberPrivacy.enabled = false + const appWithApiDisabled = startSigner(configWithApiDisabled, db, keyProvider) + + const req = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest( + req, + authorization, + SignerEndpoint.LEGACY_PNP_SIGN, + undefined, + appWithApiDisabled + ) + expect(res.status).toBe(503) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.API_UNAVAILABLE, + }) + }) + + describe('interactions between legacy and new endpoints', () => { + const configWithPNPEnabled: typeof _config = JSON.parse(JSON.stringify(_config)) + configWithPNPEnabled.api.phoneNumberPrivacy.enabled = true + let appWithPNPEnabled: any + + beforeEach(() => { + appWithPNPEnabled = startSigner(configWithPNPEnabled, db, keyProvider) + }) + + // Keep both of these cases with the legacy test suite + // since once this endpoint is deprecated, these tests will no longer be needed + it('Should not be affected by requests and queries from the new endpoint', async () => { + mockOdisPaymentsTotalPaidCUSD.mockReturnValue(new BigNumber(1e18)) + const expectedQuotaOnChain = 1000 + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest( + req, + authorization, + SignerEndpoint.PNP_SIGN, + undefined, + appWithPNPEnabled + ) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: expectedQuotaOnChain, + blockNumber: testBlockNumber, + warnings: [], + }) + expect(res.get(KEY_VERSION_HEADER)).toEqual( + _config.keystore.keys.phoneNumberPrivacy.latest.toString() + ) + const legacyReq = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const legacyAuthorization = getPnpRequestAuthorization(legacyReq, PRIVATE_KEY1) + const legacyRes = await sendRequest( + legacyReq, + legacyAuthorization, + SignerEndpoint.LEGACY_PNP_SIGN, + undefined, + appWithPNPEnabled + ) + expect(legacyRes.status).toBe(200) + expect(legacyRes.body).toStrictEqual({ + success: true, + version: legacyRes.body.version, + signature: expectedSignature, + performedQueryCount: performedQueryCount + 1, + totalQuota: legacyRes.body.totalQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + expect(legacyRes.get(KEY_VERSION_HEADER)).toEqual( + _config.keystore.keys.phoneNumberPrivacy.latest.toString() + ) + }) + + it('Should not affect the requests and queries to the new endpoint', async () => { + const legacyReq = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const legacyAuthorization = getPnpRequestAuthorization(legacyReq, PRIVATE_KEY1) + const legacyRes = await sendRequest( + legacyReq, + legacyAuthorization, + SignerEndpoint.LEGACY_PNP_SIGN, + undefined, + appWithPNPEnabled + ) + expect(legacyRes.status).toBe(200) + expect(legacyRes.body).toStrictEqual({ + success: true, + version: legacyRes.body.version, + signature: expectedSignature, + performedQueryCount: performedQueryCount + 1, + totalQuota: legacyRes.body.totalQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + expect(legacyRes.get(KEY_VERSION_HEADER)).toEqual( + _config.keystore.keys.phoneNumberPrivacy.latest.toString() + ) + mockOdisPaymentsTotalPaidCUSD.mockReturnValue(new BigNumber(1e18)) + const expectedQuotaOnChain = 1000 + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest( + req, + authorization, + SignerEndpoint.PNP_SIGN, + undefined, + appWithPNPEnabled + ) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: expectedSignature, + performedQueryCount: 1, + totalQuota: expectedQuotaOnChain, + blockNumber: testBlockNumber, + warnings: [], + }) + expect(res.get(KEY_VERSION_HEADER)).toEqual( + _config.keystore.keys.phoneNumberPrivacy.latest.toString() + ) + }) + }) + + describe('functionality in case of errors', () => { + it('Should return 500 on DB performedQueryCount query failure', async () => { + // deplete user's quota + const remainingQuota = expectedQuota - performedQueryCount + await db.transaction(async (trx) => { + for (let i = 0; i < remainingQuota; i++) { + await incrementQueryCount( + db, + ACCOUNTS_TABLE.LEGACY, + ACCOUNT_ADDRESS1, + rootLogger(_config.serviceName), + trx + ) + } + }) + // sanity check + expect( + await getPerformedQueryCount( + db, + ACCOUNTS_TABLE.LEGACY, + ACCOUNT_ADDRESS1, + rootLogger(_config.serviceName) + ) + ).toBe(expectedQuota) + + const spy = jest + .spyOn( + jest.requireActual('../../src/common/database/wrappers/account'), + 'getPerformedQueryCount' + ) + .mockRejectedValueOnce(new Error()) + + const req = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_SIGN) + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + performedQueryCount: -1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + error: ErrorMessage.DATABASE_GET_FAILURE, + }) + + spy.mockRestore() + }) + + it('Should return 200 w/ warning on blockchain totalQuota query failure when shouldFailOpen is true', async () => { + const configWithFailOpenEnabled: typeof _config = JSON.parse(JSON.stringify(_config)) + configWithFailOpenEnabled.api.legacyPhoneNumberPrivacy.shouldFailOpen = true + const appWithFailOpenEnabled = startSigner(configWithFailOpenEnabled, db, keyProvider) + + // deplete user's quota + const remainingQuota = expectedQuota - performedQueryCount + await db.transaction(async (trx) => { + for (let i = 0; i < remainingQuota; i++) { + await incrementQueryCount( + db, + ACCOUNTS_TABLE.LEGACY, + ACCOUNT_ADDRESS1, + rootLogger(_config.serviceName), + trx + ) + } + }) + // sanity check + expect( + await getPerformedQueryCount( + db, + ACCOUNTS_TABLE.LEGACY, + ACCOUNT_ADDRESS1, + rootLogger(_config.serviceName) + ) + ).toBe(expectedQuota) + + mockContractKit.connection.getTransactionCount.mockRejectedValue(new Error()) + + const req = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest( + req, + authorization, + SignerEndpoint.LEGACY_PNP_SIGN, + '1', + appWithFailOpenEnabled + ) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: expectedSignature, + performedQueryCount: expectedQuota + 1, // bc we depleted the user's quota above + totalQuota: Number.MAX_SAFE_INTEGER, + blockNumber: testBlockNumber, + warnings: [ErrorMessage.FAILURE_TO_GET_TOTAL_QUOTA, ErrorMessage.FULL_NODE_ERROR], + }) + + // check DB state: performedQueryCount was incremented and request was stored + expect( + await getPerformedQueryCount( + db, + ACCOUNTS_TABLE.LEGACY, + ACCOUNT_ADDRESS1, + rootLogger(_config.serviceName) + ) + ).toBe(expectedQuota + 1) + expect( + await getRequestExists( + db, + REQUESTS_TABLE.LEGACY, + req.account, + req.blindedQueryPhoneNumber, + rootLogger(_config.serviceName) + ) + ).toBe(true) + }) + + it('Should return 500 on blockchain totalQuota query failure when shouldFailOpen is false', async () => { + mockContractKit.connection.getTransactionCount.mockRejectedValue(new Error()) + + const configWithFailOpenDisabled: typeof _config = JSON.parse(JSON.stringify(_config)) + configWithFailOpenDisabled.api.legacyPhoneNumberPrivacy.shouldFailOpen = false + const appWithFailOpenDisabled = startSigner(configWithFailOpenDisabled, db, keyProvider) + + const req = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest( + req, + authorization, + SignerEndpoint.LEGACY_PNP_SIGN, + '1', + appWithFailOpenDisabled + ) + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + performedQueryCount: performedQueryCount, + totalQuota: -1, + blockNumber: testBlockNumber, + error: ErrorMessage.FULL_NODE_ERROR, + }) + }) + + it('Should return 500 on failure to increment query count', async () => { + const logger = rootLogger(_config.serviceName) + const spy = jest + .spyOn( + jest.requireActual('../../src/common/database/wrappers/account'), + 'incrementQueryCount' + ) + .mockImplementationOnce(() => { + countAndThrowDBError(new Error(), logger, ErrorMessage.DATABASE_UPDATE_FAILURE) + }) + + const req = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_SIGN) + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: ErrorMessage.DATABASE_UPDATE_FAILURE, + }) + + spy.mockRestore() + + // check DB state: performedQueryCount was not incremented and request was not stored + expect( + await getPerformedQueryCount(db, ACCOUNTS_TABLE.LEGACY, ACCOUNT_ADDRESS1, logger) + ).toBe(performedQueryCount) + expect( + await getRequestExists( + db, + REQUESTS_TABLE.LEGACY, + req.account, + req.blindedQueryPhoneNumber, + logger + ) + ).toBe(false) + }) + + it('Should return 500 on failure to store request', async () => { + const logger = rootLogger(_config.serviceName) + const spy = jest + .spyOn(jest.requireActual('../../src/common/database/wrappers/request'), 'storeRequest') + .mockImplementationOnce(() => { + countAndThrowDBError(new Error(), logger, ErrorMessage.DATABASE_INSERT_FAILURE) + }) + + const req = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_SIGN) + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: ErrorMessage.DATABASE_INSERT_FAILURE, + }) + + spy.mockRestore() + + // check DB state: performedQueryCount was not incremented and request was not stored + expect( + await getPerformedQueryCount(db, ACCOUNTS_TABLE.LEGACY, ACCOUNT_ADDRESS1, logger) + ).toBe(performedQueryCount) + expect( + await getRequestExists( + db, + REQUESTS_TABLE.LEGACY, + req.account, + req.blindedQueryPhoneNumber, + logger + ) + ).toBe(false) + }) + + it('Should return 200 on failure to fetch DEK when shouldFailOpen is true', async () => { + mockGetDataEncryptionKey.mockImplementation(() => { + throw new Error() + }) + + const req = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.ENCRYPTION_KEY, + IDENTIFIER + ) + + const configWithFailOpenEnabled: typeof _config = JSON.parse(JSON.stringify(_config)) + configWithFailOpenEnabled.api.legacyPhoneNumberPrivacy.shouldFailOpen = true + const appWithFailOpenEnabled = startSigner(configWithFailOpenEnabled, db, keyProvider) + + // NOT the dek private key, so authentication would fail if getDataEncryptionKey succeeded + const differentPk = '0x00000000000000000000000000000000000000000000000000000000ddddbbbb' + const authorization = getPnpRequestAuthorization(req, differentPk) + const res = await sendRequest( + req, + authorization, + SignerEndpoint.LEGACY_PNP_SIGN, + '1', + appWithFailOpenEnabled + ) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: expectedSignature, + performedQueryCount: performedQueryCount + 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [ErrorMessage.FAILURE_TO_GET_DEK, ErrorMessage.FAILING_OPEN], + }) + }) + + it('Should return 500 on bls signing error', async () => { + const spy = jest + .spyOn(jest.requireActual('blind-threshold-bls'), 'partialSignBlindedMessage') + .mockImplementationOnce(() => { + throw new Error() + }) + + const req = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_SIGN) + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + performedQueryCount: performedQueryCount, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + error: ErrorMessage.SIGNATURE_COMPUTATION_FAILURE, + }) + + spy.mockRestore() + + // check DB state: performedQueryCount was not incremented and request was not stored + expect( + await getPerformedQueryCount( + db, + ACCOUNTS_TABLE.LEGACY, + ACCOUNT_ADDRESS1, + rootLogger(_config.serviceName) + ) + ).toBe(performedQueryCount) + expect( + await getRequestExists( + db, + REQUESTS_TABLE.LEGACY, + req.account, + req.blindedQueryPhoneNumber, + rootLogger(_config.serviceName) + ) + ).toBe(false) + }) + + it('Should return 500 on generic error in sign', async () => { + const spy = jest + .spyOn( + jest.requireActual('../../src/common/bls/bls-cryptography-client'), + 'computeBlindedSignature' + ) + .mockImplementationOnce(() => { + // Trigger a generic error in .sign to trigger the default error returned. + throw new Error() + }) + + const req = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY, + IDENTIFIER + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.LEGACY_PNP_SIGN) + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + performedQueryCount: performedQueryCount, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + error: ErrorMessage.SIGNATURE_COMPUTATION_FAILURE, + }) + + spy.mockRestore() + + // check DB state: performedQueryCount was not incremented and request was not stored + expect( + await getPerformedQueryCount( + db, + ACCOUNTS_TABLE.LEGACY, + ACCOUNT_ADDRESS1, + rootLogger(config.serviceName) + ) + ).toBe(performedQueryCount) + expect( + await getRequestExists( + db, + REQUESTS_TABLE.LEGACY, + req.account, + req.blindedQueryPhoneNumber, + rootLogger(config.serviceName) + ) + ).toBe(false) + }) + + it('Should respond with 500 on signer timeout', async () => { + const testTimeoutMS = 0 + const delay = 200 + const spy = jest + .spyOn( + jest.requireActual('../../src/common/database/wrappers/account'), + 'getPerformedQueryCount' + ) + .mockImplementationOnce(async () => { + await new Promise((resolve) => setTimeout(resolve, testTimeoutMS + delay)) + return performedQueryCount + }) + + const configWithShortTimeout = JSON.parse(JSON.stringify(_config)) + configWithShortTimeout.timeout = testTimeoutMS + const appWithShortTimeout = startSigner( + configWithShortTimeout, + db, + keyProvider, + newKit('dummyKit') + ) + + const req = getLegacyPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest( + req, + authorization, + SignerEndpoint.LEGACY_PNP_SIGN, + undefined, + appWithShortTimeout + ) + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + error: ErrorMessage.TIMEOUT_FROM_SIGNER, + version: expectedVersion, + }) + spy.mockRestore() + // Allow time for non-killed processes to finish + await new Promise((resolve) => setTimeout(resolve, delay)) + // Check that DB was not updated + expect( + await getPerformedQueryCount( + db, + ACCOUNTS_TABLE.LEGACY, + ACCOUNT_ADDRESS1, + rootLogger(config.serviceName) + ) + ).toBe(performedQueryCount) + expect( + await getRequestExists( + db, + REQUESTS_TABLE.LEGACY, + req.account, + req.blindedQueryPhoneNumber, + rootLogger(config.serviceName) + ) + ).toBe(false) + }) + }) + }) + }) +}) diff --git a/packages/phone-number-privacy/signer/test/integration/pnp.test.ts b/packages/phone-number-privacy/signer/test/integration/pnp.test.ts new file mode 100644 index 00000000000..2e590f2c134 --- /dev/null +++ b/packages/phone-number-privacy/signer/test/integration/pnp.test.ts @@ -0,0 +1,1343 @@ +import { newKit } from '@celo/contractkit' +import { + AuthenticationMethod, + ErrorMessage, + KEY_VERSION_HEADER, + PhoneNumberPrivacyRequest, + PnpQuotaResponseFailure, + PnpQuotaResponseSuccess, + rootLogger, + SignerEndpoint, + SignMessageResponseFailure, + SignMessageResponseSuccess, + TestUtils, + WarningMessage, +} from '@celo/phone-number-privacy-common' +import { BLINDED_PHONE_NUMBER } from '@celo/phone-number-privacy-common/lib/test/values' +import BigNumber from 'bignumber.js' +import { Knex } from 'knex' +import request from 'supertest' +import { initDatabase } from '../../src/common/database/database' +import { ACCOUNTS_TABLE } from '../../src/common/database/models/account' +import { REQUESTS_TABLE } from '../../src/common/database/models/request' +import { countAndThrowDBError } from '../../src/common/database/utils' +import { + getPerformedQueryCount, + incrementQueryCount, +} from '../../src/common/database/wrappers/account' +import { getRequestExists } from '../../src/common/database/wrappers/request' +import { initKeyProvider } from '../../src/common/key-management/key-provider' +import { KeyProvider } from '../../src/common/key-management/key-provider-base' +import { config, getVersion, SupportedDatabase, SupportedKeystore } from '../../src/config' +import { startSigner } from '../../src/server' + +const { + ContractRetrieval, + createMockContractKit, + createMockAccounts, + createMockOdisPayments, + createMockWeb3, + getPnpQuotaRequest, + getPnpRequestAuthorization, + getPnpSignRequest, +} = TestUtils.Utils +const { + PRIVATE_KEY1, + ACCOUNT_ADDRESS1, + mockAccount, + DEK_PRIVATE_KEY, + DEK_PUBLIC_KEY, +} = TestUtils.Values + +jest.setTimeout(20000) + +const testBlockNumber = 1000000 +const zeroBalance = new BigNumber(0) + +const mockOdisPaymentsTotalPaidCUSD = jest.fn() +const mockGetWalletAddress = jest.fn() +const mockGetDataEncryptionKey = jest.fn() + +const mockContractKit = createMockContractKit( + { + [ContractRetrieval.getAccounts]: createMockAccounts( + mockGetWalletAddress, + mockGetDataEncryptionKey + ), + [ContractRetrieval.getOdisPayments]: createMockOdisPayments(mockOdisPaymentsTotalPaidCUSD), + }, + createMockWeb3(5, testBlockNumber) +) +jest.mock('@celo/contractkit', () => ({ + ...jest.requireActual('@celo/contractkit'), + newKit: jest.fn().mockImplementation(() => mockContractKit), +})) + +// Indexes correspond to keyVersion - 1 +const expectedSignatures: string[] = [ + 'MAAAAAAAAACEVdw1ULDwAiTcZuPnZxHHh38PNa+/g997JgV10QnEq9yeuLxbM9l7vk0EAicV7IAAAAAA', + 'MAAAAAAAAAAmUJY0s9p7fMfs7GIoSiGJoObAN8ZpA7kRqeC9j/Q23TBrG3Jtxc8xWibhNVZhbYEAAAAA', + 'MAAAAAAAAAC4aBbzhHvt6l/b+8F7cILmWxZZ5Q7S6R4RZ/IgZR7Pfb9B1Wg9fsDybgxVTSv5BYEAAAAA', +] + +describe('pnp', () => { + let keyProvider: KeyProvider + let app: any + let db: Knex + + const onChainBalance = new BigNumber(1e18) + const expectedQuota = 1000 + const expectedVersion = getVersion() + + // create deep copy + const _config: typeof config = JSON.parse(JSON.stringify(config)) + _config.db.type = SupportedDatabase.Sqlite + _config.keystore.type = SupportedKeystore.MOCK_SECRET_MANAGER + _config.api.phoneNumberPrivacy.enabled = true + + const expectedSignature = expectedSignatures[_config.keystore.keys.phoneNumberPrivacy.latest - 1] + + beforeAll(async () => { + keyProvider = await initKeyProvider(_config) + }) + + beforeEach(async () => { + // Create a new in-memory database for each test. + db = await initDatabase(_config) + app = startSigner(_config, db, keyProvider, newKit('dummyKit')) + mockOdisPaymentsTotalPaidCUSD.mockReset() + mockGetDataEncryptionKey.mockReset().mockReturnValue(DEK_PUBLIC_KEY) + mockGetWalletAddress.mockReset().mockReturnValue(mockAccount) + }) + + afterEach(async () => { + // Close and destroy the in-memory database. + // Note: If tests start to be too slow, this could be replaced with more complicated logic to + // reset the database state without destroying and recreting it for each test. + await db?.destroy() + }) + + describe(`${SignerEndpoint.STATUS}`, () => { + it('Should return 200 and correct version', async () => { + const res = await request(app).get(SignerEndpoint.STATUS) + expect(res.status).toBe(200) + expect(res.body.version).toBe(expectedVersion) + }) + }) + + const sendRequest = async ( + req: PhoneNumberPrivacyRequest, + authorization: string, + endpoint: SignerEndpoint, + keyVersionHeader?: string, + signerApp: any = app + ) => { + const _req = request(signerApp).post(endpoint).set('Authorization', authorization) + + if (keyVersionHeader !== undefined) { + _req.set(KEY_VERSION_HEADER, keyVersionHeader) + } + + return _req.send(req) + } + + type pnpQuotaTestCase = { + cusdOdisPaymentInWei: BigNumber + expectedTotalQuota: number + } + const quotaCalculationTestCases: pnpQuotaTestCase[] = [ + { + cusdOdisPaymentInWei: new BigNumber(0), + expectedTotalQuota: 0, + }, + { + cusdOdisPaymentInWei: new BigNumber(1), + expectedTotalQuota: 0, + }, + { + cusdOdisPaymentInWei: new BigNumber(1.56e18), + expectedTotalQuota: 1560, + }, + { + // Sanity check for the default values to be used in endpoint setup tests + cusdOdisPaymentInWei: onChainBalance, + expectedTotalQuota: expectedQuota, + }, + { + // Unrealistically large amount paid for ODIS quota + cusdOdisPaymentInWei: new BigNumber(1.23456789e26), + expectedTotalQuota: 123456789000, + }, + ] + + describe(`${SignerEndpoint.PNP_QUOTA}`, () => { + describe('quota calculation logic', () => { + quotaCalculationTestCases.forEach(({ cusdOdisPaymentInWei, expectedTotalQuota }) => { + it(`Should get totalQuota=${expectedTotalQuota} + for cUSD (wei) payment=${cusdOdisPaymentInWei.toString()}`, async () => { + mockOdisPaymentsTotalPaidCUSD.mockReturnValue(cusdOdisPaymentInWei) + const req = getPnpQuotaRequest(ACCOUNT_ADDRESS1) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.PNP_QUOTA) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + performedQueryCount: 0, + totalQuota: expectedTotalQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + }) + }) + + describe('endpoint functionality', () => { + // Use values already tested in quota logic tests, [onChainBalance, expectedQuota] + beforeEach(async () => { + mockOdisPaymentsTotalPaidCUSD.mockReturnValue(onChainBalance) + }) + + it('Should respond with 200 on valid request', async () => { + const req = getPnpQuotaRequest(ACCOUNT_ADDRESS1) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + + const res = await sendRequest(req, authorization, SignerEndpoint.PNP_QUOTA) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + performedQueryCount: 0, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + + it('Should respond with 200 on repeated valid requests', async () => { + const req = getPnpQuotaRequest(ACCOUNT_ADDRESS1) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + + const res1 = await sendRequest(req, authorization, SignerEndpoint.PNP_QUOTA) + expect(res1.status).toBe(200) + expect(res1.body).toStrictEqual({ + success: true, + version: res1.body.version, + performedQueryCount: 0, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + const res2 = await sendRequest(req, authorization, SignerEndpoint.PNP_QUOTA) + expect(res2.status).toBe(200) + expect(res2.body).toStrictEqual(res1.body) + }) + + it('Should respond with 200 on valid request when authenticated with DEK', async () => { + const req = getPnpQuotaRequest(ACCOUNT_ADDRESS1, AuthenticationMethod.ENCRYPTION_KEY) + const authorization = getPnpRequestAuthorization(req, DEK_PRIVATE_KEY) + + const res = await sendRequest(req, authorization, SignerEndpoint.PNP_QUOTA) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + performedQueryCount: 0, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + + it('Should respond with 200 on extra request fields', async () => { + const req = getPnpQuotaRequest(ACCOUNT_ADDRESS1) + // @ts-ignore Intentionally adding an extra field to the request type + req.extraField = 'dummyString' + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.PNP_QUOTA) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + performedQueryCount: 0, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + + it('Should respond with 200 if performedQueryCount is greater than totalQuota', async () => { + await db.transaction(async (trx) => { + for (let i = 0; i <= expectedQuota; i++) { + await incrementQueryCount( + db, + ACCOUNTS_TABLE.ONCHAIN, + ACCOUNT_ADDRESS1, + rootLogger(config.serviceName), + trx + ) + } + }) + const req = getPnpQuotaRequest(ACCOUNT_ADDRESS1) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.PNP_QUOTA) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + performedQueryCount: expectedQuota + 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + + it('Should respond with 400 on missing request fields', async () => { + const badRequest = getPnpQuotaRequest(ACCOUNT_ADDRESS1) + // @ts-ignore Intentionally deleting required field + delete badRequest.account + const authorization = getPnpRequestAuthorization(badRequest, PRIVATE_KEY1) + const res = await sendRequest(badRequest, authorization, SignerEndpoint.PNP_QUOTA) + + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 401 on failed WALLET_KEY auth', async () => { + // Request from one account, signed by another account + const badRequest = getPnpQuotaRequest(mockAccount, AuthenticationMethod.WALLET_KEY) + const authorization = getPnpRequestAuthorization(badRequest, PRIVATE_KEY1) + const res = await sendRequest(badRequest, authorization, SignerEndpoint.PNP_QUOTA) + + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + + it('Should respond with 401 on failed DEK auth', async () => { + const badRequest = getPnpQuotaRequest(ACCOUNT_ADDRESS1, AuthenticationMethod.ENCRYPTION_KEY) + const differentPk = '0x00000000000000000000000000000000000000000000000000000000ddddbbbb' + const authorization = getPnpRequestAuthorization(badRequest, differentPk) + const res = await sendRequest(badRequest, authorization, SignerEndpoint.PNP_QUOTA) + + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + + it('Should respond with 503 on disabled api', async () => { + const configWithApiDisabled: typeof _config = JSON.parse(JSON.stringify(_config)) + configWithApiDisabled.api.phoneNumberPrivacy.enabled = false + const appWithApiDisabled = startSigner(configWithApiDisabled, db, keyProvider) + + const req = getPnpQuotaRequest(ACCOUNT_ADDRESS1) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest( + req, + authorization, + SignerEndpoint.PNP_QUOTA, + undefined, + appWithApiDisabled + ) + expect(res.status).toBe(503) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: WarningMessage.API_UNAVAILABLE, + }) + }) + + describe('functionality in case of errors', () => { + it('Should respond with 200 on failure to fetch DEK when shouldFailOpen is true', async () => { + mockGetDataEncryptionKey.mockImplementation(() => { + throw new Error() + }) + + const req = getPnpQuotaRequest(ACCOUNT_ADDRESS1, AuthenticationMethod.ENCRYPTION_KEY) + + const configWithFailOpenEnabled: typeof _config = JSON.parse(JSON.stringify(_config)) + configWithFailOpenEnabled.api.phoneNumberPrivacy.shouldFailOpen = true + const appWithFailOpenEnabled = startSigner(configWithFailOpenEnabled, db, keyProvider) + + // NOT the dek private key, so authentication would fail if getDataEncryptionKey succeeded + const differentPk = '0x00000000000000000000000000000000000000000000000000000000ddddbbbb' + const authorization = getPnpRequestAuthorization(req, differentPk) + const res = await sendRequest( + req, + authorization, + SignerEndpoint.PNP_QUOTA, + '1', + appWithFailOpenEnabled + ) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + performedQueryCount: 0, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [ErrorMessage.FAILURE_TO_GET_DEK, ErrorMessage.FAILING_OPEN], + }) + }) + + it('Should respond with 500 on DB performedQueryCount query failure', async () => { + const spy = jest + .spyOn( + jest.requireActual('../../src/common/database/wrappers/account'), + 'getPerformedQueryCount' + ) + .mockRejectedValueOnce(new Error()) + + const req = getPnpQuotaRequest(ACCOUNT_ADDRESS1) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.PNP_QUOTA) + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: ErrorMessage.FAILURE_TO_GET_PERFORMED_QUERY_COUNT, + }) + + spy.mockRestore() + }) + + it('Should respond with 500 on blockchain totalQuota query failure', async () => { + mockOdisPaymentsTotalPaidCUSD.mockImplementation(() => { + throw new Error('dummy error') + }) + const req = getPnpQuotaRequest(ACCOUNT_ADDRESS1) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.PNP_QUOTA) + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + error: ErrorMessage.FAILURE_TO_GET_TOTAL_QUOTA, + }) + }) + + it('Should respond with 500 on signer timeout', async () => { + const testTimeoutMS = 0 + const delay = 100 + const spy = jest + .spyOn( + jest.requireActual('../../src/common/database/wrappers/account'), + 'getPerformedQueryCount' + ) + .mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, testTimeoutMS + delay)) + return expectedQuota + }) + + const configWithShortTimeout = JSON.parse(JSON.stringify(_config)) + configWithShortTimeout.timeout = testTimeoutMS + const appWithShortTimeout = startSigner( + configWithShortTimeout, + db, + keyProvider, + newKit('dummyKit') + ) + const req = getPnpQuotaRequest(ACCOUNT_ADDRESS1) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest( + req, + authorization, + SignerEndpoint.PNP_QUOTA, + undefined, + appWithShortTimeout + ) + // Ensure that this is restored before test can fail on assertions + // to prevent failures in other tests + spy.mockRestore() + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + error: ErrorMessage.TIMEOUT_FROM_SIGNER, + version: expectedVersion, + }) + // Allow time for non-killed processes to finish + await new Promise((resolve) => setTimeout(resolve, delay)) + }) + }) + }) + }) + + describe(`${SignerEndpoint.PNP_SIGN}`, () => { + describe('quota calculation logic', () => { + quotaCalculationTestCases.forEach(({ expectedTotalQuota, cusdOdisPaymentInWei }) => { + it(`Should get totalQuota=${expectedTotalQuota} + for cUSD (wei) payment=${cusdOdisPaymentInWei.toString()}`, async () => { + mockOdisPaymentsTotalPaidCUSD.mockReturnValue(cusdOdisPaymentInWei) + + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.PNP_SIGN) + + const shouldSucceed = expectedTotalQuota > 0 + + if (shouldSucceed) { + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: expectedVersion, + signature: expectedSignature, + performedQueryCount: 1, // incremented for signature request + totalQuota: expectedTotalQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + } else { + expect(res.status).toBe(403) + expect(res.body).toStrictEqual({ + success: false, + version: expectedVersion, + performedQueryCount: 0, + totalQuota: expectedTotalQuota, + blockNumber: testBlockNumber, + error: WarningMessage.EXCEEDED_QUOTA, + }) + } + }) + }) + }) + + describe('endpoint functionality', () => { + // Use values already tested in quota logic tests, [onChainBalance, expectedQuota] + const performedQueryCount = 2 + + beforeEach(async () => { + mockOdisPaymentsTotalPaidCUSD.mockReturnValue(onChainBalance) + await db.transaction(async (trx) => { + for (let i = 0; i < performedQueryCount; i++) { + await incrementQueryCount( + db, + ACCOUNTS_TABLE.ONCHAIN, + ACCOUNT_ADDRESS1, + rootLogger(_config.serviceName), + trx + ) + } + }) + }) + + it('Should respond with 200 on valid request', async () => { + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.PNP_SIGN) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: expectedSignature, + performedQueryCount: performedQueryCount + 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + expect(res.get(KEY_VERSION_HEADER)).toEqual( + _config.keystore.keys.phoneNumberPrivacy.latest.toString() + ) + }) + + it('Should respond with 200 on valid request when authenticated with DEK', async () => { + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.ENCRYPTION_KEY + ) + const authorization = getPnpRequestAuthorization(req, DEK_PRIVATE_KEY) + const res = await sendRequest(req, authorization, SignerEndpoint.PNP_SIGN) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: expectedSignature, + performedQueryCount: performedQueryCount + 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + + for (let i = 1; i <= 3; i++) { + it(`Should respond with 200 on valid request with key version header ${i}`, async () => { + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.PNP_SIGN, i.toString()) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: expectedSignatures[i - 1], + performedQueryCount: performedQueryCount + 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + expect(res.get(KEY_VERSION_HEADER)).toEqual(i.toString()) + }) + } + + it('Should respond with 200 and warning on repeated valid requests', async () => { + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res1 = await sendRequest(req, authorization, SignerEndpoint.PNP_SIGN) + expect(res1.status).toBe(200) + expect(res1.body).toStrictEqual({ + success: true, + version: res1.body.version, + signature: expectedSignature, + performedQueryCount: performedQueryCount + 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + const res2 = await sendRequest(req, authorization, SignerEndpoint.PNP_SIGN) + expect(res2.status).toBe(200) + res1.body.warnings.push(WarningMessage.DUPLICATE_REQUEST_TO_GET_PARTIAL_SIG) + expect(res2.body).toStrictEqual(res1.body) + }) + + it('Should respond with 200 on extra request fields', async () => { + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + // @ts-ignore Intentionally adding an extra field to the request type + req.extraField = 'dummyString' + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.PNP_SIGN) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: expectedSignature, + performedQueryCount: performedQueryCount + 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [], + }) + }) + + it('Should respond with 400 on missing request fields', async () => { + const badRequest = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + // @ts-ignore Intentionally deleting required field + delete badRequest.account + const authorization = getPnpRequestAuthorization(badRequest, PRIVATE_KEY1) + const res = await sendRequest(badRequest, authorization, SignerEndpoint.PNP_SIGN) + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on invalid key version', async () => { + const badRequest = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(badRequest, PRIVATE_KEY1) + const res = await sendRequest(badRequest, authorization, SignerEndpoint.PNP_SIGN, 'a') + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_KEY_VERSION_REQUEST, + }) + }) + + it('Should respond with 400 on invalid blinded message', async () => { + const badRequest = getPnpSignRequest( + ACCOUNT_ADDRESS1, + '+1234567890', + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(badRequest, PRIVATE_KEY1) + const res = await sendRequest(badRequest, authorization, SignerEndpoint.PNP_SIGN) + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 400 on invalid address', async () => { + const badRequest = getPnpSignRequest( + '0xnotanaddress', + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(badRequest, PRIVATE_KEY1) + const res = await sendRequest(badRequest, authorization, SignerEndpoint.PNP_SIGN) + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.INVALID_INPUT, + }) + }) + + it('Should respond with 401 on failed WALLET_KEY auth', async () => { + const badRequest = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const differentPk = '0x00000000000000000000000000000000000000000000000000000000ddddbbbb' + const authorization = getPnpRequestAuthorization(badRequest, differentPk) + const res = await sendRequest(badRequest, authorization, SignerEndpoint.PNP_SIGN) + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + + it('Should respond with 401 on failed DEK auth', async () => { + const badRequest = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.ENCRYPTION_KEY + ) + const differentPk = '0x00000000000000000000000000000000000000000000000000000000ddddbbbb' + const authorization = getPnpRequestAuthorization(badRequest, differentPk) + const res = await sendRequest(badRequest, authorization, SignerEndpoint.PNP_SIGN) + expect(res.status).toBe(401) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.UNAUTHENTICATED_USER, + }) + }) + + it('Should respond with 403 on out of quota', async () => { + // deplete user's quota + const remainingQuota = expectedQuota - performedQueryCount + await db.transaction(async (trx) => { + for (let i = 0; i < remainingQuota; i++) { + await incrementQueryCount( + db, + ACCOUNTS_TABLE.ONCHAIN, + ACCOUNT_ADDRESS1, + rootLogger(_config.serviceName), + trx + ) + } + }) + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.PNP_SIGN) + expect(res.status).toBe(403) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + performedQueryCount: expectedQuota, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + error: WarningMessage.EXCEEDED_QUOTA, + }) + }) + + it('Should respond with 403 if totalQuota and performedQueryCount are zero', async () => { + mockOdisPaymentsTotalPaidCUSD.mockReturnValue(zeroBalance) + const spy = jest // for convenience so we don't have to refactor or reset the db just for this test + .spyOn( + jest.requireActual('../../src/common/database/wrappers/account'), + 'getPerformedQueryCount' + ) + .mockResolvedValueOnce(0) + + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.PNP_SIGN) + expect(res.status).toBe(403) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + performedQueryCount: 0, + totalQuota: 0, + blockNumber: testBlockNumber, + error: WarningMessage.EXCEEDED_QUOTA, + }) + + spy.mockRestore() + }) + + it('Should respond with 403 if performedQueryCount is greater than totalQuota', async () => { + const expectedRemainingQuota = expectedQuota - performedQueryCount + await db.transaction(async (trx) => { + for (let i = 0; i <= expectedRemainingQuota; i++) { + await incrementQueryCount( + db, + ACCOUNTS_TABLE.ONCHAIN, + ACCOUNT_ADDRESS1, + rootLogger(_config.serviceName), + trx + ) + } + }) + + // It is possible to reach this state due to our fail-open logic + + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.PNP_SIGN) + expect(res.status).toBe(403) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + performedQueryCount: expectedQuota + 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + error: WarningMessage.EXCEEDED_QUOTA, + }) + }) + + it('Should respond with 500 on unsupported key version', async () => { + const badRequest = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(badRequest, PRIVATE_KEY1) + const res = await sendRequest(badRequest, authorization, SignerEndpoint.PNP_SIGN, '4') + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + performedQueryCount: performedQueryCount, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + error: ErrorMessage.SIGNATURE_COMPUTATION_FAILURE, + }) + }) + + it('Should respond with 503 on disabled api', async () => { + const configWithApiDisabled: typeof _config = JSON.parse(JSON.stringify(_config)) + configWithApiDisabled.api.phoneNumberPrivacy.enabled = false + const appWithApiDisabled = startSigner(configWithApiDisabled, db, keyProvider) + + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest( + req, + authorization, + SignerEndpoint.PNP_SIGN, + '1', + appWithApiDisabled + ) + expect(res.status).toBe(503) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: WarningMessage.API_UNAVAILABLE, + }) + }) + + describe('functionality in case of errors', () => { + it('Should return 500 on DB performedQueryCount query failure', async () => { + // deplete user's quota + const remainingQuota = expectedQuota - performedQueryCount + await db.transaction(async (trx) => { + for (let i = 0; i < remainingQuota; i++) { + await incrementQueryCount( + db, + ACCOUNTS_TABLE.ONCHAIN, + ACCOUNT_ADDRESS1, + rootLogger(_config.serviceName), + trx + ) + } + }) + // sanity check + expect( + await getPerformedQueryCount( + db, + ACCOUNTS_TABLE.ONCHAIN, + ACCOUNT_ADDRESS1, + rootLogger(_config.serviceName) + ) + ).toBe(expectedQuota) + + const spy = jest + .spyOn( + jest.requireActual('../../src/common/database/wrappers/account'), + 'getPerformedQueryCount' + ) + .mockRejectedValueOnce(new Error()) + + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.PNP_SIGN) + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + performedQueryCount: -1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + error: ErrorMessage.DATABASE_GET_FAILURE, + }) + + spy.mockRestore() + }) + + it('Should respond with 500 on signer timeout', async () => { + const testTimeoutMS = 0 + const delay = 200 + const spy = jest + .spyOn( + jest.requireActual('../../src/common/database/wrappers/account'), + 'getPerformedQueryCount' + ) + .mockImplementationOnce(async () => { + await new Promise((resolve) => setTimeout(resolve, testTimeoutMS + delay)) + return performedQueryCount + }) + + const configWithShortTimeout = JSON.parse(JSON.stringify(_config)) + configWithShortTimeout.timeout = testTimeoutMS + const appWithShortTimeout = startSigner( + configWithShortTimeout, + db, + keyProvider, + newKit('dummyKit') + ) + + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest( + req, + authorization, + SignerEndpoint.PNP_SIGN, + undefined, + appWithShortTimeout + ) + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + error: ErrorMessage.TIMEOUT_FROM_SIGNER, + version: expectedVersion, + }) + spy.mockRestore() + // Allow time for non-killed processes to finish + await new Promise((resolve) => setTimeout(resolve, delay)) + // Check that DB was not updated + expect( + await getPerformedQueryCount( + db, + ACCOUNTS_TABLE.ONCHAIN, + ACCOUNT_ADDRESS1, + rootLogger(config.serviceName) + ) + ).toBe(performedQueryCount) + expect( + await getRequestExists( + db, + REQUESTS_TABLE.ONCHAIN, + req.account, + req.blindedQueryPhoneNumber, + rootLogger(config.serviceName) + ) + ).toBe(false) + }) + + it('Should return 200 w/ warning on blockchain totalQuota query failure when shouldFailOpen is true', async () => { + const configWithFailOpenEnabled: typeof _config = JSON.parse(JSON.stringify(_config)) + configWithFailOpenEnabled.api.phoneNumberPrivacy.shouldFailOpen = true + const appWithFailOpenEnabled = startSigner(configWithFailOpenEnabled, db, keyProvider) + + // deplete user's quota + const remainingQuota = expectedQuota - performedQueryCount + await db.transaction(async (trx) => { + for (let i = 0; i < remainingQuota; i++) { + await incrementQueryCount( + db, + ACCOUNTS_TABLE.ONCHAIN, + ACCOUNT_ADDRESS1, + rootLogger(_config.serviceName), + trx + ) + } + }) + // sanity check + expect( + await getPerformedQueryCount( + db, + ACCOUNTS_TABLE.ONCHAIN, + ACCOUNT_ADDRESS1, + rootLogger(_config.serviceName) + ) + ).toBe(expectedQuota) + + mockOdisPaymentsTotalPaidCUSD.mockImplementation(() => { + throw new Error('dummy error') + }) + + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest( + req, + authorization, + SignerEndpoint.PNP_SIGN, + '1', + appWithFailOpenEnabled + ) + + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: expectedSignature, + performedQueryCount: expectedQuota + 1, // bc we depleted the user's quota above + totalQuota: Number.MAX_SAFE_INTEGER, + blockNumber: testBlockNumber, + warnings: [ErrorMessage.FAILURE_TO_GET_TOTAL_QUOTA, ErrorMessage.FULL_NODE_ERROR], + }) + + // check DB state: performedQueryCount was incremented and request was stored + expect( + await getPerformedQueryCount( + db, + ACCOUNTS_TABLE.ONCHAIN, + ACCOUNT_ADDRESS1, + rootLogger(config.serviceName) + ) + ).toBe(expectedQuota + 1) + expect( + await getRequestExists( + db, + REQUESTS_TABLE.ONCHAIN, + req.account, + req.blindedQueryPhoneNumber, + rootLogger(config.serviceName) + ) + ).toBe(true) + }) + + it('Should return 500 on blockchain totalQuota query failure when shouldFailOpen is false', async () => { + mockOdisPaymentsTotalPaidCUSD.mockImplementation(() => { + throw new Error('dummy error') + }) + + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + + const configWithFailOpenDisabled: typeof _config = JSON.parse(JSON.stringify(_config)) + configWithFailOpenDisabled.api.phoneNumberPrivacy.shouldFailOpen = false + const appWithFailOpenDisabled = startSigner(configWithFailOpenDisabled, db, keyProvider) + + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest( + req, + authorization, + SignerEndpoint.PNP_SIGN, + '1', + appWithFailOpenDisabled + ) + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + performedQueryCount: performedQueryCount, + totalQuota: -1, + blockNumber: testBlockNumber, + error: ErrorMessage.FULL_NODE_ERROR, + }) + }) + + it('Should return 500 on failure to increment query count', async () => { + const logger = rootLogger(_config.serviceName) + const spy = jest + .spyOn( + jest.requireActual('../../src/common/database/wrappers/account'), + 'incrementQueryCount' + ) + .mockImplementationOnce(() => { + countAndThrowDBError(new Error(), logger, ErrorMessage.DATABASE_UPDATE_FAILURE) + }) + + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.PNP_SIGN) + spy.mockRestore() + + expect.assertions(4) + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: ErrorMessage.DATABASE_UPDATE_FAILURE, + }) + + // check DB state: performedQueryCount was not incremented and request was not stored + expect( + await getPerformedQueryCount(db, ACCOUNTS_TABLE.ONCHAIN, ACCOUNT_ADDRESS1, logger) + ).toBe(performedQueryCount) + expect( + await getRequestExists( + db, + REQUESTS_TABLE.ONCHAIN, + req.account, + req.blindedQueryPhoneNumber, + logger + ) + ).toBe(false) + }) + + it('Should return 500 on failure to store request', async () => { + const logger = rootLogger(_config.serviceName) + const spy = jest + .spyOn(jest.requireActual('../../src/common/database/wrappers/request'), 'storeRequest') + .mockImplementationOnce(() => { + countAndThrowDBError(new Error(), logger, ErrorMessage.DATABASE_INSERT_FAILURE) + }) + + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.PNP_SIGN) + spy.mockRestore() + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + error: ErrorMessage.DATABASE_INSERT_FAILURE, + }) + + // check DB state: performedQueryCount was not incremented and request was not stored + expect( + await getPerformedQueryCount(db, ACCOUNTS_TABLE.ONCHAIN, ACCOUNT_ADDRESS1, logger) + ).toBe(performedQueryCount) + expect( + await getRequestExists( + db, + REQUESTS_TABLE.ONCHAIN, + req.account, + req.blindedQueryPhoneNumber, + logger + ) + ).toBe(false) + }) + + it('Should return 200 on failure to fetch DEK when shouldFailOpen is true', async () => { + mockGetDataEncryptionKey.mockImplementation(() => { + throw new Error() + }) + + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.ENCRYPTION_KEY + ) + + const configWithFailOpenEnabled: typeof _config = JSON.parse(JSON.stringify(_config)) + configWithFailOpenEnabled.api.phoneNumberPrivacy.shouldFailOpen = true + const appWithFailOpenEnabled = startSigner(configWithFailOpenEnabled, db, keyProvider) + + // NOT the dek private key, so authentication would fail if getDataEncryptionKey succeeded + const differentPk = '0x00000000000000000000000000000000000000000000000000000000ddddbbbb' + const authorization = getPnpRequestAuthorization(req, differentPk) + const res = await sendRequest( + req, + authorization, + SignerEndpoint.PNP_SIGN, + '1', + appWithFailOpenEnabled + ) + expect(res.status).toBe(200) + expect(res.body).toStrictEqual({ + success: true, + version: res.body.version, + signature: expectedSignature, + performedQueryCount: performedQueryCount + 1, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + warnings: [ErrorMessage.FAILURE_TO_GET_DEK, ErrorMessage.FAILING_OPEN], + }) + }) + + it('Should return 500 on bls signing error', async () => { + const spy = jest + .spyOn(jest.requireActual('blind-threshold-bls'), 'partialSignBlindedMessage') + .mockImplementationOnce(() => { + throw new Error() + }) + + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.PNP_SIGN) + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + performedQueryCount: performedQueryCount, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + error: ErrorMessage.SIGNATURE_COMPUTATION_FAILURE, + }) + + spy.mockRestore() + + // check DB state: performedQueryCount was not incremented and request was not stored + expect( + await getPerformedQueryCount( + db, + ACCOUNTS_TABLE.ONCHAIN, + ACCOUNT_ADDRESS1, + rootLogger(_config.serviceName) + ) + ).toBe(performedQueryCount) + expect( + await getRequestExists( + db, + REQUESTS_TABLE.ONCHAIN, + req.account, + req.blindedQueryPhoneNumber, + rootLogger(_config.serviceName) + ) + ).toBe(false) + }) + + it('Should return 500 on generic error in sign', async () => { + const spy = jest + .spyOn( + jest.requireActual('../../src/common/bls/bls-cryptography-client'), + 'computeBlindedSignature' + ) + .mockImplementationOnce(() => { + // Trigger a generic error in .sign to trigger the default error returned. + throw new Error() + }) + + const req = getPnpSignRequest( + ACCOUNT_ADDRESS1, + BLINDED_PHONE_NUMBER, + AuthenticationMethod.WALLET_KEY + ) + const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) + const res = await sendRequest(req, authorization, SignerEndpoint.PNP_SIGN) + + expect(res.status).toBe(500) + expect(res.body).toStrictEqual({ + success: false, + version: res.body.version, + performedQueryCount: performedQueryCount, + totalQuota: expectedQuota, + blockNumber: testBlockNumber, + error: ErrorMessage.SIGNATURE_COMPUTATION_FAILURE, + }) + + spy.mockRestore() + + // check DB state: performedQueryCount was not incremented and request was not stored + expect( + await getPerformedQueryCount( + db, + ACCOUNTS_TABLE.ONCHAIN, + ACCOUNT_ADDRESS1, + rootLogger(config.serviceName) + ) + ).toBe(performedQueryCount) + expect( + await getRequestExists( + db, + REQUESTS_TABLE.ONCHAIN, + req.account, + req.blindedQueryPhoneNumber, + rootLogger(config.serviceName) + ) + ).toBe(false) + }) + }) + }) + }) +}) diff --git a/packages/phone-number-privacy/signer/test/key-management/aws-key-provider.test.ts b/packages/phone-number-privacy/signer/test/key-management/aws-key-provider.test.ts index 85d5342e8fe..480a636a20f 100644 --- a/packages/phone-number-privacy/signer/test/key-management/aws-key-provider.test.ts +++ b/packages/phone-number-privacy/signer/test/key-management/aws-key-provider.test.ts @@ -1,4 +1,5 @@ -import { AWSKeyProvider } from '../../src/key-management/aws-key-provider' +import { AWSKeyProvider } from '../../src/common/key-management/aws-key-provider' +import { DefaultKeyName, Key } from '../../src/common/key-management/key-provider-base' const mockKey = '010101010101010101010101010101010101010101010101010101010101010101010101' const mockResponse = { SecretString: `{"mockSecretKey":"${mockKey}"}` } @@ -7,12 +8,30 @@ const mockBinaryResponse = { SecretBinary: Buffer.from(mockKey).toString('base64 const mockInvalidResponse1 = { foo: 'bar' } const mockInvalidResponse2 = { SecretString: 'totally not a json string' } +const key: Key = { + name: DefaultKeyName.PHONE_NUMBER_PRIVACY, + version: 1, +} + jest.mock('../../src/config', () => ({ - keystore: { - aws: { - region: 'mockRegion', - secretName: 'mockSecretName', - secretKey: 'mockSecretKey', + config: { + serviceName: 'odis-signer', + keystore: { + keys: { + phoneNumberPrivacy: { + name: 'phoneNumberPrivacy', + latest: 1, + }, + domains: { + name: 'domains', + latest: 1, + }, + }, + aws: { + region: 'mockRegion', + secretKey: 'mockSecretKey', + secretName: 'mockSecretName', + }, }, }, })) @@ -34,16 +53,16 @@ describe('AWSKeyProvider', () => { getSecretValue.mockReturnValue({ promise: jest.fn().mockResolvedValue(mockResponse) }) const provider = new AWSKeyProvider() - await provider.fetchPrivateKeyFromStore() - expect(provider.getPrivateKey()).toBe(mockKey) + await provider.fetchPrivateKeyFromStore(key) + expect(provider.getPrivateKey(key)).toBe(mockKey) }) it('parses binary keys correctly', async () => { getSecretValue.mockReturnValue({ promise: jest.fn().mockResolvedValue(mockBinaryResponse) }) const provider = new AWSKeyProvider() - await provider.fetchPrivateKeyFromStore() - expect(provider.getPrivateKey()).toBe(mockKey) + await provider.fetchPrivateKeyFromStore(key) + expect(provider.getPrivateKey(key)).toBe(mockKey) }) }) @@ -58,8 +77,8 @@ describe('AWSKeyProvider', () => { const provider = new AWSKeyProvider() expect.assertions(2) - await expect(provider.fetchPrivateKeyFromStore()).rejects.toThrow() - await expect(provider.fetchPrivateKeyFromStore()).rejects.toThrow() + await expect(provider.fetchPrivateKeyFromStore(key)).rejects.toThrow() + await expect(provider.fetchPrivateKeyFromStore(key)).rejects.toThrow() }) it('empty key is handled correctly', async () => { @@ -69,7 +88,7 @@ describe('AWSKeyProvider', () => { const provider = new AWSKeyProvider() expect.assertions(1) - await expect(provider.fetchPrivateKeyFromStore()).rejects.toThrow() + await expect(provider.fetchPrivateKeyFromStore(key)).rejects.toThrow() }) }) }) diff --git a/packages/phone-number-privacy/signer/test/key-management/azure-key-provider.test.ts b/packages/phone-number-privacy/signer/test/key-management/azure-key-provider.test.ts index 6713bf5c720..fac9fdd4df4 100644 --- a/packages/phone-number-privacy/signer/test/key-management/azure-key-provider.test.ts +++ b/packages/phone-number-privacy/signer/test/key-management/azure-key-provider.test.ts @@ -1,15 +1,34 @@ -import { AzureKeyProvider } from '../../src/key-management/azure-key-provider' +import { AzureKeyProvider } from '../../src/common/key-management/azure-key-provider' +import { DefaultKeyName, Key } from '../../src/common/key-management/key-provider-base' const mockKey = '030303030303030303030303030303030303030303030303030303030303030303030303' +const key: Key = { + name: DefaultKeyName.PHONE_NUMBER_PRIVACY, + version: 1, +} + jest.mock('../../src/config', () => ({ - keystore: { - azure: { - clientID: 'mockClientID', - clientSecret: 'mockClientSecret', - tenant: 'mockTenant', - vaultName: 'mockVaultName', - secretName: 'mockSecretName', + config: { + serviceName: 'odis-signer', + keystore: { + keys: { + phoneNumberPrivacy: { + name: 'phoneNumberPrivacy', + latest: 1, + }, + domains: { + name: 'domains', + latest: 1, + }, + }, + azure: { + clientID: 'mockClientID', + clientSecret: 'mockClientSecret', + tenant: 'mockTenant', + vaultName: 'mockVaultName', + secretName: 'mockSecretName', + }, }, }, })) @@ -25,8 +44,8 @@ describe('AzureKeyProvider', () => { getSecret.mockResolvedValue(mockKey) const provider = new AzureKeyProvider() - await provider.fetchPrivateKeyFromStore() - expect(provider.getPrivateKey()).toBe(mockKey) + await provider.fetchPrivateKeyFromStore(key) + expect(provider.getPrivateKey(key)).toBe(mockKey) }) it('handles exceptions correctly', async () => { @@ -34,6 +53,6 @@ describe('AzureKeyProvider', () => { const provider = new AzureKeyProvider() expect.assertions(1) - await expect(provider.fetchPrivateKeyFromStore()).rejects.toThrow() + await expect(provider.fetchPrivateKeyFromStore(key)).rejects.toThrow() }) }) diff --git a/packages/phone-number-privacy/signer/test/key-management/google-key-provider.test.ts b/packages/phone-number-privacy/signer/test/key-management/google-key-provider.test.ts index 0c616b66a5e..0d6d9e298b2 100644 --- a/packages/phone-number-privacy/signer/test/key-management/google-key-provider.test.ts +++ b/packages/phone-number-privacy/signer/test/key-management/google-key-provider.test.ts @@ -1,23 +1,41 @@ -import { GoogleKeyProvider } from '../../src/key-management/google-key-provider' +import { GoogleKeyProvider } from '../../src/common/key-management/google-key-provider' +import { DefaultKeyName, Key } from '../../src/common/key-management/key-provider-base' const mockKey = '020202020202020202020202020202020202020202020202020202020202020202020202' const mockResponse = [{ payload: { data: `${mockKey}` } }] const emptyMockResponse = [{ payload: {} }] const invalidMockResponse = [{ payload: { data: '123' } }] +const key: Key = { + name: DefaultKeyName.PHONE_NUMBER_PRIVACY, + version: 1, +} jest.mock('../../src/config', () => ({ - keystore: { - google: { - projectId: 'mockProject', - secretName: 'mockSecretName', - secretVersion: 'mockSecretVersion', + config: { + serviceName: 'odis-signer', + keystore: { + keys: { + phoneNumberPrivacy: { + name: 'phoneNumberPrivacy', + latest: 1, + }, + domains: { + name: 'domains', + latest: 1, + }, + }, + google: { + projectId: 'mockProject', + secretVersion: 'mockSecretVersion', + secretName: 'mockSecretName', + }, }, }, })) const accessSecretVersion = jest.fn() -jest.mock('@google-cloud/secret-manager', () => ({ +jest.mock('@google-cloud/secret-manager/build/src/v1', () => ({ SecretManagerServiceClient: jest.fn(() => ({ accessSecretVersion })), })) @@ -26,8 +44,8 @@ describe('GoogleKeyProvider', () => { accessSecretVersion.mockResolvedValue(mockResponse) const provider = new GoogleKeyProvider() - await provider.fetchPrivateKeyFromStore() - expect(provider.getPrivateKey()).toBe(mockKey) + await provider.fetchPrivateKeyFromStore(key) + expect(provider.getPrivateKey(key)).toBe(mockKey) }) it('handles errors correctly', async () => { @@ -35,7 +53,7 @@ describe('GoogleKeyProvider', () => { const provider = new GoogleKeyProvider() expect.assertions(1) - await expect(provider.fetchPrivateKeyFromStore()).rejects.toThrow() + await expect(provider.fetchPrivateKeyFromStore(key)).rejects.toThrow() }) it('unitialized provider throws', () => { @@ -43,7 +61,7 @@ describe('GoogleKeyProvider', () => { const provider = new GoogleKeyProvider() expect.assertions(1) - expect(() => provider.getPrivateKey()).toThrow() + expect(() => provider.getPrivateKey(key)).toThrow() }) it('set invalid private key throws', async () => { @@ -51,6 +69,6 @@ describe('GoogleKeyProvider', () => { const provider = new GoogleKeyProvider() expect.assertions(1) - await expect(provider.fetchPrivateKeyFromStore()).rejects.toThrow() + await expect(provider.fetchPrivateKeyFromStore(key)).rejects.toThrow() }) }) diff --git a/packages/phone-number-privacy/signer/test/signing/bls-signature.test.ts b/packages/phone-number-privacy/signer/test/signing/bls-signature.test.ts index 0c74f01099a..75a26a3da85 100644 --- a/packages/phone-number-privacy/signer/test/signing/bls-signature.test.ts +++ b/packages/phone-number-privacy/signer/test/signing/bls-signature.test.ts @@ -1,7 +1,7 @@ +import { rootLogger, TestUtils } from '@celo/phone-number-privacy-common' import threshold_bls from 'blind-threshold-bls' -import { computeBlindedSignature } from '../../src/bls/bls-cryptography-client' -import { DEV_POLYNOMIAL, DEV_PRIVATE_KEY, DEV_PUBLIC_KEY } from '../../src/config' -import { rootLogger } from '@celo/phone-number-privacy-common' +import { computeBlindedSignature } from '../../src/common/bls/bls-cryptography-client' +import { config } from '../../src/config' describe(`BLS service computes signature`, () => { it('provides blinded signature', async () => { @@ -14,14 +14,18 @@ describe(`BLS service computes signature`, () => { const blindedMsgResult = threshold_bls.blind(message, userSeed) const blindedMsg = Buffer.from(blindedMsgResult.message).toString('base64') - const actual = await computeBlindedSignature(blindedMsg, DEV_PRIVATE_KEY, rootLogger()) + const actual = computeBlindedSignature( + blindedMsg, + TestUtils.Values.PNP_DEV_SIGNER_PRIVATE_KEY, + rootLogger(config.serviceName) + ) expect(actual).toEqual( 'MAAAAAAAAADDilSaA/xvbtE4NV3agMzHIf8PGPQ83Cu8gQy5E2mRWyUIges8bjE4EBe1L7pcY4AAAAAA' ) expect( threshold_bls.partialVerifyBlindSignature( - Buffer.from(DEV_POLYNOMIAL, 'hex'), + Buffer.from(TestUtils.Values.PNP_DEV_ODIS_POLYNOMIAL, 'hex'), blindedMsgResult.message, Buffer.from(actual, 'base64') ) @@ -32,7 +36,7 @@ describe(`BLS service computes signature`, () => { combinedSignature, blindedMsgResult.blindingFactor ) - const publicKey = Buffer.from(DEV_PUBLIC_KEY, 'hex') + const publicKey = Buffer.from(TestUtils.Values.PNP_DEV_ODIS_PUBLIC_KEY, 'base64') expect(threshold_bls.verify(publicKey, message, unblindedSignedMessage)) }) @@ -40,6 +44,12 @@ describe(`BLS service computes signature`, () => { const blindedMsg = Buffer.from('invalid blinded message').toString('base64') expect.assertions(1) - await expect(() => computeBlindedSignature(blindedMsg, DEV_PRIVATE_KEY, rootLogger())).toThrow() + expect(() => + computeBlindedSignature( + blindedMsg, + TestUtils.Values.PNP_DEV_SIGNER_PRIVATE_KEY, + rootLogger(config.serviceName) + ) + ).toThrow() }) }) diff --git a/packages/phone-number-privacy/signer/test/signing/query-quota.test.ts b/packages/phone-number-privacy/signer/test/signing/query-quota.test.ts deleted file mode 100644 index 6bdc41cac0d..00000000000 --- a/packages/phone-number-privacy/signer/test/signing/query-quota.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { StableToken } from '@celo/contractkit' -import { isVerified, rootLogger, TestUtils } from '@celo/phone-number-privacy-common' -import BigNumber from 'bignumber.js' -import allSettled from 'promise.allsettled' -import { getPerformedQueryCount } from '../../src/database/wrappers/account' -import { getRemainingQueryCount } from '../../src/signing/query-quota' -import { getContractKit } from '../../src/web3/contracts' - -allSettled.shim() - -const { - ContractRetrieval, - createMockAccounts, - createMockAttestation, - createMockContractKit, - createMockToken, - createMockWeb3, -} = TestUtils.Utils -const { mockAccount, mockPhoneNumber } = TestUtils.Values - -jest.mock('../../src/web3/contracts') -const mockGetContractKit = getContractKit as jest.Mock -jest.mock('../../src/database/wrappers/account') -const mockPerformedQueryCount = getPerformedQueryCount as jest.Mock -jest.mock('@celo/phone-number-privacy-common', () => ({ - ...jest.requireActual('@celo/phone-number-privacy-common'), - isVerified: jest.fn(), -})) -const mockIsVerified = isVerified as jest.Mock -// tslint:disable-next-line: no-object-literal-type-assertion - -describe(getRemainingQueryCount, () => { - it('Calculates remaining query count for verified account', async () => { - const contractKitVerifiedNoTx = createMockContractKit( - { - [ContractRetrieval.getAttestations]: createMockAttestation(3, 3), - [ContractRetrieval.getStableToken]: createMockToken(new BigNumber(200000000000000000)), - [ContractRetrieval.getGoldToken]: createMockToken(new BigNumber(200000000000000000)), - [ContractRetrieval.getAccounts]: createMockAccounts('0x0'), - }, - createMockWeb3(5) - ) - mockPerformedQueryCount.mockImplementation(() => new Promise((resolve) => resolve(2))) - mockIsVerified.mockReturnValue(true) - mockGetContractKit.mockImplementation(() => contractKitVerifiedNoTx) - expect(await getRemainingQueryCount(rootLogger(), mockAccount, mockPhoneNumber)).toEqual({ - performedQueryCount: 2, - totalQuota: 60, - }) - }) - it('Calculates remaining query count for unverified account', async () => { - const contractKitVerifiedNoTx = createMockContractKit( - { - [ContractRetrieval.getAttestations]: createMockAttestation(0, 0), - [ContractRetrieval.getStableToken]: createMockToken(new BigNumber(200000000000000000)), - [ContractRetrieval.getGoldToken]: createMockToken(new BigNumber(200000000000000000)), - [ContractRetrieval.getAccounts]: createMockAccounts('0x0'), - }, - createMockWeb3(0) - ) - mockGetContractKit.mockImplementation(() => contractKitVerifiedNoTx) - mockPerformedQueryCount.mockImplementation(() => new Promise((resolve) => resolve(1))) - mockIsVerified.mockReturnValue(false) - expect(await getRemainingQueryCount(rootLogger(), mockAccount, mockPhoneNumber)).toEqual({ - performedQueryCount: 1, - totalQuota: 10, - }) - }) - it('Calculates remaining query count for verified account with many txs', async () => { - const contractKitVerifiedNoTx = createMockContractKit( - { - [ContractRetrieval.getAttestations]: createMockAttestation(3, 3), - [ContractRetrieval.getStableToken]: createMockToken(new BigNumber(200000000000000000)), - [ContractRetrieval.getGoldToken]: createMockToken(new BigNumber(200000000000000000)), - [ContractRetrieval.getAccounts]: createMockAccounts('0x0'), - }, - createMockWeb3(100) - ) - mockPerformedQueryCount.mockImplementation(() => new Promise((resolve) => resolve(10))) - mockIsVerified.mockReturnValue(true) - mockGetContractKit.mockImplementation(() => contractKitVerifiedNoTx) - expect(await getRemainingQueryCount(rootLogger(), mockAccount, mockPhoneNumber)).toEqual({ - performedQueryCount: 10, - totalQuota: 440, - }) - }) - it('Calculates remaining query count for unverified account with many txs', async () => { - const contractKitVerifiedNoTx = createMockContractKit( - { - [ContractRetrieval.getAttestations]: createMockAttestation(0, 0), - [ContractRetrieval.getStableToken]: createMockToken(new BigNumber(200000000000000000)), - [ContractRetrieval.getGoldToken]: createMockToken(new BigNumber(200000000000000000)), - [ContractRetrieval.getAccounts]: createMockAccounts('0x0'), - }, - createMockWeb3(100) - ) - mockPerformedQueryCount.mockImplementation(() => new Promise((resolve) => resolve(0))) - mockIsVerified.mockReturnValue(false) - mockGetContractKit.mockImplementation(() => contractKitVerifiedNoTx) - expect(await getRemainingQueryCount(rootLogger(), mockAccount, mockPhoneNumber)).toEqual({ - performedQueryCount: 0, - totalQuota: 410, - }) - }) - it('Calculates remaining query count for unverified account without any balance', async () => { - const contractKitVerifiedNoTx = createMockContractKit( - { - [ContractRetrieval.getAttestations]: createMockAttestation(0, 0), - [ContractRetrieval.getStableToken]: createMockToken(new BigNumber(0)), - [ContractRetrieval.getGoldToken]: createMockToken(new BigNumber(0)), - [ContractRetrieval.getAccounts]: createMockAccounts('0x0'), - }, - createMockWeb3(100) - ) - mockPerformedQueryCount.mockImplementation(() => new Promise((resolve) => resolve(0))) - mockIsVerified.mockReturnValue(false) - mockGetContractKit.mockImplementation(() => contractKitVerifiedNoTx) - expect(await getRemainingQueryCount(rootLogger(), mockAccount, mockPhoneNumber)).toEqual({ - performedQueryCount: 0, - totalQuota: 0, - }) - }) - it('Calculates remaining query count for unverified account with cUSD balance', async () => { - const contractKitVerifiedNoTx = createMockContractKit( - { - [ContractRetrieval.getAttestations]: createMockAttestation(0, 0), - [ContractRetrieval.getStableToken]: createMockToken(new BigNumber(0)), - [ContractRetrieval.getGoldToken]: createMockToken(new BigNumber(0)), - [ContractRetrieval.getAccounts]: createMockAccounts('0x0'), - }, - createMockWeb3(0) - ) - contractKitVerifiedNoTx.contracts[ContractRetrieval.getStableToken] = jest.fn( - (stableToken: StableToken) => { - return stableToken === StableToken.cUSD - ? createMockToken(new BigNumber(200000000000000000)) - : createMockToken(new BigNumber(0)) - } - ) - mockPerformedQueryCount.mockImplementation(() => new Promise((resolve) => resolve(1))) - mockIsVerified.mockReturnValue(false) - mockGetContractKit.mockImplementation(() => contractKitVerifiedNoTx) - expect(await getRemainingQueryCount(rootLogger(), mockAccount, mockPhoneNumber)).toEqual({ - performedQueryCount: 1, - totalQuota: 10, - }) - }) - it('Calculates remaining query count for unverified account with cEUR balance', async () => { - const contractKitVerifiedNoTx = createMockContractKit( - { - [ContractRetrieval.getAttestations]: createMockAttestation(0, 0), - [ContractRetrieval.getStableToken]: createMockToken(new BigNumber(0)), - [ContractRetrieval.getGoldToken]: createMockToken(new BigNumber(0)), - [ContractRetrieval.getAccounts]: createMockAccounts('0x0'), - }, - createMockWeb3(0) - ) - contractKitVerifiedNoTx.contracts[ContractRetrieval.getStableToken] = jest.fn( - (stableToken: StableToken) => { - return stableToken === StableToken.cEUR - ? createMockToken(new BigNumber(200000000000000000)) - : createMockToken(new BigNumber(0)) - } - ) - mockPerformedQueryCount.mockImplementation(() => new Promise((resolve) => resolve(1))) - mockIsVerified.mockReturnValue(false) - mockGetContractKit.mockImplementation(() => contractKitVerifiedNoTx) - expect(await getRemainingQueryCount(rootLogger(), mockAccount, mockPhoneNumber)).toEqual({ - performedQueryCount: 1, - totalQuota: 10, - }) - }) - it('Calculates remaining query count for unverified account with only CELO balance', async () => { - const contractKitVerifiedNoTx = createMockContractKit( - { - [ContractRetrieval.getAttestations]: createMockAttestation(0, 0), - [ContractRetrieval.getStableToken]: createMockToken(new BigNumber(0)), - [ContractRetrieval.getGoldToken]: createMockToken(new BigNumber(200000000000000000)), - [ContractRetrieval.getAccounts]: createMockAccounts('0x0'), - }, - createMockWeb3(0) - ) - mockPerformedQueryCount.mockImplementation(() => new Promise((resolve) => resolve(1))) - mockIsVerified.mockReturnValue(false) - mockGetContractKit.mockImplementation(() => contractKitVerifiedNoTx) - expect(await getRemainingQueryCount(rootLogger(), mockAccount, mockPhoneNumber)).toEqual({ - performedQueryCount: 1, - totalQuota: 10, - }) - }) - it('No phone number hash when request own phone number', async () => { - const contractKitVerifiedNoTx = createMockContractKit( - { - [ContractRetrieval.getAttestations]: createMockAttestation(0, 0), - [ContractRetrieval.getStableToken]: createMockToken(new BigNumber(200000000000000000)), - [ContractRetrieval.getGoldToken]: createMockToken(new BigNumber(200000000000000000)), - [ContractRetrieval.getAccounts]: createMockAccounts('0x0'), - }, - createMockWeb3(0) - ) - mockPerformedQueryCount.mockImplementation(() => new Promise((resolve) => resolve(0))) - mockGetContractKit.mockImplementation(() => contractKitVerifiedNoTx) - expect(await getRemainingQueryCount(rootLogger(), mockAccount, undefined)).toEqual({ - performedQueryCount: 0, - totalQuota: 10, - }) - }) -}) diff --git a/packages/protocol/scripts/bash/backupmigrations.sh b/packages/protocol/scripts/bash/backupmigrations.sh index 2b8fe2994f1..f17162027fc 100755 --- a/packages/protocol/scripts/bash/backupmigrations.sh +++ b/packages/protocol/scripts/bash/backupmigrations.sh @@ -47,6 +47,7 @@ else # cp migrations.bak/23_governance_approver_multisig.* migrations/ # cp migrations.bak/24_grandamento.* migrations/ # cp migrations.bak/25_stableToken_registry.* migrations/ + # cp migrations.bak/26_federated_attestations.* migrations/ # cp migrations.bak/27_odispayments.* migrations/ # cp migrations.bak/28_governance.* migrations/ # cp migrations.bak/29_elect_validators.* migrations/ diff --git a/packages/sdk/contractkit/src/contract-cache.ts b/packages/sdk/contractkit/src/contract-cache.ts index ff079d7301f..53eac1aa725 100644 --- a/packages/sdk/contractkit/src/contract-cache.ts +++ b/packages/sdk/contractkit/src/contract-cache.ts @@ -166,19 +166,15 @@ export class WrapperCache implements ContractCacheType { getEscrow(): Promise { return this.getContract(CeloContract.Escrow) } - getExchange(stableToken: StableToken = StableToken.cUSD) { return this.getContract(stableTokenInfos[stableToken].exchangeContract) } - getFreezer() { return this.getContract(CeloContract.Freezer) } - getFederatedAttestations() { return this.getContract(CeloContract.FederatedAttestations) } - getGasPriceMinimum() { return this.getContract(CeloContract.GasPriceMinimum) } @@ -206,14 +202,12 @@ export class WrapperCache implements ContractCacheType { getOdisPayments() { return this.getContract(CeloContract.OdisPayments) } - getReserve() { return this.getContract(CeloContract.Reserve) } getSortedOracles() { return this.getContract(CeloContract.SortedOracles) } - getStableToken(stableToken: StableToken = StableToken.cUSD) { return this.getContract(stableTokenInfos[stableToken].contract) } diff --git a/packages/sdk/contractkit/src/web3-contract-cache.ts b/packages/sdk/contractkit/src/web3-contract-cache.ts index 8755adceea8..98b73b4f3fd 100644 --- a/packages/sdk/contractkit/src/web3-contract-cache.ts +++ b/packages/sdk/contractkit/src/web3-contract-cache.ts @@ -165,6 +165,9 @@ export class Web3ContractCache { getMultiSig(address: string) { return this.getContract(CeloContract.MultiSig, address) } + getOdisPayments() { + return this.getContract(CeloContract.OdisPayments) + } getRandom() { return this.getContract(CeloContract.Random) } diff --git a/packages/sdk/contractkit/src/wrappers/OdisPayments.ts b/packages/sdk/contractkit/src/wrappers/OdisPayments.ts index eaf807b4994..3b4998a0a74 100644 --- a/packages/sdk/contractkit/src/wrappers/OdisPayments.ts +++ b/packages/sdk/contractkit/src/wrappers/OdisPayments.ts @@ -1,14 +1,17 @@ import { Address, CeloTransactionObject } from '@celo/connect' +import { BigNumber } from 'bignumber.js' import { OdisPayments } from '../generated/OdisPayments' -import { BaseWrapper, proxyCall, proxySend } from './BaseWrapper' +import { BaseWrapper, proxyCall, proxySend, valueToBigNumber } from './BaseWrapper' export class OdisPaymentsWrapper extends BaseWrapper { /** * @notice Fetches total amount sent (all-time) for given account to odisPayments * @param account The account to fetch total amount of funds sent */ - totalPaidCUSD: (account: Address) => Promise = proxyCall( - this.contract.methods.totalPaidCUSD + totalPaidCUSD: (account: Address) => Promise = proxyCall( + this.contract.methods.totalPaidCUSD, + undefined, + valueToBigNumber ) /** @@ -22,3 +25,5 @@ export class OdisPaymentsWrapper extends BaseWrapper { this.contract.methods.payInCUSD ) } + +export type OdisPaymentsWrapperType = OdisPaymentsWrapper diff --git a/packages/sdk/encrypted-backup/package.json b/packages/sdk/encrypted-backup/package.json index 5dd143657c3..cd4e4de0474 100644 --- a/packages/sdk/encrypted-backup/package.json +++ b/packages/sdk/encrypted-backup/package.json @@ -27,8 +27,8 @@ "dependencies": { "@celo/base": "2.3.1-dev", "@celo/identity": "2.3.1-dev", - "@celo/phone-number-privacy-common": "1.0.39", - "@celo/poprf": "^0.1.6", + "@celo/phone-number-privacy-common": "1.0.42-dev", + "@celo/poprf": "^0.1.9", "@celo/utils": "2.3.1-dev", "@types/debug": "^4.1.5", "debug": "^4.1.1", @@ -43,4 +43,4 @@ "engines": { "node": ">=8.13.0" } -} \ No newline at end of file +} diff --git a/packages/sdk/encrypted-backup/src/backup.test.ts b/packages/sdk/encrypted-backup/src/backup.test.ts index 956bc9f6d2b..9e6cb75e794 100644 --- a/packages/sdk/encrypted-backup/src/backup.test.ts +++ b/packages/sdk/encrypted-backup/src/backup.test.ts @@ -155,7 +155,12 @@ describe('createBackup', () => { body: { success: true, version: 'mock', - status: { timer: Date.now() / 1000 + 3600, counter: 0, disabled: false }, + status: { + timer: Date.now() / 1000 + 3600, + counter: 0, + disabled: false, + now: Date.now() / 1000, + }, }, }) const result = await createBackup({ @@ -356,7 +361,12 @@ describe('openBackup', () => { body: { success: true, version: 'mock', - status: { timer: Date.now() / 1000 + 3600, counter: 0, disabled: false }, + status: { + timer: Date.now() / 1000 + 3600, + counter: 0, + disabled: false, + now: Date.now() / 1000, + }, }, }) const result = await openBackup({ diff --git a/packages/sdk/encrypted-backup/src/odis.mock.ts b/packages/sdk/encrypted-backup/src/odis.mock.ts index cb24c660dbb..2804d794625 100644 --- a/packages/sdk/encrypted-backup/src/odis.mock.ts +++ b/packages/sdk/encrypted-backup/src/odis.mock.ts @@ -10,8 +10,8 @@ import { PoprfServer, SequentialDelayDomain, SequentialDelayDomainState, - verifyDomainQuotaStatusRequestSignature, - verifyDomainRestrictedSignatureRequestSignature, + verifyDomainQuotaStatusRequestAuthenticity, + verifyDomainRestrictedSignatureRequestAuthenticity, } from '@celo/phone-number-privacy-common' import * as poprf from '@celo/poprf' import debugFactory from 'debug' @@ -31,10 +31,18 @@ export class MockOdis { readonly state: Record = {} readonly poprf = new PoprfServer(MOCK_ODIS_KEYPAIR.privateKey) + private now = () => Math.floor(Date.now() / 1000) + + private domainState(hash: Buffer) { + return ( + this.state[hash.toString('hex')] ?? { timer: 0, counter: 0, disabled: false, now: this.now() } + ) + } + quota( req: DomainQuotaStatusRequest ): { status: number; body: DomainQuotaStatusResponse } { - const authorized = verifyDomainQuotaStatusRequestSignature(req) + const authorized = verifyDomainQuotaStatusRequestAuthenticity(req) if (!authorized) { return { status: 401, @@ -46,14 +54,12 @@ export class MockOdis { } } - const hash = domainHash(req.domain).toString('hex') - const domainState = this.state[hash] ?? { timer: 0, counter: 0, disabled: false } return { status: 200, body: { success: true, version: 'mock', - status: domainState, + status: this.domainState(domainHash(req.domain)), }, } } @@ -61,7 +67,7 @@ export class MockOdis { sign( req: DomainRestrictedSignatureRequest ): { status: number; body: DomainRestrictedSignatureResponse } { - const authorized = verifyDomainRestrictedSignatureRequestSignature(req) + const authorized = verifyDomainRestrictedSignatureRequestAuthenticity(req) if (!authorized) { return { status: 401, @@ -69,16 +75,13 @@ export class MockOdis { success: false, version: 'mock', error: 'unauthorized', + status: undefined, }, } } const hash = domainHash(req.domain) - const domainState = this.state[hash.toString('hex')] ?? { - timer: 0, - counter: 0, - disabled: false, - } + const domainState = this.domainState(hash) const nonce = req.options.nonce.defined ? req.options.nonce.value : undefined if (nonce !== domainState.counter) { return { @@ -87,11 +90,12 @@ export class MockOdis { success: false, version: 'mock', error: 'incorrect nonce', + status: domainState, }, } } - const limitCheck = checkSequentialDelayRateLimit(req.domain, Date.now() / 1000, domainState) + const limitCheck = checkSequentialDelayRateLimit(req.domain, this.now(), domainState) if (!limitCheck.accepted || limitCheck.state === undefined) { return { status: 429, @@ -99,6 +103,7 @@ export class MockOdis { success: false, version: 'mock', error: 'request limit exceeded', + status: domainState, }, } } @@ -119,6 +124,7 @@ export class MockOdis { body: { success: false, version: 'mock', + status: undefined, error: (error as Error).toString(), }, } @@ -129,6 +135,7 @@ export class MockOdis { body: { success: true, version: 'mock', + status: limitCheck.state, signature, }, } @@ -145,7 +152,7 @@ export class MockOdis { const res = this.quota( JSON.parse(req.body) as DomainQuotaStatusRequest ) - debug('Mocking request', { url, req, res }) + debug('Mocking request', JSON.stringify({ url, req, res })) return res }) ) @@ -162,7 +169,7 @@ export class MockOdis { const res = this.sign( JSON.parse(req.body) as DomainRestrictedSignatureRequest ) - debug('Mocking request', { url, req, res }) + debug('Mocking request', JSON.stringify({ url, req, res })) return res }) ) diff --git a/packages/sdk/encrypted-backup/src/odis.ts b/packages/sdk/encrypted-backup/src/odis.ts index bf720b0f92d..857c2e4ff4a 100644 --- a/packages/sdk/encrypted-backup/src/odis.ts +++ b/packages/sdk/encrypted-backup/src/odis.ts @@ -15,15 +15,15 @@ import { DomainQuotaStatusResponse, domainQuotaStatusResponseSchema, DomainQuotaStatusResponseSuccess, + DomainRequestTypeTag, DomainRestrictedSignatureRequest, domainRestrictedSignatureRequestEIP712, DomainRestrictedSignatureResponse, - DomainRestrictedSignatureResponseSchema, + domainRestrictedSignatureResponseSchema, DomainRestrictedSignatureResponseSuccess, genSessionID, PoprfClient, SequentialDelayDomain, - SequentialDelayDomainState, SequentialDelayDomainStateSchema, } from '@celo/phone-number-privacy-common' import { defined, noNumber, noString } from '@celo/utils/lib/sign-typed-data-utils' @@ -87,22 +87,20 @@ export async function odisHardenKey( } // Check locally whether or not we should expect to be able to make a query to ODIS right now. - // TODO(victor) Using Date.now is actually not appropriate because mobile clients may have a large - // clock drift. Modify this to use a time returned from ODIS either in the status response, or as - // part of the 429 response upon rejecting the signature request. Risk with the latter approach is - // that unless replay handling is implemented, having the request accepted by half of the signers, - // but rejected by the other half can get the client into a bad state. - const quotaState = quotaResp.result.status as SequentialDelayDomainState - const { accepted, notBefore } = checkSequentialDelayRateLimit( + // Note that this uses the servers timestamp through the `quotaState.now` field. This is because + // mobile clients may have a large clock drift. This prevents that clock drift from resulting in + // misinterpretations of the domain quota. + const quotaState = quotaResp.result.status + const quotaResult = checkSequentialDelayRateLimit( domain, - // Dividing by 1000 to convert ms to seconds for the rate limit check. - Date.now() / 1000, + // Use the local clock as a fallback. Divide by 1000 to get seconds from ms. + quotaState.now ?? Date.now() / 1000, quotaState ) - if (!accepted) { + if (!quotaResult.accepted) { return Err( new OdisRateLimitingError( - notBefore, + quotaResult.notBefore, new Error('client does not currently have quota based on status response.') ) ) @@ -175,6 +173,7 @@ async function requestOdisQuotaStatus( wallet?: EIP712Wallet ): Promise> { const quotaStatusReq: DomainQuotaStatusRequest = { + type: DomainRequestTypeTag.QUOTA, domain, options: { signature: noString, @@ -230,6 +229,7 @@ async function requestOdisDomainSignature( wallet?: EIP712Wallet ): Promise> { const signatureReq: DomainRestrictedSignatureRequest = { + type: DomainRequestTypeTag.SIGN, domain, options: { signature: noString, @@ -262,7 +262,7 @@ async function requestOdisDomainSignature( signatureReq, environment, DomainEndpoint.DOMAIN_SIGN, - DomainRestrictedSignatureResponseSchema + domainRestrictedSignatureResponseSchema(SequentialDelayDomainStateSchema) ) } catch (error) { if ((error as Error).message?.includes(ErrorMessages.ODIS_FETCH_ERROR)) { diff --git a/packages/sdk/identity/package.json b/packages/sdk/identity/package.json index c39eedb7d31..a1684a54340 100644 --- a/packages/sdk/identity/package.json +++ b/packages/sdk/identity/package.json @@ -28,7 +28,7 @@ "@celo/base": "2.3.1-dev", "@celo/utils": "2.3.1-dev", "@celo/contractkit": "2.3.1-dev", - "@celo/phone-number-privacy-common": "1.0.39", + "@celo/phone-number-privacy-common": "1.0.42-dev", "@types/debug": "^4.1.5", "bignumber.js": "^9.0.0", "blind-threshold-bls": "https://github.com/celo-org/blind-threshold-bls-wasm#e1e2f8a", diff --git a/packages/sdk/identity/src/odis/identifier.ts b/packages/sdk/identity/src/odis/identifier.ts index 9f8e185713a..2c3b985c541 100644 --- a/packages/sdk/identity/src/odis/identifier.ts +++ b/packages/sdk/identity/src/odis/identifier.ts @@ -1,4 +1,9 @@ -import { CombinerEndpoint } from '@celo/phone-number-privacy-common' +import { + CombinerEndpoint, + KEY_VERSION_HEADER, + SignMessageRequest, + SignMessageResponseSchema, +} from '@celo/phone-number-privacy-common' import { soliditySha3 } from '@celo/utils/lib/solidity' import { createHash } from 'crypto' import debugFactory from 'debug' @@ -6,11 +11,10 @@ import { BlsBlindingClient, WasmBlsBlindingClient } from './bls-blinding-client' import { AuthenticationMethod, AuthSigner, - CombinerSignMessageResponse, EncryptionKeySigner, + getOdisPnpRequestAuth, queryOdis, ServiceContext, - SignMessageRequest, } from './query' const debug = debugFactory('kit:odis:identifier') @@ -57,7 +61,6 @@ export async function getOnchainIdentifier( signer: AuthSigner, context: ServiceContext, blindingFactor?: string, - selfidentifierHash?: string, clientVersion?: string, blsBlindingClient?: BlsBlindingClient, sessionID?: string @@ -89,7 +92,6 @@ export async function getOnchainIdentifier( signer, context, base64BlindedMessage, - selfidentifierHash, clientVersion, sessionID ) @@ -126,29 +128,34 @@ export async function getBlindedIdentifierSignature( signer: AuthSigner, context: ServiceContext, base64BlindedMessage: string, - selfidentifierHash?: string, clientVersion?: string, - sessionID?: string + sessionID?: string, + keyVersion?: number ): Promise { const body: SignMessageRequest = { account, - blindedQueryIdentifier: base64BlindedMessage, - hashedIdentifier: selfidentifierHash, - version: clientVersion ? clientVersion : 'unknown', + blindedQueryPhoneNumber: base64BlindedMessage, + version: clientVersion, authenticationMethod: signer.authenticationMethod, + sessionID, } - if (sessionID) { - body.sessionID = sessionID - } - - const response = await queryOdis( - signer, + const response = await queryOdis( body, context, - CombinerEndpoint.PNP_SIGN + CombinerEndpoint.PNP_SIGN, + SignMessageResponseSchema, + { + [KEY_VERSION_HEADER]: keyVersion?.toString(), + Authorization: await getOdisPnpRequestAuth(body, signer), + } ) - return response.combinedSignature + + if (!response.success) { + throw new Error(response.error) + } + + return response.signature } /** diff --git a/packages/sdk/identity/src/odis/index.ts b/packages/sdk/identity/src/odis/index.ts index 53ed9bfc1a1..ba8e89928a7 100644 --- a/packages/sdk/identity/src/odis/index.ts +++ b/packages/sdk/identity/src/odis/index.ts @@ -1,13 +1,11 @@ import * as BlsBlindingClient from './bls-blinding-client' import * as CircuitBreaker from './circuit-breaker' -import * as Matchmaking from './matchmaking' import * as PhoneNumberIdentifier from './phone-number-identifier' import * as Query from './query' export const OdisUtils = { BlsBlindingClient, Query, - Matchmaking, PhoneNumberIdentifier, CircuitBreaker, } diff --git a/packages/sdk/identity/src/odis/matchmaking.test.ts b/packages/sdk/identity/src/odis/matchmaking.test.ts deleted file mode 100644 index c8649e8c52f..00000000000 --- a/packages/sdk/identity/src/odis/matchmaking.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { getContactMatches, obfuscateNumberForMatchmaking } from './matchmaking' -import { AuthenticationMethod, EncryptionKeySigner, ErrorMessages, ServiceContext } from './query' - -const mockE164Number = '+14155550000' -const mockE164Number2 = '+14155550002' -const mockE164Number3 = '+14155550003' -const mockContacts = [mockE164Number2, mockE164Number3] -const mockAccount = '0x0000000000000000000000000000000000007E57' - -const serviceContext: ServiceContext = { - odisUrl: 'https://mockodis.com', - odisPubKey: - '7FsWGsFnmVvRfMDpzz95Np76wf/1sPaK0Og9yiB+P8QbjiC8FV67NBans9hzZEkBaQMhiapzgMR6CkZIZPvgwQboAxl65JWRZecGe5V3XO4sdKeNemdAZ2TzQuWkuZoA', -} -const endpoint = serviceContext.odisUrl + '/getContactMatches' - -const authSigner: EncryptionKeySigner = { - authenticationMethod: AuthenticationMethod.ENCRYPTION_KEY, - rawKey: '41e8e8593108eeedcbded883b8af34d2f028710355c57f4c10a056b72486aa04', -} - -describe(getContactMatches, () => { - afterEach(() => { - fetchMock.reset() - }) - - it('Retrieves matches correctly', async () => { - fetchMock.mock(endpoint, { - success: true, - matchedContacts: [{ phoneNumber: obfuscateNumberForMatchmaking(mockE164Number2) }], - }) - - await expect( - getContactMatches( - mockE164Number, - mockContacts, - mockAccount, - mockAccount, - authSigner, - serviceContext, - authSigner - ) - ).resolves.toMatchObject([mockE164Number2]) - }) - - it('Throws quota error', async () => { - fetchMock.mock(endpoint, 403) - - await expect( - getContactMatches( - mockE164Number, - mockContacts, - mockAccount, - mockAccount, - authSigner, - serviceContext - ) - ).rejects.toThrow(ErrorMessages.ODIS_QUOTA_ERROR) - }) - - it('Throws auth error', async () => { - fetchMock.mock(endpoint, 401) - await expect( - getContactMatches( - mockE164Number, - mockContacts, - mockAccount, - mockAccount, - authSigner, - serviceContext - ) - ).rejects.toThrow(ErrorMessages.ODIS_AUTH_ERROR) - }) -}) - -describe(obfuscateNumberForMatchmaking, () => { - it('Hashes a number correctly', () => { - expect(obfuscateNumberForMatchmaking(mockE164Number)).toBe( - '2sLQ49R4yTFxeknNQRQEj01WcCx3kLQam29TFbrXcxU=' - ) - }) -}) diff --git a/packages/sdk/identity/src/odis/matchmaking.ts b/packages/sdk/identity/src/odis/matchmaking.ts deleted file mode 100644 index 523637adbac..00000000000 --- a/packages/sdk/identity/src/odis/matchmaking.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { CombinerEndpoint } from '@celo/phone-number-privacy-common' -import { E164Number } from '@celo/phone-utils/lib/io' -import crypto from 'crypto' -import debugFactory from 'debug' -import { - AuthenticationMethod, - AuthSigner, - EncryptionKeySigner, - MatchmakingRequest, - MatchmakingResponse, - queryOdis, - ServiceContext, - signWithDEK, -} from './query' - -const debug = debugFactory('kit:odis:matchmaking') - -// Eventually, the matchmaking process will use blinded numbers same as salt lookups -// But for now numbers are simply hashed using this static salt -const SALT = '__celo__' - -// Uses the phone number privacy service to find mutual matches between Celo users -export async function getContactMatches( - e164NumberCaller: E164Number, - e164NumberContacts: E164Number[], - account: string, - phoneNumberIdentifier: string, - signer: AuthSigner, - context: ServiceContext, - dekSigner?: EncryptionKeySigner, - clientVersion?: string, - sessionID?: string -): Promise { - const selfPhoneNumObfuscated = obfuscateNumberForMatchmaking(e164NumberCaller) - const obfucsatedNumToE164Number = getContactNumsObfuscated(e164NumberContacts) - - const body: MatchmakingRequest = { - account, - userPhoneNumber: selfPhoneNumObfuscated, - contactPhoneNumbers: Object.keys(obfucsatedNumToE164Number), - hashedPhoneNumber: phoneNumberIdentifier, - version: clientVersion ? clientVersion : 'unknown', - authenticationMethod: signer.authenticationMethod, - } - - if (sessionID) { - body.sessionID = sessionID - } - - if (signer.authenticationMethod === AuthenticationMethod.ENCRYPTION_KEY) { - dekSigner = signer as EncryptionKeySigner - } - - if (dekSigner) { - body.signedUserPhoneNumber = signWithDEK(selfPhoneNumObfuscated, dekSigner) - } else { - console.warn('Failure to provide DEK will prevent users from requerying their matches') - } - - const response = await queryOdis( - signer, - body, - context, - CombinerEndpoint.MATCHMAKING - ) - - const matchHashes: string[] = response.matchedContacts.map( - (match: { phoneNumber: string }) => match.phoneNumber - ) - - if (!matchHashes || !matchHashes.length) { - debug('No matches found') - return [] - } - - return getMatchedContacts(obfucsatedNumToE164Number, matchHashes) -} - -function getContactNumsObfuscated(e164NumberMatches: E164Number[]) { - const hashes: Record = {} - for (const e164Number of e164NumberMatches) { - // TODO For large contact lists, would be faster to these hashes - // in a native module. - const hash = obfuscateNumberForMatchmaking(e164Number) - hashes[hash] = e164Number - } - return hashes -} - -// Hashes the phone number using a static salt -// This is different than the phone + unique salt hashing that -// we use for numbers getting verified and going on chain -// Matchmaking doesn't support per-number salts yet -export function obfuscateNumberForMatchmaking(e164Number: string) { - return crypto - .createHash('sha256') - .update(e164Number + SALT) - .digest('base64') -} - -/** - * Constructs a mapping between contact's phone numbers and - * their on-chain identifier - * @param obfucsatedNumToE164Number map of obfuscated number to original number - * @param matchHashes list of obfuscated numbers that are matched - */ -function getMatchedContacts( - obfucsatedNumToE164Number: Record, - matchHashes: string[] -): E164Number[] { - const matches: E164Number[] = [] - for (const match of matchHashes) { - const e164Number = obfucsatedNumToE164Number[match] - if (!e164Number) { - throw new Error('Number missing in hash map, should never happen') - } - matches.push(e164Number) - } - return matches -} diff --git a/packages/sdk/identity/src/odis/phone-number-identifier.test.ts b/packages/sdk/identity/src/odis/phone-number-identifier.test.ts index cf697bb465c..dc244d918e4 100644 --- a/packages/sdk/identity/src/odis/phone-number-identifier.test.ts +++ b/packages/sdk/identity/src/odis/phone-number-identifier.test.ts @@ -1,3 +1,4 @@ +import { Endpoint } from '@celo/phone-number-privacy-common' import { WasmBlsBlindingClient } from './bls-blinding-client' import { getBlindedIdentifier, @@ -32,7 +33,7 @@ const serviceContext: ServiceContext = { odisPubKey: '7FsWGsFnmVvRfMDpzz95Np76wf/1sPaK0Og9yiB+P8QbjiC8FV67NBans9hzZEkBaQMhiapzgMR6CkZIZPvgwQboAxl65JWRZecGe5V3XO4sdKeNemdAZ2TzQuWkuZoA', } -const endpoint = serviceContext.odisUrl + '/getBlindedMessageSig' +const endpoint = serviceContext.odisUrl + Endpoint.PNP_SIGN const rawKey = '41e8e8593108eeedcbded883b8af34d2f028710355c57f4c10a056b72486aa04' const authSigner: EncryptionKeySigner = { @@ -57,7 +58,10 @@ describe(getPhoneNumberIdentifier, () => { it('Using EncryptionKeySigner', async () => { fetchMock.mock(endpoint, { success: true, - combinedSignature: '0Uj+qoAu7ASMVvm6hvcUGx2eO/cmNdyEgGn0mSoZH8/dujrC1++SZ1N6IP6v2I8A', + signature: '0Uj+qoAu7ASMVvm6hvcUGx2eO/cmNdyEgGn0mSoZH8/dujrC1++SZ1N6IP6v2I8A', + performedQueryCount: 5, + totalQuota: 10, + version: '', }) const blsBlindingClient = new WasmBlsBlindingClient(serviceContext.odisPubKey) @@ -83,7 +87,10 @@ describe(getPhoneNumberIdentifier, () => { it('Preblinding the phone number', async () => { fetchMock.mock(endpoint, { success: true, - combinedSignature: '0Uj+qoAu7ASMVvm6hvcUGx2eO/cmNdyEgGn0mSoZH8/dujrC1++SZ1N6IP6v2I8A', + signature: '0Uj+qoAu7ASMVvm6hvcUGx2eO/cmNdyEgGn0mSoZH8/dujrC1++SZ1N6IP6v2I8A', + performedQueryCount: 5, + totalQuota: 10, + version: '', }) const blsBlindingClient = new WasmBlsBlindingClient(serviceContext.odisPubKey) diff --git a/packages/sdk/identity/src/odis/phone-number-identifier.ts b/packages/sdk/identity/src/odis/phone-number-identifier.ts index d2731b82430..57e3aa03a3c 100644 --- a/packages/sdk/identity/src/odis/phone-number-identifier.ts +++ b/packages/sdk/identity/src/odis/phone-number-identifier.ts @@ -34,7 +34,6 @@ export async function getPhoneNumberIdentifier( signer: AuthSigner, context: ServiceContext, blindingFactor?: string, - selfPhoneHash?: string, clientVersion?: string, blsBlindingClient?: BlsBlindingClient, sessionID?: string @@ -66,7 +65,6 @@ export async function getPhoneNumberIdentifier( signer, context, base64BlindedMessage, - selfPhoneHash, clientVersion, sessionID ) diff --git a/packages/sdk/identity/src/odis/query.ts b/packages/sdk/identity/src/odis/query.ts index 68fd0183cfd..90af37858d7 100644 --- a/packages/sdk/identity/src/odis/query.ts +++ b/packages/sdk/identity/src/odis/query.ts @@ -1,21 +1,19 @@ -import { hexToBuffer } from '@celo/base/lib/address' import { selectiveRetryAsyncWithBackOff } from '@celo/base/lib/async' import { ContractKit } from '@celo/contractkit' import { AuthenticationMethod, CombinerEndpoint, - Domain, DomainEndpoint, DomainRequest, + DomainRequestHeader, DomainResponse, - GetBlindedMessageSigRequest, - GetContactMatchesRequest, - GetContactMatchesResponse, - PhoneNumberPrivacyEndpoint, + OdisRequest, + OdisRequestHeader, + OdisResponse, PhoneNumberPrivacyRequest, + signWithRawKey, } from '@celo/phone-number-privacy-common' import fetch from 'cross-fetch' -import crypto from 'crypto' import debugFactory from 'debug' import { isLeft } from 'fp-ts/lib/Either' import * as t from 'io-ts' @@ -36,19 +34,7 @@ export interface EncryptionKeySigner { export type AuthSigner = WalletKeySigner | EncryptionKeySigner // Re-export types and aliases to maintain backwards compatibility. -export { AuthenticationMethod, PhoneNumberPrivacyRequest } -export type SignMessageRequest = GetBlindedMessageSigRequest -export type MatchmakingRequest = GetContactMatchesRequest -export type MatchmakingResponse = GetContactMatchesResponse - -// Combiner returns a response inconsistent with the SignMessageResponse defined in -// @celo/phone-number-privacy-common. Combiner response type is defined here as a result. -export interface CombinerSignMessageResponse { - success: boolean - combinedSignature: string -} -/** @deprecated Exported as SignMessageResponse for backwards compatibility. */ -export type SignMessageResponse = CombinerSignMessageResponse +export { AuthenticationMethod, PhoneNumberPrivacyRequest, signWithRawKey } export enum ErrorMessages { ODIS_QUOTA_ERROR = 'odisQuotaError', @@ -98,50 +84,38 @@ export function signWithDEK(msg: string, signer: EncryptionKeySigner) { return signWithRawKey(msg, signer.rawKey) } -export function signWithRawKey(msg: string, rawKey: string) { - // NOTE: Elliptic will truncate the raw msg to 64 bytes before signing, - // so make sure to always pass the hex encoded msgDigest instead. - const msgDigest = crypto.createHash('sha256').update(JSON.stringify(msg)).digest('hex') - - // NOTE: elliptic is disabled elsewhere in this library to prevent - // accidental signing of truncated messages. - // tslint:disable-next-line:import-blacklist - const EC = require('elliptic').ec - const ec = new EC('secp256k1') - - // Sign - const key = ec.keyFromPrivate(hexToBuffer(rawKey)) - return JSON.stringify(key.sign(msgDigest).toDER()) +export async function getOdisPnpRequestAuth( + body: PhoneNumberPrivacyRequest, + signer: AuthSigner +): Promise { + // Sign payload using provided account and authentication method. + const bodyString = JSON.stringify(body) + if (signer.authenticationMethod === AuthenticationMethod.ENCRYPTION_KEY) { + return signWithDEK(bodyString, signer as EncryptionKeySigner) + } + if (signer.authenticationMethod === AuthenticationMethod.WALLET_KEY) { + return signer.contractKit.connection.sign(bodyString, body.account) + } + throw new Error('AuthenticationMethod not supported') } /** - * Make a request to lookup the phone number identifier or perform matchmaking - * @param signer Type of key to sign with. May be undefined if the request is presigned. - * @param body Request to send in the body of the HTTP request. + * Send an OdisRequest to ODIS + * @param body OdisRequest to send in the body of the HTTP request. * @param context Contains service URL and public to determine which instance to contact. - * @param endpoint Endpoint to query (e.g. '/getBlindedMessagePartialSig', '/getContactMatches'). + * @param endpoint Endpoint to query + * @param responseSchema io-ts schema to ensure type safety of responses + * @param headers custom request headers corresponding to the type of OdisRequest (keyVersion, Authentication, etc.) */ -export async function queryOdis( - signer: AuthSigner, - body: PhoneNumberPrivacyRequest, +export async function queryOdis( + body: R, context: ServiceContext, - endpoint: PhoneNumberPrivacyEndpoint | CombinerEndpoint -): Promise { + endpoint: CombinerEndpoint, + responseSchema: t.Type>, + headers: OdisRequestHeader +): Promise> { debug(`Posting to ${endpoint}`) - const bodyString = JSON.stringify(body) - - // Sign payload using provided account and authentication method. - let signature: string - if (signer.authenticationMethod === AuthenticationMethod.ENCRYPTION_KEY) { - signature = signWithDEK(bodyString, signer as EncryptionKeySigner) - } else if (signer.authenticationMethod === AuthenticationMethod.WALLET_KEY) { - const account = body.account - signature = await signer.contractKit.connection.sign(bodyString, account) - } - - const { odisUrl } = context - const dontRetry = [ ErrorMessages.ODIS_QUOTA_ERROR, ErrorMessages.ODIS_RATE_LIMIT_ERROR, @@ -154,14 +128,14 @@ export async function queryOdis( async () => { let res: Response try { - res = await fetch(odisUrl + endpoint, { + res = await fetch(context.odisUrl + endpoint, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', - Authorization: signature, + ...headers, }, - body: bodyString, + body: JSON.stringify(body), }) } catch (error) { throw new Error(`${ErrorMessages.ODIS_FETCH_ERROR}: ${error}`) @@ -170,7 +144,13 @@ export async function queryOdis( if (res.ok) { debug('Response ok. Parsing.') const response = await res.json() - return response as ResponseType + + // Verify that the response is the type we expected, then return it. + const decoding = responseSchema.decode(response) + if (isLeft(decoding)) { + throw new Error(ErrorMessages.ODIS_RESPONSE_ERROR) + } + return decoding.right } debug(`Response not okay. Status ${res.status}`) @@ -205,76 +185,20 @@ export async function queryOdis( * @param context Contains service URL and public to determine which instance to contact. * @param endpoint Endpoint to query (e.g. '/domain/sign', '/domain/quotaStatus'). * @param responseSchema io-ts type for the expected response type. Provided to ensure type safety. + * @param headers optional header fields relevant to the given request type (keyVersion, Authentication, etc.) */ -export async function sendOdisDomainRequest>( - body: RequestType, +export async function sendOdisDomainRequest( + body: R, context: ServiceContext, endpoint: DomainEndpoint, - responseSchema: t.Type> -): Promise> { - debug(`Posting to ${endpoint}`) - - const bodyString = JSON.stringify(body) - - const { odisUrl } = context - - const dontRetry = [ - ErrorMessages.ODIS_QUOTA_ERROR, - ErrorMessages.ODIS_RATE_LIMIT_ERROR, - ErrorMessages.ODIS_AUTH_ERROR, - ErrorMessages.ODIS_INPUT_ERROR, - ErrorMessages.ODIS_CLIENT_ERROR, - ] - - return selectiveRetryAsyncWithBackOff( - async () => { - let res: Response - try { - res = await fetch(odisUrl + endpoint, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: bodyString, - }) - } catch (error) { - throw new Error(`${ErrorMessages.ODIS_FETCH_ERROR}: ${error}`) - } - - if (res.ok) { - debug('Response ok. Parsing.') - const response = await res.json() - - // Verify that the response is the type we expected, then return it. - const decoding = responseSchema.decode(response) - if (isLeft(decoding)) { - throw new Error(ErrorMessages.ODIS_RESPONSE_ERROR) - } - return decoding.right - } - - debug(`Response not okay. Status ${res.status}`) - - switch (res.status) { - case 403: - throw new Error(ErrorMessages.ODIS_QUOTA_ERROR) - case 429: - throw new Error(ErrorMessages.ODIS_RATE_LIMIT_ERROR) - case 400: - throw new Error(ErrorMessages.ODIS_INPUT_ERROR) - case 401: - throw new Error(ErrorMessages.ODIS_AUTH_ERROR) - default: - if (res.status >= 400 && res.status < 500) { - // Don't retry error codes in 400s - throw new Error(`${ErrorMessages.ODIS_CLIENT_ERROR} ${res.status}`) - } - throw new Error(`Unknown failure ${res.status}`) - } - }, - 3, - dontRetry, - [] - ) + responseSchema: t.Type>, + headers?: DomainRequestHeader +): Promise> { + return queryOdis( + body, + context, + endpoint, + responseSchema, + headers as OdisRequestHeader + ) as Promise> } diff --git a/packages/sdk/identity/src/odis/quota.test.ts b/packages/sdk/identity/src/odis/quota.test.ts new file mode 100644 index 00000000000..7bafddbf022 --- /dev/null +++ b/packages/sdk/identity/src/odis/quota.test.ts @@ -0,0 +1,54 @@ +import { AuthenticationMethod, CombinerEndpoint } from '@celo/phone-number-privacy-common' +import { EncryptionKeySigner, ServiceContext } from './query' +import { getPnpQuotaStatus, PnpClientQuotaStatus } from './quota' + +const mockAccount = '0x0000000000000000000000000000000000007E57' +const serviceContext: ServiceContext = { + odisUrl: 'https://mockodis.com', + odisPubKey: + '7FsWGsFnmVvRfMDpzz95Np76wf/1sPaK0Og9yiB+P8QbjiC8FV67NBans9hzZEkBaQMhiapzgMR6CkZIZPvgwQboAxl65JWRZecGe5V3XO4sdKeNemdAZ2TzQuWkuZoA', +} +const endpoint = serviceContext.odisUrl + CombinerEndpoint.PNP_QUOTA +const rawKey = '41e8e8593108eeedcbded883b8af34d2f028710355c57f4c10a056b72486aa04' + +const authSigner: EncryptionKeySigner = { + authenticationMethod: AuthenticationMethod.ENCRYPTION_KEY, + rawKey, +} + +describe(getPnpQuotaStatus, () => { + afterEach(() => { + fetchMock.reset() + }) + it('returns the correct remaining quota amount', async () => { + const performedQueryCount = 5 + const totalQuota = 10 + const version = '' + fetchMock.mock(endpoint, { + success: true, + totalQuota, + performedQueryCount, + version, + }) + + await expect( + getPnpQuotaStatus(mockAccount, authSigner, serviceContext) + ).resolves.toStrictEqual({ + performedQueryCount, + totalQuota, + remainingQuota: totalQuota - performedQueryCount, + version, + warnings: undefined, + blockNumber: undefined, + }) + }) + + it('throws quota error on failure response', async () => { + fetchMock.mock(endpoint, { + success: false, + version: '', + }) + + await expect(getPnpQuotaStatus(mockAccount, authSigner, serviceContext)).rejects.toThrow() + }) +}) diff --git a/packages/sdk/identity/src/odis/quota.ts b/packages/sdk/identity/src/odis/quota.ts new file mode 100644 index 00000000000..374a97158c3 --- /dev/null +++ b/packages/sdk/identity/src/odis/quota.ts @@ -0,0 +1,54 @@ +import { Address } from '@celo/base' +import { + CombinerEndpoint, + PnpQuotaRequest, + PnpQuotaResponseSchema, +} from '@celo/phone-number-privacy-common' +import { AuthSigner, getOdisPnpRequestAuth, queryOdis, ServiceContext } from './query' + +export interface PnpClientQuotaStatus { + version: string + performedQueryCount: number + totalQuota: number + remainingQuota: number + blockNumber?: number + warnings?: string[] +} + +export async function getPnpQuotaStatus( + account: Address, + signer: AuthSigner, + context: ServiceContext, + clientVersion?: string, + sessionID?: string +): Promise { + const body: PnpQuotaRequest = { + account, + version: clientVersion, + authenticationMethod: signer.authenticationMethod, + sessionID, + } + + const response = await queryOdis( + body, + context, + CombinerEndpoint.PNP_QUOTA, + PnpQuotaResponseSchema, + { + Authorization: await getOdisPnpRequestAuth(body, signer), + } + ) + + if (response.success) { + return { + version: response.version, + performedQueryCount: response.performedQueryCount, + totalQuota: response.totalQuota, + remainingQuota: response.totalQuota - response.performedQueryCount, + warnings: response.warnings, + blockNumber: response.blockNumber, + } + } + + throw new Error(response.error) +} diff --git a/yarn.lock b/yarn.lock index 25028fe9287..41bb59286d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1324,6 +1324,11 @@ resolved "https://registry.yarnpkg.com/@celo/base/-/base-1.5.1.tgz#53e16cd36c51f9eaeec0321f6752de6385f2a131" integrity sha512-76MAosahwCDjkBsqfgnKT2CbyjV6TdzIztHJvAuJ+VrKeaIFe/IMoPwIxPy95xDJmHhD0zqPWMixGeyVGAwYQw== +"@celo/base@1.5.2": + version "1.5.2" + resolved "https://registry.yarnpkg.com/@celo/base/-/base-1.5.2.tgz#168ab5e4e30b374079d8d139fafc52ca6bfd4100" + integrity sha512-KGf6Dl9E6D01vAfkgkjL2sG+zqAjspAogILIpWstljWdG5ifyA75jihrnDEHaMCoQS0KxHvTdP1XYS/GS6BEyQ== + "@celo/base@2.3.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@celo/base/-/base-2.3.0.tgz#a6369473200d5cc7e856a2a95a3f4d2fddfbfc6b" @@ -1337,6 +1342,18 @@ "@stablelib/blake2xs" "0.10.4" big-integer "^1.6.44" +"@celo/connect@1.5.2": + version "1.5.2" + resolved "https://registry.yarnpkg.com/@celo/connect/-/connect-1.5.2.tgz#09f0b03bda6f8a6d523fd010492f204cbe82aabd" + integrity sha512-IHsvYp1HizIPfPPeIHyvsmJytIf7HNtNWo9CqCbsqfNfmw53q6dFJu2p5X0qz/fUnR5840cUga8cEyuYZTfp+w== + dependencies: + "@celo/utils" "1.5.2" + "@types/debug" "^4.1.5" + "@types/utf8" "^2.1.6" + bignumber.js "^9.0.0" + debug "^4.1.1" + utf8 "3.0.0" + "@celo/connect@2.3.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@celo/connect/-/connect-2.3.0.tgz#6cc853c7241717ce53de94a0479ac7b5e8e56d44" @@ -1350,6 +1367,24 @@ debug "^4.1.1" utf8 "3.0.0" +"@celo/contractkit@1.5.2": + version "1.5.2" + resolved "https://registry.yarnpkg.com/@celo/contractkit/-/contractkit-1.5.2.tgz#be15d570f3044a190dabb6bbe53d5081c78ea605" + integrity sha512-b0r5TlfYDEscxze1Ai2jyJayiVElA9jvEehMD6aOSNtVhfP8oirjFIIffRe0Wzw1MSDGkw+q1c4m0Yw5sEOlvA== + dependencies: + "@celo/base" "1.5.2" + "@celo/connect" "1.5.2" + "@celo/utils" "1.5.2" + "@celo/wallet-local" "1.5.2" + "@types/debug" "^4.1.5" + bignumber.js "^9.0.0" + cross-fetch "^3.0.6" + debug "^4.1.1" + fp-ts "2.1.1" + io-ts "2.0.1" + semver "^7.3.5" + web3 "1.3.6" + "@celo/contractkit@2.3.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@celo/contractkit/-/contractkit-2.3.0.tgz#b34a0b8cca8daf2242738214a6ed88f818f0af92" @@ -1437,10 +1472,28 @@ fp-ts "2.1.1" io-ts "2.0.1" -"@celo/poprf@^0.1.6": - version "0.1.6" - resolved "https://registry.yarnpkg.com/@celo/poprf/-/poprf-0.1.6.tgz#f8960a454b027910741595ecd3fcc067838c16e1" - integrity sha512-zWcChteh5tcNr2s85gDNnz6hGvVF3NK1c5qlMPrMn70KWiy7d0YzXzkNYCjL99OQn1SUx+SD9RtZ2l48ByAtKw== +"@celo/phone-number-privacy-common@1.0.39": + version "1.0.39" + resolved "https://registry.yarnpkg.com/@celo/phone-number-privacy-common/-/phone-number-privacy-common-1.0.39.tgz#3c9568f70378d24d11afcc4306024c5cf4f8efe9" + integrity sha512-0sbeuoYCN2ZQYO1CryR0Hf9HhOQKuIDZraWFMpUlwrUKk5qKmSMlV16xobG4VL5qUpXHgIRjKPfmcaf0rkrn8A== + dependencies: + "@celo/base" "1.5.2" + "@celo/contractkit" "1.5.2" + "@celo/utils" "1.5.2" + bignumber.js "^9.0.0" + blind-threshold-bls "https://github.com/celo-org/blind-threshold-bls-wasm#e1e2f8a" + btoa "1.2.1" + bunyan "1.8.12" + bunyan-debug-stream "2.0.0" + bunyan-gke-stackdriver "0.1.2" + dotenv "^8.2.0" + elliptic "^6.5.4" + is-base64 "^1.1.0" + +"@celo/poprf@^0.1.9": + version "0.1.9" + resolved "https://registry.yarnpkg.com/@celo/poprf/-/poprf-0.1.9.tgz#38c514ce0f572b80edeb9dc280b6cf5e9d7c2a75" + integrity sha512-+993EA/W+TBCZyY5G0B2EVdXnPX6t2AldgRAIMaT9WIqTwZKi/TcdJDUQl8mj7HEHMPHlpgCBOVgaHkUcwo/5A== "@celo/typechain-target-web3-v1-celo@0.2.0": version "0.2.0" @@ -1483,6 +1536,39 @@ web3-eth-abi "1.3.6" web3-utils "1.3.6" +"@celo/utils@1.5.2": + version "1.5.2" + resolved "https://registry.yarnpkg.com/@celo/utils/-/utils-1.5.2.tgz#ddb7f3b50c801225ab41d2355fbe010976329099" + integrity sha512-JyKjuVMbdkyFOb1TpQw6zqamPQWYg7I9hOnva3MeIcQ3ZrJIaNHx0/I+JXFjuu3YYBc1mG8nXp2uPJJTGrwzCQ== + dependencies: + "@celo/base" "1.5.2" + "@types/country-data" "^0.0.0" + "@types/elliptic" "^6.4.9" + "@types/ethereumjs-util" "^5.2.0" + "@types/google-libphonenumber" "^7.4.17" + "@types/lodash" "^4.14.170" + "@types/node" "^10.12.18" + "@types/randombytes" "^2.0.0" + bigi "^1.1.0" + bignumber.js "^9.0.0" + bip32 "2.0.5" + bip39 "https://github.com/bitcoinjs/bip39#d8ea080a18b40f301d4e2219a2991cd2417e83c2" + bls12377js "https://github.com/celo-org/bls12377js#cb38a4cfb643c778619d79b20ca3e5283a2122a6" + bn.js "4.11.8" + buffer-reverse "^1.0.1" + country-data "^0.0.31" + crypto-js "^3.1.9-1" + elliptic "^6.5.4" + ethereumjs-util "^5.2.0" + fp-ts "2.1.1" + google-libphonenumber "^3.2.15" + io-ts "2.0.1" + keccak256 "^1.0.0" + lodash "^4.17.21" + numeral "^2.0.6" + web3-eth-abi "1.3.6" + web3-utils "1.3.6" + "@celo/utils@2.3.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@celo/utils/-/utils-2.3.0.tgz#bd143a8c75bc78e47c4bfb2aa7ca30f9c629bee7" @@ -1500,6 +1586,21 @@ web3-eth-abi "1.3.6" web3-utils "1.3.6" +"@celo/wallet-base@1.5.2": + version "1.5.2" + resolved "https://registry.yarnpkg.com/@celo/wallet-base/-/wallet-base-1.5.2.tgz#ae8df425bf3c702277bb1b63a761a2ec8429e7aa" + integrity sha512-NYJu7OtSRFpGcvSMl2Wc8zN32S6oTkAzKqhH7rXisQ0I2q4yNwCzoquzPVYB0G2UVUFKuuxgsA5V+Zda/LQCyw== + dependencies: + "@celo/base" "1.5.2" + "@celo/connect" "1.5.2" + "@celo/utils" "1.5.2" + "@types/debug" "^4.1.5" + "@types/ethereumjs-util" "^5.2.0" + bignumber.js "^9.0.0" + debug "^4.1.1" + eth-lib "^0.2.8" + ethereumjs-util "^5.2.0" + "@celo/wallet-base@2.3.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@celo/wallet-base/-/wallet-base-2.3.0.tgz#85a3410b5d812b6a699c506ee2d65ff14b88a654" @@ -1515,6 +1616,18 @@ eth-lib "^0.2.8" ethereumjs-util "^5.2.0" +"@celo/wallet-local@1.5.2": + version "1.5.2" + resolved "https://registry.yarnpkg.com/@celo/wallet-local/-/wallet-local-1.5.2.tgz#66ea5fb763e19724309e3d56f312f1a342e12b91" + integrity sha512-Aas4SwqQc8ap0OFAOZc+jBR4cXr20V9AReHNEI8Y93R3g1+RlSEJ1Zmsu4vN+Rriz58YqgMnr+pihorw8QydFQ== + dependencies: + "@celo/connect" "1.5.2" + "@celo/utils" "1.5.2" + "@celo/wallet-base" "1.5.2" + "@types/ethereumjs-util" "^5.2.0" + eth-lib "^0.2.8" + ethereumjs-util "^5.2.0" + "@celo/wallet-local@2.3.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@celo/wallet-local/-/wallet-local-2.3.0.tgz#fca3adfcbaebc6951a0251e510d59a658a9d0eb9" @@ -1538,7 +1651,7 @@ "@ethersproject/abi@5.0.0-beta.142": version "5.0.0-beta.142" resolved "https://registry.yarnpkg.com/@ethersproject/abi/-/abi-5.0.0-beta.142.tgz#cde0ced7daa2fbc98e35a2c31203331907e84a39" - integrity sha512-vJ2V9fPNzi+8iutY4sjy6mgogkJtiGsd9hmpa1bjnGW6qnHOEkAV1fzVpvT002LlnjFgqgtzuLBDZob6oU7i8w== + integrity "sha1-zeDO19qi+8mONaLDEgMzGQfoSjk= sha512-vJ2V9fPNzi+8iutY4sjy6mgogkJtiGsd9hmpa1bjnGW6qnHOEkAV1fzVpvT002LlnjFgqgtzuLBDZob6oU7i8w==" dependencies: "@ethersproject/address" ">=5.0.0-beta.128" "@ethersproject/bignumber" ">=5.0.0-beta.130" @@ -1581,17 +1694,17 @@ "@ethersproject/strings" "^5.0.4" "@ethersproject/abstract-provider@^5.0.8": - version "5.0.10" - resolved "https://registry.yarnpkg.com/@ethersproject/abstract-provider/-/abstract-provider-5.0.10.tgz#a533aed39a5f27312745c8c4c40fa25fc884831c" - integrity sha512-OSReY5iz94iIaPlRvLiJP8YVIvQLx4aUvMMnHWSaA/vTU8QHZmgNlt4OBdYV1+aFY8Xl+VRYiWBHq72ZDKXXCQ== + version "5.5.1" + resolved "https://registry.yarnpkg.com/@ethersproject/abstract-provider/-/abstract-provider-5.5.1.tgz#2f1f6e8a3ab7d378d8ad0b5718460f85649710c5" + integrity sha512-m+MA/ful6eKbxpr99xUYeRvLkfnlqzrF8SZ46d/xFB1A7ZVknYc/sXJG0RcufF52Qn2jeFj1hhcoQ7IXjNKUqg== dependencies: - "@ethersproject/bignumber" "^5.0.13" - "@ethersproject/bytes" "^5.0.9" - "@ethersproject/logger" "^5.0.8" - "@ethersproject/networks" "^5.0.7" - "@ethersproject/properties" "^5.0.7" - "@ethersproject/transactions" "^5.0.9" - "@ethersproject/web" "^5.0.12" + "@ethersproject/bignumber" "^5.5.0" + "@ethersproject/bytes" "^5.5.0" + "@ethersproject/logger" "^5.5.0" + "@ethersproject/networks" "^5.5.0" + "@ethersproject/properties" "^5.5.0" + "@ethersproject/transactions" "^5.5.0" + "@ethersproject/web" "^5.5.0" "@ethersproject/abstract-signer@^5.0.10": version "5.0.14" @@ -1604,17 +1717,16 @@ "@ethersproject/logger" "^5.0.8" "@ethersproject/properties" "^5.0.7" -"@ethersproject/address@>=5.0.0-beta.128", "@ethersproject/address@^5.0.4": - version "5.0.4" - resolved "https://registry.yarnpkg.com/@ethersproject/address/-/address-5.0.4.tgz#8669bcbd02f4b64f4cede0a10e84df6d964ec9d3" - integrity sha512-CIjAeG6zNehbpJTi0sgwUvaH2ZICiAV9XkCBaFy5tjuEVFpQNeqd6f+B7RowcNO7Eut+QbhcQ5CVLkmP5zhL9A== +"@ethersproject/address@>=5.0.0-beta.128", "@ethersproject/address@^5.0.4", "@ethersproject/address@^5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@ethersproject/address/-/address-5.5.0.tgz#bcc6f576a553f21f3dd7ba17248f81b473c9c78f" + integrity sha512-l4Nj0eWlTUh6ro5IbPTgbpT4wRbdH5l8CQf7icF7sb/SI3Nhd9Y9HzhonTSTi6CefI0necIw7LJqQPopPLZyWw== dependencies: - "@ethersproject/bignumber" "^5.0.7" - "@ethersproject/bytes" "^5.0.4" - "@ethersproject/keccak256" "^5.0.3" - "@ethersproject/logger" "^5.0.5" - "@ethersproject/rlp" "^5.0.3" - bn.js "^4.4.0" + "@ethersproject/bignumber" "^5.5.0" + "@ethersproject/bytes" "^5.5.0" + "@ethersproject/keccak256" "^5.5.0" + "@ethersproject/logger" "^5.5.0" + "@ethersproject/rlp" "^5.5.0" "@ethersproject/address@^5.0.9": version "5.0.11" @@ -1627,12 +1739,12 @@ "@ethersproject/logger" "^5.0.8" "@ethersproject/rlp" "^5.0.7" -"@ethersproject/base64@^5.0.7": - version "5.0.9" - resolved "https://registry.yarnpkg.com/@ethersproject/base64/-/base64-5.0.9.tgz#bb1f35d3dba92082a574d5e2418f9202a0a1a7e6" - integrity sha512-37RBz5LEZ9SlTNGiWCYFttnIN9J7qVs9Xo2EbqGqDH5LfW9EIji66S+YDMpXVo1zWDax1FkEldAoatxHK2gfgA== +"@ethersproject/base64@^5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@ethersproject/base64/-/base64-5.5.0.tgz#881e8544e47ed976930836986e5eb8fab259c090" + integrity sha512-tdayUKhU1ljrlHzEWbStXazDpsx4eg1dBXUSI6+mHlYklOXoXF6lZvw8tnD6oVaWfnMxAgRSKROg3cVKtCcppA== dependencies: - "@ethersproject/bytes" "^5.0.9" + "@ethersproject/bytes" "^5.5.0" "@ethersproject/bignumber@>=5.0.0-beta.130", "@ethersproject/bignumber@^5.0.7": version "5.0.7" @@ -1652,6 +1764,15 @@ "@ethersproject/logger" "^5.0.8" bn.js "^4.4.0" +"@ethersproject/bignumber@^5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@ethersproject/bignumber/-/bignumber-5.5.0.tgz#875b143f04a216f4f8b96245bde942d42d279527" + integrity sha512-6Xytlwvy6Rn3U3gKEc1vP7nR92frHkv6wtVr95LFR3jREXiCPzdWxKQ1cx4JGQBXxcguAwjA8murlYN2TSiEbg== + dependencies: + "@ethersproject/bytes" "^5.5.0" + "@ethersproject/logger" "^5.5.0" + bn.js "^4.11.9" + "@ethersproject/bytes@>=5.0.0-beta.129", "@ethersproject/bytes@^5.0.4": version "5.0.4" resolved "https://registry.yarnpkg.com/@ethersproject/bytes/-/bytes-5.0.4.tgz#328d9d929a3e970964ecf5d62e12568a187189f1" @@ -1666,6 +1787,13 @@ dependencies: "@ethersproject/logger" "^5.0.8" +"@ethersproject/bytes@^5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@ethersproject/bytes/-/bytes-5.5.0.tgz#cb11c526de657e7b45d2e0f0246fb3b9d29a601c" + integrity sha512-ABvc7BHWhZU9PNM/tANm/Qx4ostPGadAuQzWTr3doklZOhDlmcBqclrQe/ZXUIj3K8wC28oYeuRa+A37tX9kog== + dependencies: + "@ethersproject/logger" "^5.5.0" + "@ethersproject/constants@>=5.0.0-beta.128", "@ethersproject/constants@^5.0.4": version "5.0.4" resolved "https://registry.yarnpkg.com/@ethersproject/constants/-/constants-5.0.4.tgz#9ddaa5f3c738a94e5adc4b3f71b36206fa5cdf88" @@ -1680,6 +1808,13 @@ dependencies: "@ethersproject/bignumber" "^5.0.13" +"@ethersproject/constants@^5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@ethersproject/constants/-/constants-5.5.0.tgz#d2a2cd7d94bd1d58377d1d66c4f53c9be4d0a45e" + integrity sha512-2MsRRVChkvMWR+GyMGY4N1sAX9Mt3J9KykCsgUFd/1mwS0UH1qw+Bv9k1UJb3X3YJYFco9H20pjSlOIfCG5HYQ== + dependencies: + "@ethersproject/bignumber" "^5.5.0" + "@ethersproject/hash@>=5.0.0-beta.128": version "5.0.0-beta.133" resolved "https://registry.yarnpkg.com/@ethersproject/hash/-/hash-5.0.0-beta.133.tgz#bda0c74454a82359642033f27c5157963495fcdf" @@ -1720,6 +1855,14 @@ "@ethersproject/bytes" "^5.0.9" js-sha3 "0.5.7" +"@ethersproject/keccak256@^5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@ethersproject/keccak256/-/keccak256-5.5.0.tgz#e4b1f9d7701da87c564ffe336f86dcee82983492" + integrity sha512-5VoFCTjo2rYbBe1l2f4mccaRFN/4VQEYFwwn04aJV2h7qf4ZvI2wFxUE1XOX+snbwCLRzIeikOqtAoPwMza9kg== + dependencies: + "@ethersproject/bytes" "^5.5.0" + js-sha3 "0.8.0" + "@ethersproject/logger@>=5.0.0-beta.129", "@ethersproject/logger@^5.0.5": version "5.0.5" resolved "https://registry.yarnpkg.com/@ethersproject/logger/-/logger-5.0.5.tgz#e3ba3d0bcf9f5be4da5f043b1e328eb98b80002f" @@ -1730,12 +1873,17 @@ resolved "https://registry.yarnpkg.com/@ethersproject/logger/-/logger-5.0.10.tgz#fd884688b3143253e0356ef92d5f22d109d2e026" integrity sha512-0y2T2NqykDrbPM3Zw9RSbPkDOxwChAL8detXaom76CfYoGxsOnRP/zTX8OUAV+x9LdwzgbWvWmeXrc0M7SuDZw== -"@ethersproject/networks@^5.0.7": - version "5.0.9" - resolved "https://registry.yarnpkg.com/@ethersproject/networks/-/networks-5.0.9.tgz#ec5da11e4d4bfd69bec4eaebc9ace33eb9569279" - integrity sha512-L8+VCQwArBLGkxZb/5Ns/OH/OxP38AcaveXIxhUTq+VWpXYjrObG3E7RDQIKkUx1S1IcQl/UWTz5w4DK0UitJg== +"@ethersproject/logger@^5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@ethersproject/logger/-/logger-5.5.0.tgz#0c2caebeff98e10aefa5aef27d7441c7fd18cf5d" + integrity sha512-rIY/6WPm7T8n3qS2vuHTUBPdXHl+rGxWxW5okDfo9J4Z0+gRRZT0msvUdIJkE4/HS29GUMziwGaaKO2bWONBrg== + +"@ethersproject/networks@^5.5.0": + version "5.5.2" + resolved "https://registry.yarnpkg.com/@ethersproject/networks/-/networks-5.5.2.tgz#784c8b1283cd2a931114ab428dae1bd00c07630b" + integrity sha512-NEqPxbGBfy6O3x4ZTISb90SjEDkWYDUbEeIFhJly0F7sZjoQMnj5KYzMSkMkLKZ+1fGpx00EDpHQCy6PrDupkQ== dependencies: - "@ethersproject/logger" "^5.0.8" + "@ethersproject/logger" "^5.5.0" "@ethersproject/properties@>=5.0.0-beta.131", "@ethersproject/properties@^5.0.3": version "5.0.3" @@ -1751,6 +1899,13 @@ dependencies: "@ethersproject/logger" "^5.0.8" +"@ethersproject/properties@^5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@ethersproject/properties/-/properties-5.5.0.tgz#61f00f2bb83376d2071baab02245f92070c59995" + integrity sha512-l3zRQg3JkD8EL3CPjNK5g7kMx4qSwiR60/uk5IVjd3oq1MZR5qUg40CNOoEJoX5wc3DyY5bt9EbMk86C7x0DNA== + dependencies: + "@ethersproject/logger" "^5.5.0" + "@ethersproject/rlp@^5.0.3": version "5.0.3" resolved "https://registry.yarnpkg.com/@ethersproject/rlp/-/rlp-5.0.3.tgz#841a5edfdf725f92155fe74424f5510c9043c13a" @@ -1767,6 +1922,14 @@ "@ethersproject/bytes" "^5.0.9" "@ethersproject/logger" "^5.0.8" +"@ethersproject/rlp@^5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@ethersproject/rlp/-/rlp-5.5.0.tgz#530f4f608f9ca9d4f89c24ab95db58ab56ab99a0" + integrity sha512-hLv8XaQ8PTI9g2RHoQGf/WSxBfTB/NudRacbzdxmst5VHAqd1sMibWG7SENzT5Dj3yZ3kJYx+WiRYEcQTAkcYA== + dependencies: + "@ethersproject/bytes" "^5.5.0" + "@ethersproject/logger" "^5.5.0" + "@ethersproject/signing-key@^5.0.4": version "5.0.4" resolved "https://registry.yarnpkg.com/@ethersproject/signing-key/-/signing-key-5.0.4.tgz#a5334ce8a52d4e9736dc8fb6ecc384704ecf8783" @@ -1777,15 +1940,17 @@ "@ethersproject/properties" "^5.0.3" elliptic "6.5.3" -"@ethersproject/signing-key@^5.0.8": - version "5.0.11" - resolved "https://registry.yarnpkg.com/@ethersproject/signing-key/-/signing-key-5.0.11.tgz#19fc5c4597e18ad0a5efc6417ba5b74069fdd2af" - integrity sha512-Jfcru/BGwdkXhLxT+8WCZtFy7LL0TPFZw05FAb5asxB/MyVsEfNdNxGDtjVE9zXfmRSPe/EusXYY4K7wcygOyQ== +"@ethersproject/signing-key@^5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@ethersproject/signing-key/-/signing-key-5.5.0.tgz#2aa37169ce7e01e3e80f2c14325f624c29cedbe0" + integrity sha512-5VmseH7qjtNmDdZBswavhotYbWB0bOwKIlOTSlX14rKn5c11QmJwGt4GHeo7NrL/Ycl7uo9AHvEqs5xZgFBTng== dependencies: - "@ethersproject/bytes" "^5.0.9" - "@ethersproject/logger" "^5.0.8" - "@ethersproject/properties" "^5.0.7" + "@ethersproject/bytes" "^5.5.0" + "@ethersproject/logger" "^5.5.0" + "@ethersproject/properties" "^5.5.0" + bn.js "^4.11.9" elliptic "6.5.4" + hash.js "1.1.7" "@ethersproject/strings@>=5.0.0-beta.130": version "5.0.4" @@ -1805,6 +1970,15 @@ "@ethersproject/constants" "^5.0.8" "@ethersproject/logger" "^5.0.8" +"@ethersproject/strings@^5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@ethersproject/strings/-/strings-5.5.0.tgz#e6784d00ec6c57710755699003bc747e98c5d549" + integrity sha512-9fy3TtF5LrX/wTrBaT8FGE6TDJyVjOvXynXJz5MT5azq+E6D92zuKNx7i29sWW2FjVOaWjAsiZ1ZWznuduTIIQ== + dependencies: + "@ethersproject/bytes" "^5.5.0" + "@ethersproject/constants" "^5.5.0" + "@ethersproject/logger" "^5.5.0" + "@ethersproject/transactions@^5.0.0-beta.135": version "5.0.5" resolved "https://registry.yarnpkg.com/@ethersproject/transactions/-/transactions-5.0.5.tgz#9a966f9ef4817b1752265d4efee0f1e9fd6aeaad" @@ -1820,31 +1994,31 @@ "@ethersproject/rlp" "^5.0.3" "@ethersproject/signing-key" "^5.0.4" -"@ethersproject/transactions@^5.0.9": - version "5.0.11" - resolved "https://registry.yarnpkg.com/@ethersproject/transactions/-/transactions-5.0.11.tgz#b31df5292f47937136a45885d6ee6112477c13df" - integrity sha512-ftsRvR9+gQp7L63F6+XmstvsZ4w8GtWvQB08e/zB+oB86Fnhq8+i/tkgpJplSHC8I/qgiCisva+M3u2GVhDFPA== - dependencies: - "@ethersproject/address" "^5.0.9" - "@ethersproject/bignumber" "^5.0.13" - "@ethersproject/bytes" "^5.0.9" - "@ethersproject/constants" "^5.0.8" - "@ethersproject/keccak256" "^5.0.7" - "@ethersproject/logger" "^5.0.8" - "@ethersproject/properties" "^5.0.7" - "@ethersproject/rlp" "^5.0.7" - "@ethersproject/signing-key" "^5.0.8" - -"@ethersproject/web@^5.0.12": - version "5.0.14" - resolved "https://registry.yarnpkg.com/@ethersproject/web/-/web-5.0.14.tgz#6e7bebdd9fb967cb25ee60f44d9218dc0803bac4" - integrity sha512-QpTgplslwZ0Sp9oKNLoRuS6TKxnkwfaEk3gr7zd7XLF8XBsYejsrQO/03fNfnMx/TAT/RR6WEw/mbOwpRSeVRA== +"@ethersproject/transactions@^5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@ethersproject/transactions/-/transactions-5.5.0.tgz#7e9bf72e97bcdf69db34fe0d59e2f4203c7a2908" + integrity sha512-9RZYSKX26KfzEd/1eqvv8pLauCKzDTub0Ko4LfIgaERvRuwyaNV78mJs7cpIgZaDl6RJui4o49lHwwCM0526zA== + dependencies: + "@ethersproject/address" "^5.5.0" + "@ethersproject/bignumber" "^5.5.0" + "@ethersproject/bytes" "^5.5.0" + "@ethersproject/constants" "^5.5.0" + "@ethersproject/keccak256" "^5.5.0" + "@ethersproject/logger" "^5.5.0" + "@ethersproject/properties" "^5.5.0" + "@ethersproject/rlp" "^5.5.0" + "@ethersproject/signing-key" "^5.5.0" + +"@ethersproject/web@^5.5.0": + version "5.5.1" + resolved "https://registry.yarnpkg.com/@ethersproject/web/-/web-5.5.1.tgz#cfcc4a074a6936c657878ac58917a61341681316" + integrity sha512-olvLvc1CB12sREc1ROPSHTdFCdvMh0J5GSJYiQg2D0hdD4QmJDy8QYDb1CvoqD/bF1c++aeKv2sR5uduuG9dQg== dependencies: - "@ethersproject/base64" "^5.0.7" - "@ethersproject/bytes" "^5.0.9" - "@ethersproject/logger" "^5.0.8" - "@ethersproject/properties" "^5.0.7" - "@ethersproject/strings" "^5.0.8" + "@ethersproject/base64" "^5.5.0" + "@ethersproject/bytes" "^5.5.0" + "@ethersproject/logger" "^5.5.0" + "@ethersproject/properties" "^5.5.0" + "@ethersproject/strings" "^5.5.0" "@evocateur/libnpmaccess@^3.1.2": version "3.1.2" @@ -2853,6 +3027,16 @@ "@ledgerhq/logs" "^5.11.0" rxjs "^6.5.4" +"@ledgerhq/devices@^5.51.1": + version "5.51.1" + resolved "https://registry.yarnpkg.com/@ledgerhq/devices/-/devices-5.51.1.tgz#d741a4a5d8f17c2f9d282fd27147e6fe1999edb7" + integrity sha512-4w+P0VkbjzEXC7kv8T1GJ/9AVaP9I6uasMZ/JcdwZBS3qwvKo5A5z9uGhP5c7TvItzcmPb44b5Mw2kT+WjUuAA== + dependencies: + "@ledgerhq/errors" "^5.50.0" + "@ledgerhq/logs" "^5.50.0" + rxjs "6" + semver "^7.3.5" + "@ledgerhq/errors@^4.64.0": version "4.64.0" resolved "https://registry.yarnpkg.com/@ledgerhq/errors/-/errors-4.64.0.tgz#fb9c74edb0ea434e659711d88ffcef315a44aeee" @@ -2863,6 +3047,11 @@ resolved "https://registry.yarnpkg.com/@ledgerhq/errors/-/errors-5.11.0.tgz#722151b36ce1d53f555eeaae3e66f819c2362b28" integrity sha512-o9Mx10+qZwuS6tKe6Z2mQJMClIvVtRNt/tdz4P/pNcGeT/0d6jdUfyF+J5P7RF3au0e9HTxG2LMCPFPZ2Tnibg== +"@ledgerhq/errors@^5.50.0": + version "5.50.0" + resolved "https://registry.yarnpkg.com/@ledgerhq/errors/-/errors-5.50.0.tgz#e3a6834cb8c19346efca214c1af84ed28e69dad9" + integrity sha512-gu6aJ/BHuRlpU7kgVpy2vcYk6atjB4iauP2ymF7Gk0ez0Y/6VSMVSJvubeEQN+IV60+OBK0JgeIZG7OiHaw8ow== + "@ledgerhq/hw-app-eth@^4.3.0": version "4.64.0" resolved "https://registry.yarnpkg.com/@ledgerhq/hw-app-eth/-/hw-app-eth-4.64.0.tgz#7d41ea7e72ce0033ad50f9adb98a3e3a78e7123a" @@ -2891,15 +3080,15 @@ node-hid "^0.7.9" "@ledgerhq/hw-transport-node-hid-noevents@^5.11.0": - version "5.11.0" - resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport-node-hid-noevents/-/hw-transport-node-hid-noevents-5.11.0.tgz#6d400414dfc54defce4c765ea52481dcbb4505c9" - integrity sha512-egE2qaZEHkL89qjbWAnTEV3bYe5ALQRCCL8oTc8JVAu4JaxqCoro4DntXrBjjye7gT6gnUHgok1H5f/DVqX4aw== + version "5.51.1" + resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport-node-hid-noevents/-/hw-transport-node-hid-noevents-5.51.1.tgz#71f37f812e448178ad0bcc2258982150d211c1ab" + integrity sha512-9wFf1L8ZQplF7XOY2sQGEeOhpmBRzrn+4X43kghZ7FBDoltrcK+s/D7S+7ffg3j2OySyP6vIIIgloXylao5Scg== dependencies: - "@ledgerhq/devices" "^5.11.0" - "@ledgerhq/errors" "^5.11.0" - "@ledgerhq/hw-transport" "^5.11.0" - "@ledgerhq/logs" "^5.11.0" - node-hid "^1.2.0" + "@ledgerhq/devices" "^5.51.1" + "@ledgerhq/errors" "^5.50.0" + "@ledgerhq/hw-transport" "^5.51.1" + "@ledgerhq/logs" "^5.50.0" + node-hid "2.1.1" "@ledgerhq/hw-transport-node-hid@^4.3.0": version "4.64.0" @@ -2955,6 +3144,15 @@ "@ledgerhq/errors" "^5.11.0" events "^3.1.0" +"@ledgerhq/hw-transport@^5.51.1": + version "5.51.1" + resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport/-/hw-transport-5.51.1.tgz#8dd14a8e58cbee4df0c29eaeef983a79f5f22578" + integrity sha512-6wDYdbWrw9VwHIcoDnqWBaDFyviyjZWv6H9vz9Vyhe4Qd7TIFmbTl/eWs6hZvtZBza9K8y7zD8ChHwRI4s9tSw== + dependencies: + "@ledgerhq/devices" "^5.51.1" + "@ledgerhq/errors" "^5.50.0" + events "^3.3.0" + "@ledgerhq/logs@^4.64.0": version "4.64.0" resolved "https://registry.yarnpkg.com/@ledgerhq/logs/-/logs-4.64.0.tgz#19a335e3f2d9188188437f8dabb06e0fd45b0052" @@ -2965,6 +3163,11 @@ resolved "https://registry.yarnpkg.com/@ledgerhq/logs/-/logs-5.11.0.tgz#9ad2aefceeef48cf9d77972f67e63ba478dd04cc" integrity sha512-NiFDdxLU/z1VGQy0/cbpv7UScMDQ/rU8SznqILSHYTnhK2xvvNFTUkd1W2mpmf9E/hzXFI0UAOieLQ44qovX3w== +"@ledgerhq/logs@^5.50.0": + version "5.50.0" + resolved "https://registry.yarnpkg.com/@ledgerhq/logs/-/logs-5.50.0.tgz#29c6419e8379d496ab6d0426eadf3c4d100cd186" + integrity sha512-swKHYCOZUGyVt4ge0u8a7AwNcA//h4nx5wIi0sruGye1IJ5Cva0GyK9L2/WdX+kWVTKp92ZiEo1df31lrWGPgA== + "@lerna/add@3.16.0": version "3.16.0" resolved "https://registry.yarnpkg.com/@lerna/add/-/add-3.16.0.tgz#f871eda820fe43b868c3a6154906799485c33342" @@ -3643,6 +3846,21 @@ npmlog "^4.1.2" write-file-atomic "^2.3.0" +"@mapbox/node-pre-gyp@^1.0.0": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.9.tgz#09a8781a3a036151cdebbe8719d6f8b25d4058bc" + integrity sha512-aDF3S3rK9Q2gey/WAttUlISduDItz5BU3306M9Eyv6/oS40aMprnopshtlKTykxRNIBEZuRMaZAnbrQ4QtKGyw== + dependencies: + detect-libc "^2.0.0" + https-proxy-agent "^5.0.0" + make-dir "^3.1.0" + node-fetch "^2.6.7" + nopt "^5.0.0" + npmlog "^5.0.1" + rimraf "^3.0.2" + semver "^7.3.5" + tar "^6.1.11" + "@mrmlnc/readdir-enhanced@^2.2.1": version "2.2.1" resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde" @@ -5321,10 +5539,10 @@ "@types/cookiejar" "*" "@types/node" "*" -"@types/supertest@^2.0.9": - version "2.0.9" - resolved "https://registry.yarnpkg.com/@types/supertest/-/supertest-2.0.9.tgz#049bddbcb0ee0d60a9b836ccc977d813a1c32325" - integrity sha512-0BTpWWWAO1+uXaP/oA0KW1eOZv4hc0knhrWowV06Gwwz3kqQxNO98fUFM2e15T+PdPRmOouNFrYvaBgdojPJ3g== +"@types/supertest@^2.0.12": + version "2.0.12" + resolved "https://registry.yarnpkg.com/@types/supertest/-/supertest-2.0.12.tgz#ddb4a0568597c9aadff8dbec5b2e8fddbe8692fc" + integrity sha512-X3HPWTwXRerBZS7Mo1k6vMVR1Z6zmJcDVn5O/31whe0tnjE4te6ZJSJGq1RiqHPjzPdMTfjCFogDJmwng9xHaQ== dependencies: "@types/superagent" "*" @@ -6038,7 +6256,7 @@ aproba@^1.0.3, aproba@^1.1.1, aproba@^1.1.2, aproba@~1.2.0: resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== -"aproba@^1.1.2 || 2", aproba@^2.0.0: +"aproba@^1.0.3 || ^2.0.0", "aproba@^1.1.2 || 2", aproba@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== @@ -6077,6 +6295,14 @@ archy@^1.0.0, archy@~1.0.0: resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40" integrity sha1-+cjBN1fMHde8N5rHeyxipcKGjEA= +are-we-there-yet@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" + integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw== + dependencies: + delegates "^1.0.0" + readable-stream "^3.6.0" + are-we-there-yet@~1.1.2: version "1.1.4" resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.4.tgz#bb5dca382bb94f05e15194373d16fd3ba1ca110d" @@ -8795,7 +9021,7 @@ color-string@^1.5.2: color-name "^1.0.0" simple-swizzle "^0.2.2" -color-support@^1.1.3: +color-support@^1.1.2, color-support@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== @@ -8808,7 +9034,12 @@ color@3.0.x: color-convert "^1.9.1" color-string "^1.5.2" -colorette@1.1.0, colorette@^1.0.7: +colorette@2.0.16: + version "2.0.16" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.16.tgz#713b9af84fdb000139f04546bd4a93f62a5085da" + integrity sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g== + +colorette@^1.0.7: version "1.1.0" resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.1.0.tgz#1f943e5a357fac10b4e0f5aaef3b14cdc1af6ec7" integrity sha512-6S062WDQUXi6hOfkO/sBPVwE5ASXY4G2+b4atvhJfSsuUUhIaUKlkjLe9692Ipyt5/a+IPF5aVTu3V5gvXq5cg== @@ -8900,10 +9131,10 @@ commander@^4.0.1: resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== -commander@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" - integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== +commander@^9.1.0: + version "9.3.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-9.3.0.tgz#f619114a5a2d2054e0d9ff1b31d5ccf89255e26b" + integrity sha512-hv95iU5uXPbK83mjrJKuZyFM/LBAoCV/XhVGkS5Je6tl7sxr6A0ITMw5WoRV46/UaJ46Nllm3Xt7IaJhXTIkzw== commander@~2.13.0: version "2.13.0" @@ -8957,6 +9188,11 @@ component-emitter@^1.2.0, component-emitter@^1.2.1: resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY= +component-emitter@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" + integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== + compress-commons@^4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-4.1.1.tgz#df2a09a7ed17447642bad10a85cc9a19e5c42a7d" @@ -9231,11 +9467,16 @@ cookie@0.4.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== -cookiejar@^2.1.0, cookiejar@^2.1.1: +cookiejar@^2.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.2.tgz#dd8a235530752f988f9a0844f3fc589e3111125c" integrity sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA== +cookiejar@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.3.tgz#fc7a6216e408e74414b90230050842dacda75acc" + integrity sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ== + copy-concurrently@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0" @@ -9653,7 +9894,7 @@ debug@3.2.6, debug@^3.0.1, debug@^3.1.0: dependencies: ms "^2.1.1" -debug@4: +debug@4, debug@4.3.4, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -9667,7 +9908,7 @@ debug@4.1.0: dependencies: ms "^2.1.1" -debug@4.1.1, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: +debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== @@ -10038,6 +10279,11 @@ detect-libc@^1.0.2, detect-libc@^1.0.3: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= +detect-libc@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.1.tgz#e1897aa88fa6ad197862937fbc0441ef352ee0cd" + integrity sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w== + detect-newline@2.X, detect-newline@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2" @@ -10082,7 +10328,7 @@ detox@^17.13.2: yargs "^16.0.3" yargs-unparser "^2.0.0" -dezalgo@^1.0.0, dezalgo@~1.0.3: +dezalgo@1.0.3, dezalgo@^1.0.0, dezalgo@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.3.tgz#7f742de066fc748bc8db820569dddce49bf0d456" integrity sha1-f3Qt4Gb8dIvI24IFad3c5Jvw1FY= @@ -10476,7 +10722,7 @@ ent@^2.2.0: env-paths@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-1.0.0.tgz#4168133b42bb05c38a35b1ae4397c8298ab369e0" - integrity sha1-QWgTO0K7BcOKNbGuQ5fIKYqzaeA= + integrity sha512-+6r/UAzikJWJPcQZpBQS+bVmjAMz2BkDP/N4n2Uz1zz8lyw1IHWUeVdh/85gs0dp5A+z76LOQhCZkR6F88mlUw== env-variable@0.0.x: version "0.0.6" @@ -11554,6 +11800,11 @@ events@^3.0.0, events@^3.1.0: resolved "https://registry.yarnpkg.com/events/-/events-3.1.0.tgz#84279af1b34cb75aa88bf5ff291f6d0bd9b31a59" integrity sha512-Rv+u8MLHNOdMjTAFeT3nCjHn2aGlx435FP/sDHNaRhDEMwyI/aB22Kj2qIN8R0cw3z28psEQLYwxVKLsKrMgWg== +events@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" @@ -11988,6 +12239,11 @@ fast-safe-stringify@^2.0.4, fast-safe-stringify@^2.0.6: resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743" integrity sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA== +fast-safe-stringify@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" + integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== + fast-text-encoding@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.0.tgz#3e5ce8293409cfaa7177a71b9ca84e1b1e6f25ef" @@ -12595,7 +12851,7 @@ forever-agent@~0.6.1: resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= -form-data@^2.2.0, form-data@^2.3.1, form-data@^2.3.2: +form-data@^2.2.0, form-data@^2.3.2: version "2.5.1" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4" integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA== @@ -12613,6 +12869,15 @@ form-data@^3.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + form-data@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" @@ -12622,10 +12887,15 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" -formidable@^1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.1.tgz#70fb7ca0290ee6ff961090415f4b3df3d2082659" - integrity sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg== +formidable@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-2.0.1.tgz#4310bc7965d185536f9565184dee74fbb75557ff" + integrity sha512-rjTMNbp2BpfQShhFbR3Ruk3qk2y9jKpvMW78nJgx8QKtxjDVrwbZG+wvDOmVbifHyOUOQJXxqEy6r0faRrPzTQ== + dependencies: + dezalgo "1.0.3" + hexoid "1.0.0" + once "1.4.0" + qs "6.9.3" forwarded@~0.1.2: version "0.1.2" @@ -12850,6 +13120,21 @@ funpermaproxy@^1.0.1: resolved "https://registry.yarnpkg.com/funpermaproxy/-/funpermaproxy-1.0.1.tgz#4650e69b7c334d9717c06beba9b339cc08ac3335" integrity sha512-9pEzs5vnNtR7ZGihly98w/mQ7blsvl68Wj30ZCDAXy7qDN4CWLLjdfjtH/P2m6whsnaJkw15hysCNHMXue+wdA== +gauge@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" + integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q== + dependencies: + aproba "^1.0.3 || ^2.0.0" + color-support "^1.1.2" + console-control-strings "^1.0.0" + has-unicode "^2.0.1" + object-assign "^4.1.1" + signal-exit "^3.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" + wide-align "^1.1.2" + gauge@~2.7.3: version "2.7.4" resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" @@ -13079,6 +13364,11 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.1: has "^1.0.3" has-symbols "^1.0.1" +get-package-type@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" + integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== + get-pkg-repo@^1.0.0: version "1.4.0" resolved "https://registry.yarnpkg.com/get-pkg-repo/-/get-pkg-repo-1.4.0.tgz#c73b489c06d80cc5536c2c853f9e05232056972d" @@ -13183,10 +13473,10 @@ get-value@^2.0.3, get-value@^2.0.6: resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= -getopts@2.2.5: - version "2.2.5" - resolved "https://registry.yarnpkg.com/getopts/-/getopts-2.2.5.tgz#67a0fe471cacb9c687d817cab6450b96dde8313b" - integrity sha512-9jb7AW5p3in+IiJWhQiZmmwkpLaR/ccTWdWQCtZM66HJcHHLegowh4q4tSD7gouUyeNvFWRavfK9GXosQHDpFA== +getopts@2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/getopts/-/getopts-2.3.0.tgz#71e5593284807e03e2427449d4f6712a268666f4" + integrity sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA== getpass@^0.1.1: version "0.1.7" @@ -13464,14 +13754,14 @@ glob@^7.0.0, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, gl path-is-absolute "^1.0.0" glob@^7.0.3: - version "7.2.0" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" - integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== dependencies: fs.realpath "^1.0.0" inflight "^1.0.4" inherits "2" - minimatch "^3.0.4" + minimatch "^3.1.1" once "^1.3.0" path-is-absolute "^1.0.0" @@ -14216,7 +14506,7 @@ hash.js@1.1.3: inherits "^2.0.3" minimalistic-assert "^1.0.0" -hash.js@^1.0.0, hash.js@^1.0.3, hash.js@^1.1.7: +hash.js@1.1.7, hash.js@^1.0.0, hash.js@^1.0.3, hash.js@^1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA== @@ -14268,6 +14558,11 @@ hermes-profile-transformer@^0.0.6: dependencies: source-map "^0.7.3" +hexoid@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hexoid/-/hexoid-1.0.0.tgz#ad10c6573fb907de23d9ec63a711267d9dc9bc18" + integrity sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g== + highlight.js@^10.2.0: version "10.3.2" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.3.2.tgz#135fd3619a00c3cbb8b4cd6dbc78d56bfcbc46f1" @@ -14777,10 +15072,10 @@ interpret@1.2.0, interpret@^1.0.0, interpret@^1.1.0: resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296" integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw== -interpret@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.0.0.tgz#b783ffac0b8371503e9ab39561df223286aa5433" - integrity sha512-e0/LknJ8wpMMhTiWcjivB+ESwIuvHnBSlBbmP/pSb8CQJldoj1p2qv7xGZ/+BtbTziYRFSz8OsvdbiX45LtYQA== +interpret@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9" + integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw== invariant@^2.2.2, invariant@^2.2.4: version "2.2.4" @@ -14953,6 +15248,13 @@ is-core-module@^2.0.0: dependencies: has "^1.0.3" +is-core-module@^2.8.1: + version "2.9.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69" + integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A== + dependencies: + has "^1.0.3" + is-data-descriptor@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" @@ -16667,26 +16969,25 @@ kleur@^3.0.0: resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.1.tgz#4f5b313f5fa315432a400f19a24db78d451ede62" integrity sha512-P3kRv+B+Ra070ng2VKQqW4qW7gd/v3iD8sy/zOdcYRsfiD+QBokQNOps/AfP6Hr48cBhIIBFWckB9aO+IZhrWg== -knex@^0.21.1: - version "0.21.1" - resolved "https://registry.yarnpkg.com/knex/-/knex-0.21.1.tgz#4fba7e6c58c9f459846c3090be157a732fc75e41" - integrity sha512-uWszXC2DPaLn/YznGT9wFTWUG9+kqbL4DMz+hCH789GLcLuYzq8werHPDKBJxtKvxrW/S1XIXgrTWdMypiVvsw== +knex@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/knex/-/knex-2.1.0.tgz#9348aace3a08ff5be26eb1c8e838416ddf1aa216" + integrity sha512-vVsnD6UJdSJy55TvCXfFF9syfwyXNxfE9mvr2hJL/4Obciy2EPGoqjDpgRSlMruHuPWDOeYAG25nyrGvU+jJog== dependencies: - colorette "1.1.0" - commander "^5.1.0" - debug "4.1.1" + colorette "2.0.16" + commander "^9.1.0" + debug "4.3.4" + escalade "^3.1.1" esm "^3.2.25" - getopts "2.2.5" - inherits "~2.0.4" - interpret "^2.0.0" - liftoff "3.1.0" - lodash "^4.17.15" - mkdirp "^1.0.4" - pg-connection-string "2.2.0" - tarn "^3.0.0" + get-package-type "^0.1.0" + getopts "2.3.0" + interpret "^2.2.0" + lodash "^4.17.21" + pg-connection-string "2.5.0" + rechoir "^0.8.0" + resolve-from "^5.0.0" + tarn "^3.0.2" tildify "2.0.0" - uuid "^7.0.3" - v8flags "^3.1.3" kuler@1.0.x: version "1.0.1" @@ -17027,7 +17328,7 @@ libnpx@^10.2.0: y18n "^4.0.0" yargs "^14.2.3" -liftoff@3.1.0, liftoff@^3.1.0: +liftoff@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/liftoff/-/liftoff-3.1.0.tgz#c9ba6081f908670607ee79062d700df062c52ed3" integrity sha512-DlIPlJUkCV0Ips2zf2pJP0unEoT1kwYhiiPUGF3s/jtxTCjziNLoiVVh+jqWOWeFi6mmwQ5fNxvAUyPad4Dfog== @@ -17614,6 +17915,13 @@ make-dir@^3.0.0: dependencies: semver "^6.0.0" +make-dir@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" + integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== + dependencies: + semver "^6.0.0" + make-error@1.x, make-error@^1.1.1: version "1.3.5" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.5.tgz#efe4e81f6db28cadd605c70f29c831b58ef776c8" @@ -17995,7 +18303,7 @@ messagebird@^3.5.0: safe-buffer "^5.1.2" scmp "^2.0.0" -methods@^1.1.1, methods@^1.1.2, methods@~1.1.2: +methods@^1.1.2, methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= @@ -18324,11 +18632,16 @@ mime-types@^2.1.12: dependencies: mime-db "1.52.0" -mime@1.6.0, mime@^1.4.1: +mime@1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +mime@2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" + integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + mime@^2.2.0: version "2.4.4" resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5" @@ -18388,6 +18701,13 @@ minimalistic-crypto-utils@^1.0.1: dependencies: brace-expansion "^1.1.7" +minimatch@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + minimist-options@^3.0.1: version "3.0.2" resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-3.0.2.tgz#fba4c8191339e13ecf4d61beb03f070103f3d954" @@ -18396,7 +18716,7 @@ minimist-options@^3.0.1: arrify "^1.0.1" is-plain-obj "^1.1.0" -minimist@0.0.5, minimist@0.0.8, minimist@^1.1.0, minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5, minimist@~1.2.0: +minimist@0.0.5, minimist@0.0.8, minimist@^1.1.0, minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@~1.2.0: version "1.2.5" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== @@ -18477,6 +18797,11 @@ mixin-object@^2.0.0: for-in "^0.1.3" is-extendable "^0.1.1" +mkdirp-classic@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" + integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + mkdirp-promise@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/mkdirp-promise/-/mkdirp-promise-5.0.1.tgz#e9b8f68e552c68a9c1713b84883f7a1dd039b8a1" @@ -18974,6 +19299,13 @@ nocache@^2.1.0: resolved "https://registry.yarnpkg.com/nocache/-/nocache-2.1.0.tgz#120c9ffec43b5729b1d5de88cd71aa75a0ba491f" integrity sha512-0L9FvHG3nfnnmaEQPjT9xhfN4ISk0A8/2j4M37Np4mcDesJjHgEUfgPhdCyZuFI954tjokaIj/A3NdpFNdEh4Q== +node-abi@^2.19.3, node-abi@^2.21.0: + version "2.30.1" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-2.30.1.tgz#c437d4b1fe0e285aaf290d45b45d4d7afedac4cf" + integrity sha512-/2D0wOQPgaUWzVSVgRMx+trKJRC2UG4SUc4oCJoXx9Uxjtp0Vy3/kt7zcbxHF8+Z/pK3UloLWzBISg72brfy1w== + dependencies: + semver "^5.4.1" + node-abi@^2.7.0: version "2.9.0" resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-2.9.0.tgz#ae4075b298dab2d92dd1e22c48ccc7ffd7f06200" @@ -18986,6 +19318,16 @@ node-addon-api@^2.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-2.0.2.tgz#432cfa82962ce494b132e9d72a15b29f71ff5d32" integrity sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA== +node-addon-api@^3.0.2: + version "3.2.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" + integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== + +node-addon-api@^4.2.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f" + integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ== + node-emoji@^1.4.1: version "1.10.0" resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.10.0.tgz#8886abd25d9c7bb61802a658523d1f8d2a89b2da" @@ -19018,7 +19360,7 @@ node-fetch-npm@^2.0.2: json-parse-better-errors "^1.0.0" safe-buffer "^5.1.1" -node-fetch@2.6.7: +node-fetch@2.6.7, node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== @@ -19053,7 +19395,7 @@ node-gyp-build@~3.7.0: resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-3.7.0.tgz#daa77a4f547b9aed3e2aac779eaf151afd60ec8d" integrity sha512-L/Eg02Epx6Si2NXmedx+Okg+4UHqmaf3TNcxd50SF9NQGcJaON3AtU++kax69XV7YWz4tUspqZSAsVofhFKG2w== -node-gyp@5.0.1, node-gyp@^3.6.2, node-gyp@^4.0.0, node-gyp@^5.0.2, node-gyp@^8.0.0: +node-gyp@5.0.1, node-gyp@8.x, node-gyp@^3.6.2, node-gyp@^4.0.0, node-gyp@^5.0.2, node-gyp@^8.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-5.0.1.tgz#db211e9c5d7f611e79d1dcbdc53bca646b99ae4c" integrity sha512-D68549U6EDVJLrAkSOZCWX/nmlYo0eCX2dYZoTOOZJ7bEIFrSE/MQgsgMFBKjByJ323hNzkifw2OuT3A5bR5mA== @@ -19070,6 +19412,15 @@ node-gyp@5.0.1, node-gyp@^3.6.2, node-gyp@^4.0.0, node-gyp@^5.0.2, node-gyp@^8.0 tar "^4.4.8" which "1" +node-hid@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/node-hid/-/node-hid-2.1.1.tgz#f83c8aa0bb4e6758b5f7383542477da93f67359d" + integrity sha512-Skzhqow7hyLZU93eIPthM9yjot9lszg9xrKxESleEs05V2NcbUptZc5HFqzjOkSmL0sFlZFr3kmvaYebx06wrw== + dependencies: + bindings "^1.5.0" + node-addon-api "^3.0.2" + prebuild-install "^6.0.0" + node-hid@^0.7.9: version "0.7.9" resolved "https://registry.yarnpkg.com/node-hid/-/node-hid-0.7.9.tgz#cc0cdf1418a286a7667f0b63642b5eeb544ccd05" @@ -19080,13 +19431,14 @@ node-hid@^0.7.9: prebuild-install "^5.3.0" node-hid@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/node-hid/-/node-hid-1.2.0.tgz#d084a9750832b28fd6de6fe2ccd8063fe8e3e7c0" - integrity sha512-ap06Wo1E0aGrZf0t1zvjWSk+IzI6yvKpTDYQRIrdxLHEelifnGDx6XOb2VVlrQhxzM4etma8jH/i0M1LUB55dA== + version "1.3.2" + resolved "https://registry.yarnpkg.com/node-hid/-/node-hid-1.3.2.tgz#094a3ed8b8282cc2dc27f93839e97a81174586cc" + integrity sha512-dRFRpCAoZvMSVN+GYjtbA7D86GPBEAvyv262CV60NF18s/KgtKAGSqHUm677KZtf2rUvBkbccp9QhCXy/0gBmg== dependencies: bindings "^1.5.0" nan "^2.14.0" - prebuild-install "^5.3.3" + node-abi "^2.19.3" + prebuild-install "^6.0.0" node-int64@^0.4.0: version "0.4.0" @@ -19285,7 +19637,7 @@ noop-logger@^0.1.1: "nopt@2 || 3", nopt@3.x: version "3.0.6" resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9" - integrity sha1-xkZdvwirzU2zWTF/eaxopkayj/k= + integrity sha512-4GUt3kSEYmk4ITxzB/b9vaIDfUVWN/Ml1Fwl11IlnIG2iaJ9O6WXZ9SrYM9NLI8OCBieN2Y8SWC2oJV0RQ7qYg== dependencies: abbrev "1" @@ -19297,6 +19649,13 @@ nopt@^4.0.1, nopt@~4.0.1: abbrev "1" osenv "^0.1.4" +nopt@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" + integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== + dependencies: + abbrev "1" + nopt@~1.0.10: version "1.0.10" resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" @@ -19653,6 +20012,16 @@ npmi@^4.0.0: gauge "~2.7.3" set-blocking "~2.0.0" +npmlog@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" + integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw== + dependencies: + are-we-there-yet "^2.0.0" + console-control-strings "^1.1.0" + gauge "^3.0.0" + set-blocking "^2.0.0" + nullthrows@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1" @@ -19891,7 +20260,7 @@ on-headers@^1.0.0, on-headers@~1.0.2: resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== -once@1.x, once@^1.3.0, once@^1.3.1, once@^1.3.2, once@^1.3.3, once@^1.4.0, once@~1.4.0: +once@1.4.0, once@1.x, once@^1.3.0, once@^1.3.1, once@^1.3.2, once@^1.3.3, once@^1.4.0, once@~1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= @@ -20535,6 +20904,11 @@ path-parse@^1.0.5, path-parse@^1.0.6: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + path-root-regex@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/path-root-regex/-/path-root-regex-0.1.2.tgz#bfccdc8df5b12dc52c8b43ec38d18d72c04ba96d" @@ -20645,10 +21019,10 @@ pg-connection-string@0.1.3: resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-0.1.3.tgz#da1847b20940e42ee1492beaf65d49d91b245df7" integrity sha1-2hhHsglA5C7hSSvq9l1J2RskXfc= -pg-connection-string@2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.2.0.tgz#caab4d38a9de4fdc29c9317acceed752897de41c" - integrity sha512-xB/+wxcpFipUZOQcSzcgkjcNOosGhEoPSjz06jC89lv1dj7mc9bZv6wLVy8M2fVjP0a/xN0N988YDq1L0FhK3A== +pg-connection-string@2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.5.0.tgz#538cadd0f7e603fc09a12590f3b8a452c2c0cf34" + integrity sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ== pg-connection-string@^2.2.3: version "2.2.3" @@ -20898,7 +21272,7 @@ postgres-interval@^1.1.0: dependencies: xtend "^4.0.0" -prebuild-install@5.3.3, prebuild-install@^5.2.4, prebuild-install@^5.3.0, prebuild-install@^5.3.3: +prebuild-install@5.3.3, prebuild-install@^5.2.4, prebuild-install@^5.3.0: version "5.3.3" resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-5.3.3.tgz#ef4052baac60d465f5ba6bf003c9c1de79b9da8e" integrity sha512-GV+nsUXuPW2p8Zy7SarF/2W/oiK8bFQgJcncoJ0d7kRpekEA0ftChjfEaF9/Y+QJEc/wFR7RAEa8lYByuUIe2g== @@ -20919,6 +21293,25 @@ prebuild-install@5.3.3, prebuild-install@^5.2.4, prebuild-install@^5.3.0, prebui tunnel-agent "^0.6.0" which-pm-runs "^1.0.0" +prebuild-install@^6.0.0: + version "6.1.4" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-6.1.4.tgz#ae3c0142ad611d58570b89af4986088a4937e00f" + integrity sha512-Z4vpywnK1lBg+zdPCVCsKq0xO66eEV9rWo2zrROGGiRS4JtueBOdlB1FnY8lcy7JsUud/Q3ijUxyWN26Ika0vQ== + dependencies: + detect-libc "^1.0.3" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp-classic "^0.5.3" + napi-build-utils "^1.0.1" + node-abi "^2.21.0" + npmlog "^4.0.1" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^3.0.3" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + precond@0.2: version "0.2.3" resolved "https://registry.yarnpkg.com/precond/-/precond-0.2.3.tgz#aa9591bcaa24923f1e0f4849d240f47efc1075ac" @@ -21514,7 +21907,19 @@ qs@6.7.0: resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== -qs@^6.4.0, qs@^6.5.1, qs@^6.5.2, qs@^6.7.0, qs@^6.9.4: +qs@6.9.3: + version "6.9.3" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.3.tgz#bfadcd296c2d549f1dffa560619132c977f5008e" + integrity sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw== + +qs@^6.10.3: + version "6.10.3" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.3.tgz#d6cde1b2ffca87b5aa57889816c5f81535e22e8e" + integrity sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ== + dependencies: + side-channel "^1.0.4" + +qs@^6.4.0, qs@^6.5.2, qs@^6.7.0, qs@^6.9.4: version "6.9.4" resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.4.tgz#9090b290d1f91728d3c22e54843ca44aea5ab687" integrity sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ== @@ -21980,6 +22385,13 @@ rechoir@^0.6.2: dependencies: resolve "^1.1.6" +rechoir@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.8.0.tgz#49f866e0d32146142da3ad8f0eff352b3215ff22" + integrity sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ== + dependencies: + resolve "^1.20.0" + redent@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde" @@ -22407,6 +22819,15 @@ resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.17.0, resolve@^1.18. is-core-module "^2.0.0" path-parse "^1.0.6" +resolve@^1.20.0: + version "1.22.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198" + integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw== + dependencies: + is-core-module "^2.8.1" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + resolve@~1.11.1: version "1.11.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.11.1.tgz#ea10d8110376982fef578df8fc30b9ac30a07a3e" @@ -22517,7 +22938,7 @@ rimraf@2.6.3, rimraf@~2.6.2: dependencies: glob "^7.1.3" -rimraf@3.0.2, rimraf@^3.0.0: +rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== @@ -22618,6 +23039,13 @@ rx-lite@*, rx-lite@^4.0.8: resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-4.0.8.tgz#0b1e11af8bc44836f04a6407e92da42467b79444" integrity sha1-Cx4Rr4vESDbwSmQH6S2kJGe3lEQ= +rxjs@6: + version "6.6.7" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9" + integrity sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ== + dependencies: + tslib "^1.9.0" + rxjs@^6.4.0, rxjs@^6.5.2, rxjs@^6.5.4, rxjs@^6.6.0: version "6.6.3" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552" @@ -22877,10 +23305,17 @@ semver@^6.0.0, semver@^6.2.0, semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== +semver@^7.3.7: + version "7.3.7" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f" + integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g== + dependencies: + lru-cache "^6.0.0" + semver@~5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" - integrity sha1-myzl094C0XxgEq0yaqa00M9U+U8= + integrity sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw== semver@~5.4.1: version "5.4.1" @@ -23037,10 +23472,10 @@ sha1@^1.1.1: charenc ">= 0.0.1" crypt ">= 0.0.1" -sha3@1.2.3, sha3@^1.2.2: - version "1.2.3" - resolved "https://registry.yarnpkg.com/sha3/-/sha3-1.2.3.tgz#ed5958fa8331df1b1b8529ca9fdf225a340c5418" - integrity sha512-sOWDZi8cDBRkLfWOw18wvJyNblXDHzwMGnRWut8zNNeIeLnmMRO17bjpLc7OzMuj1ASUgx2IyohzUCAl+Kx5vA== +sha3@1.2.6, sha3@^1.2.2: + version "1.2.6" + resolved "https://registry.yarnpkg.com/sha3/-/sha3-1.2.6.tgz#102aa3e47dc793e2357902c3cce8760822f9e905" + integrity sha512-KgLGmJGrmNB4JWVsAV11Yk6KbvsAiygWJc7t5IebWva/0NukNrjJqhtKhzy3Eiv2AKuGvhZZt7dt1mDo7HkoiQ== dependencies: nan "2.13.2" @@ -23594,6 +24029,17 @@ sqlite3@4.0.9: node-pre-gyp "^0.11.0" request "^2.87.0" +sqlite3@^5.0.8: + version "5.0.8" + resolved "https://registry.yarnpkg.com/sqlite3/-/sqlite3-5.0.8.tgz#b4b7eab7156debec80866ef492e01165b4688272" + integrity sha512-f2ACsbSyb2D1qFFcqIXPfFscLtPVOWJr5GmUzYxf4W+0qelu5MWrR+FAQE1d5IUArEltBrzSDxDORG8P/IkqyQ== + dependencies: + "@mapbox/node-pre-gyp" "^1.0.0" + node-addon-api "^4.2.0" + tar "^6.1.11" + optionalDependencies: + node-gyp "8.x" + sqlstring@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.1.tgz#475393ff9e91479aea62dcaf0ca3d14983a7fb40" @@ -23821,6 +24267,15 @@ string-width@^1.0.1, string-width@^1.0.2: is-fullwidth-code-point "^2.0.0" strip-ansi "^4.0.0" +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.2.2, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^3.0.0, string-width@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" @@ -23839,15 +24294,6 @@ string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.0" -string-width@^4.2.2: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string.prototype.trim@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.1.2.tgz#d04de2c89e137f4d7d206f086b5ed2fae6be8cea" @@ -24044,21 +24490,22 @@ sudo-prompt@^9.0.0: resolved "https://registry.yarnpkg.com/sudo-prompt/-/sudo-prompt-9.2.1.tgz#77efb84309c9ca489527a4e749f287e6bdd52afd" integrity sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw== -superagent@^3.8.3: - version "3.8.3" - resolved "https://registry.yarnpkg.com/superagent/-/superagent-3.8.3.tgz#460ea0dbdb7d5b11bc4f78deba565f86a178e128" - integrity sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA== - dependencies: - component-emitter "^1.2.0" - cookiejar "^2.1.0" - debug "^3.1.0" - extend "^3.0.0" - form-data "^2.3.1" - formidable "^1.2.0" - methods "^1.1.1" - mime "^1.4.1" - qs "^6.5.1" - readable-stream "^2.3.5" +superagent@^7.1.3: + version "7.1.6" + resolved "https://registry.yarnpkg.com/superagent/-/superagent-7.1.6.tgz#64f303ed4e4aba1e9da319f134107a54cacdc9c6" + integrity sha512-gZkVCQR1gy/oUXr+kxJMLDjla434KmSOKbx5iGD30Ql+AkJQ/YlPKECJy2nhqOsHLjGHzoDTXNSjhnvWhzKk7g== + dependencies: + component-emitter "^1.3.0" + cookiejar "^2.1.3" + debug "^4.3.4" + fast-safe-stringify "^2.1.1" + form-data "^4.0.0" + formidable "^2.0.1" + methods "^1.1.2" + mime "2.6.0" + qs "^6.10.3" + readable-stream "^3.6.0" + semver "^7.3.7" superstatic@^7.1.0: version "7.1.0" @@ -24092,13 +24539,13 @@ superstatic@^7.1.0: optionalDependencies: re2 "^1.15.8" -supertest@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/supertest/-/supertest-4.0.2.tgz#c2234dbdd6dc79b6f15b99c8d6577b90e4ce3f36" - integrity sha512-1BAbvrOZsGA3YTCWqbmh14L0YEq0EGICX/nBnfkfVJn7SrxQV1I3pMYjSzG9y/7ZU2V9dWqyqk2POwxlb09duQ== +supertest@^6.2.3: + version "6.2.3" + resolved "https://registry.yarnpkg.com/supertest/-/supertest-6.2.3.tgz#291b220126e5faa654d12abe1ada3658757c8c67" + integrity sha512-3GSdMYTMItzsSYjnIcljxMVZKPW1J9kYHZY+7yLfD0wpPwww97GeImZC1oOk0S5+wYl2niJwuFusBJqwLqYM3g== dependencies: methods "^1.1.2" - superagent "^3.8.3" + superagent "^7.1.3" supports-color@5.4.0: version "5.4.0" @@ -24163,6 +24610,11 @@ supports-hyperlinks@^2.0.0, supports-hyperlinks@^2.1.0: has-flag "^4.0.0" supports-color "^7.0.0" +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + sver-compat@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/sver-compat/-/sver-compat-1.5.0.tgz#3cf87dfeb4d07b4a3f14827bc186b3fd0c645cd8" @@ -24326,7 +24778,7 @@ tar-stream@^2.2.0: inherits "^2.0.3" readable-stream "^3.1.1" -tar@4.4.15, tar@^4, tar@^4.0.2, tar@^4.3.0, tar@^4.4.0, tar@^4.4.10, tar@^4.4.2, tar@^4.4.3, tar@^4.4.8: +tar@4.4.15, tar@^4, tar@^4.0.2, tar@^4.3.0, tar@^4.4.0, tar@^4.4.10, tar@^4.4.2, tar@^4.4.3, tar@^4.4.8, tar@^6.1.11: version "4.4.15" resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.15.tgz#3caced4f39ebd46ddda4d6203d48493a919697f8" integrity sha512-ItbufpujXkry7bHH9NpQyTXPbJ72iTlXgkBAYsAjDXk3Ds8t/3NfO5P4xZGy7u+sYuQUbimgzswX4uQIEeNVOA== @@ -24351,10 +24803,10 @@ tarn@^1.1.5: resolved "https://registry.yarnpkg.com/tarn/-/tarn-1.1.5.tgz#7be88622e951738b9fa3fb77477309242cdddc2d" integrity sha512-PMtJ3HCLAZeedWjJPgGnCvcphbCOMbtZpjKgLq3qM5Qq9aQud+XHrL0WlrlgnTyS8U+jrjGbEXprFcQrxPy52g== -tarn@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/tarn/-/tarn-3.0.0.tgz#a4082405216c0cce182b8b4cb2639c52c1e870d4" - integrity sha512-PKUnlDFODZueoA8owLehl8vLcgtA8u4dRuVbZc92tspDYZixjJL6TqYOmryf/PfP/EBX+2rgNcrj96NO+RPkdQ== +tarn@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/tarn/-/tarn-3.0.2.tgz#73b6140fbb881b71559c4f8bfde3d9a4b3d27693" + integrity sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ== tcp-port-used@^1.0.1: version "1.0.1" @@ -26024,7 +26476,7 @@ v8-to-istanbul@^7.0.0: convert-source-map "^1.6.0" source-map "^0.7.3" -v8flags@^3.0.1, v8flags@^3.1.3: +v8flags@^3.0.1: version "3.1.3" resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-3.1.3.tgz#fc9dc23521ca20c5433f81cc4eb9b3033bb105d8" integrity sha512-amh9CCg3ZxkzQ48Mhcb8iX7xpAfYJgePHxWMQCBWECpOSqJUXgY26ncA61UTV0BkPqfhcy6mzwCIoP4ygxpW8w== @@ -27899,6 +28351,13 @@ wide-align@1.1.3, wide-align@^1.1.0: dependencies: string-width "^1.0.2 || 2" +wide-align@^1.1.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" + integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== + dependencies: + string-width "^1.0.2 || 2 || 3 || 4" + widest-line@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.1.tgz#7438764730ec7ef4381ce4df82fb98a53142a3fc"