diff --git a/packages/phone-number-privacy/combiner/package.json b/packages/phone-number-privacy/combiner/package.json index 37f7f112456..f0d042c8140 100644 --- a/packages/phone-number-privacy/combiner/package.json +++ b/packages/phone-number-privacy/combiner/package.json @@ -23,9 +23,15 @@ "test": "jest --runInBand --testPathIgnorePatterns test/end-to-end", "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 test/end-to-end --verbose", "test:e2e:staging": "CONTEXT_NAME=staging yarn test:e2e", - "test:e2e:alfajores": "CONTEXT_NAME=alfajores yarn test:e2e" + "test:e2e:alfajores": "CONTEXT_NAME=alfajores 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" }, "dependencies": { "@celo/contractkit": "^4.1.1-beta.1", diff --git a/packages/phone-number-privacy/combiner/scripts/run-migrations.ts b/packages/phone-number-privacy/combiner/scripts/run-migrations.ts new file mode 100644 index 00000000000..72f965c7d8a --- /dev/null +++ b/packages/phone-number-privacy/combiner/scripts/run-migrations.ts @@ -0,0 +1,19 @@ +// tslint:disable: no-console +// TODO de-dupe with signer script +import { initDatabase } from '../src/database/database' +import config from '../src/config' + +async function start() { + console.info('Running migrations') + await initDatabase(config, undefined, false) +} + +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/config.ts b/packages/phone-number-privacy/combiner/src/config.ts index 091817d2c2d..233654a3581 100644 --- a/packages/phone-number-privacy/combiner/src/config.ts +++ b/packages/phone-number-privacy/combiner/src/config.ts @@ -1,14 +1,26 @@ import { BlockchainConfig, + DB_POOL_MAX_SIZE, + DB_TIMEOUT, FULL_NODE_TIMEOUT_IN_MS, RETRY_COUNT, RETRY_DELAY_IN_MS, rootLogger, TestUtils, + toBool, } from '@celo/phone-number-privacy-common' import { blockchainApiKey, blockchainProvider, + dbHost, + dbName, + dbPassword, + dbPoolMaxSize, + dbPort, + dbSsl, + dbTimeout, + dbType, + dbUsername, domainEnabled, domainFullNodeDelaysMs, domainFullNodeRetryCount, @@ -35,6 +47,7 @@ export function getCombinerVersion(): string { } export const DEV_MODE = process.env.NODE_ENV !== 'production' || process.env.FUNCTIONS_EMULATOR === 'true' +export const VERBOSE_DB_LOGGING = toBool(process.env.VERBOSE_DB_LOGGING, false) export const FORNO_ALFAJORES = 'https://alfajores-forno.celo-testnet.org' @@ -47,6 +60,13 @@ export const MAX_BLOCK_DISCREPANCY_THRESHOLD = 3 export const MAX_TOTAL_QUOTA_DISCREPANCY_THRESHOLD = 5 export const MAX_QUERY_COUNT_DISCREPANCY_THRESHOLD = 5 +export enum SupportedDatabase { + Postgres = 'postgres', // PostgresSQL + MySql = 'mysql', // MySQL + MsSql = 'mssql', // Microsoft SQL Server + Sqlite = 'sqlite3', // SQLite (for testing) +} + export interface OdisConfig { serviceName: string enabled: boolean @@ -68,6 +88,17 @@ export interface CombinerConfig { blockchain: BlockchainConfig phoneNumberPrivacy: OdisConfig domains: OdisConfig + db: { + type: SupportedDatabase | string + user: string + password: string + database: string + host: string + port?: number + ssl: boolean + poolMaxSize: number + timeout: number + } } let config: CombinerConfig @@ -163,6 +194,17 @@ if (DEV_MODE) { fullNodeRetryCount: RETRY_COUNT, fullNodeRetryDelayMs: RETRY_DELAY_IN_MS, }, + db: { + type: SupportedDatabase.Sqlite, + user: '', + password: '', + database: 'phoneNumber+privacy', + host: 'http://localhost', + port: undefined, + ssl: true, + poolMaxSize: DB_POOL_MAX_SIZE, + timeout: DB_TIMEOUT, + }, } } else { config = { @@ -201,6 +243,17 @@ if (DEV_MODE) { fullNodeRetryCount: domainFullNodeRetryCount.value(), fullNodeRetryDelayMs: domainFullNodeDelaysMs.value(), }, + db: { + type: dbType.value(), + user: dbUsername.value(), + password: dbPassword.value(), + database: dbName.value(), + host: `/cloudsql/${dbHost.value()}`, + port: dbPort.value(), + ssl: dbSsl.value(), + poolMaxSize: dbPoolMaxSize.value(), + timeout: dbTimeout.value(), + }, } } export default config diff --git a/packages/phone-number-privacy/combiner/src/database/database.ts b/packages/phone-number-privacy/combiner/src/database/database.ts new file mode 100644 index 00000000000..88561a31152 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/database/database.ts @@ -0,0 +1,96 @@ +import { rootLogger } from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import knex, { Knex } from 'knex' +import { CombinerConfig, DEV_MODE, SupportedDatabase, VERBOSE_DB_LOGGING } from '../config' +import { ACCOUNTS_COLUMNS, ACCOUNTS_TABLE } from './models/account' + +export async function initDatabase( + config: CombinerConfig, + 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') + client = 'pg' + connection = { + user, + password, + database, + host, + port: port ?? 5432, + ssl, + pool: { max: poolMaxSize }, + } + } else if (type === SupportedDatabase.MySql) { + logger.info('Using MySql') + client = 'mysql2' + connection = { + user, + password, + database, + host, + port: port ?? 3306, + ssl, + pool: { max: poolMaxSize }, + } + } else if (type === SupportedDatabase.MsSql) { + logger.info('Using MS SQL') + client = 'mssql' + connection = { + user, + password, + database, + server: host, + port: port ?? 1433, + pool: { max: poolMaxSize }, + } + } else if (type === SupportedDatabase.Sqlite) { + logger.info('Using SQLite - combiner') + client = 'sqlite3' + connection = ':memory:' + } else { + throw new Error(`Unsupported database type: ${type}`) + } + const db = knex({ + client, + useNullAsDefault: type === SupportedDatabase.Sqlite, + connection, + debug: DEV_MODE && VERBOSE_DB_LOGGING, + }) + + logger.info('Running Migrations') + + await db.migrate.latest({ + directory: migrationsPath ?? './dist/database/migrations', + loadExtensions: ['.js'], + }) + + if (doTestQuery) { + await executeTestQuery(db, logger) + } + + logger.info('Database initialized successfully') + return db +} + +async function executeTestQuery(db: Knex, logger: Logger) { + logger.info('Counting accounts') + const result = await db(ACCOUNTS_TABLE).count(ACCOUNTS_COLUMNS.address).first() + + if (!result) { + throw new Error('No result from count, have migrations been run?') + } + + const count = Object.values(result)[0] + if (count === undefined || count === null || count === '') { + throw new Error('No result from count, have migrations been run?') + } + + logger.info(`Found ${count} accounts`) +} diff --git a/packages/phone-number-privacy/combiner/src/database/migrations/20230815120000_create_accounts_table.ts b/packages/phone-number-privacy/combiner/src/database/migrations/20230815120000_create_accounts_table.ts new file mode 100644 index 00000000000..d55e5d37809 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/database/migrations/20230815120000_create_accounts_table.ts @@ -0,0 +1,18 @@ +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))) { + return knex.schema.createTable(ACCOUNTS_TABLE, (t) => { + t.string(ACCOUNTS_COLUMNS.address).notNullable().primary() + t.dateTime(ACCOUNTS_COLUMNS.createdAt).notNullable() + t.string(ACCOUNTS_COLUMNS.dek) + t.dateTime(ACCOUNTS_COLUMNS.onChainDataLastUpdated) + }) + } + return null +} + +export async function down(knex: Knex): Promise { + return knex.schema.dropTable(ACCOUNTS_TABLE) +} diff --git a/packages/phone-number-privacy/combiner/src/database/migrations/20230819120000_create_request_table.ts b/packages/phone-number-privacy/combiner/src/database/migrations/20230819120000_create_request_table.ts new file mode 100644 index 00000000000..46e4e9feb29 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/database/migrations/20230819120000_create_request_table.ts @@ -0,0 +1,20 @@ +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) => { + t.string(REQUESTS_COLUMNS.address).notNullable() + t.dateTime(REQUESTS_COLUMNS.timestamp).notNullable() + t.string(REQUESTS_COLUMNS.blindedQuery).notNullable() + t.string(REQUESTS_COLUMNS.combinedSignature).notNullable() + t.primary([REQUESTS_COLUMNS.address, REQUESTS_COLUMNS.blindedQuery]) + }) + } + return null +} + +export async function down(knex: Knex): Promise { + return knex.schema.dropTable(REQUESTS_TABLE) +} diff --git a/packages/phone-number-privacy/combiner/src/database/models/account.ts b/packages/phone-number-privacy/combiner/src/database/models/account.ts new file mode 100644 index 00000000000..319c9c04f76 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/database/models/account.ts @@ -0,0 +1,22 @@ +export const ACCOUNTS_TABLE = 'accounts' + +export enum ACCOUNTS_COLUMNS { + address = 'address', + createdAt = 'created_at', + dek = 'dek', + onChainDataLastUpdated = 'onChainDataLastUpdated', +} + +export class Account { + [ACCOUNTS_COLUMNS.address]: string | undefined; + [ACCOUNTS_COLUMNS.createdAt]: Date = new Date(); + [ACCOUNTS_COLUMNS.dek]: string | undefined; + [ACCOUNTS_COLUMNS.onChainDataLastUpdated]: Date | null = null + + constructor(address: string, dek?: string) { + this.address = address + if (dek) { + this.dek = dek + } + } +} diff --git a/packages/phone-number-privacy/combiner/src/database/models/request.ts b/packages/phone-number-privacy/combiner/src/database/models/request.ts new file mode 100644 index 00000000000..c58c4edb9ef --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/database/models/request.ts @@ -0,0 +1,29 @@ +export const REQUESTS_TABLE = 'requests' + +export enum REQUESTS_COLUMNS { + address = 'caller_address', + timestamp = 'timestamp', + blindedQuery = 'blinded_query', + combinedSignature = 'combined_signature', + //TODO (soloseng): add session response too? +} + +export interface PnpSignRequestRecord { + [REQUESTS_COLUMNS.address]: string + [REQUESTS_COLUMNS.timestamp]: Date + [REQUESTS_COLUMNS.blindedQuery]: string + [REQUESTS_COLUMNS.combinedSignature]: string +} + +export function toPnpSignRequestRecord( + account: string, + blindedQuery: string, + combinedSignature: string +): PnpSignRequestRecord { + return { + [REQUESTS_COLUMNS.address]: account, + [REQUESTS_COLUMNS.timestamp]: new Date(), + [REQUESTS_COLUMNS.blindedQuery]: blindedQuery, + [REQUESTS_COLUMNS.combinedSignature]: combinedSignature, + } +} diff --git a/packages/phone-number-privacy/combiner/src/database/wrappers/account.ts b/packages/phone-number-privacy/combiner/src/database/wrappers/account.ts new file mode 100644 index 00000000000..a003329e28d --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/database/wrappers/account.ts @@ -0,0 +1,79 @@ +import { DB_TIMEOUT, ErrorMessage } from '@celo/phone-number-privacy-common' +import Logger from 'bunyan' +import { Knex } from 'knex' +import { Account, ACCOUNTS_COLUMNS, ACCOUNTS_TABLE } from '../models/account' + +function accounts(db: Knex) { + return db(ACCOUNTS_TABLE) +} + +/* + * Get DEK signer record from DB. + */ +export async function getDekSignerRecord( + db: Knex, + account: string, + logger: Logger +): Promise { + try { + logger.info('getting Dek Signer Record') + const dekSignerRecord = await accounts(db) + .where(ACCOUNTS_COLUMNS.address, account) + .select(ACCOUNTS_COLUMNS.dek) + .first() + .timeout(DB_TIMEOUT) + + return dekSignerRecord ? dekSignerRecord[ACCOUNTS_COLUMNS.dek] : undefined + } catch (err) { + logger.error(ErrorMessage.DATABASE_GET_FAILURE) + logger.error(err) + return undefined + } +} + +export async function updateDekSignerRecord( + db: Knex, + account: string, + newDek: string, + logger: Logger, + trx: Knex.Transaction +) { + logger.info(`updating Dek Signer Record`) + if (await getAccountExist(db, account, trx)) { + await accounts(db) + .transacting(trx) + .timeout(DB_TIMEOUT) + .where(ACCOUNTS_COLUMNS.address, account) + .update({ [ACCOUNTS_COLUMNS.dek]: newDek }) + await accounts(db) + .transacting(trx) + .timeout(DB_TIMEOUT) + .where(ACCOUNTS_COLUMNS.address, account) + .update({ [ACCOUNTS_COLUMNS.onChainDataLastUpdated]: new Date() }) + } else { + // account does not exists + const newAccount = new Account(account, newDek) + await accounts(db).transacting(trx).timeout(DB_TIMEOUT).insert(newAccount) + } +} + +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 +} + +async function getAccountExist( + db: Knex, + account: string, + trx?: Knex.Transaction +): Promise { + const accountRecord = await tableWithLockForTrx(accounts(db), trx) + .where(ACCOUNTS_COLUMNS.address, account) + .first() + .timeout(DB_TIMEOUT) + + return !!accountRecord +} diff --git a/packages/phone-number-privacy/combiner/src/database/wrappers/request.ts b/packages/phone-number-privacy/combiner/src/database/wrappers/request.ts new file mode 100644 index 00000000000..47f272e5cf5 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/database/wrappers/request.ts @@ -0,0 +1,54 @@ +import Logger from 'bunyan' +import { Knex } from 'knex' +import config from '../../config' +import { tableWithLockForTrx } from '../../utils/utils' +import { + PnpSignRequestRecord, + REQUESTS_COLUMNS, + REQUESTS_TABLE, + toPnpSignRequestRecord, +} from '../models/request' + +function requests(db: Knex) { + return db(REQUESTS_TABLE) +} + +// TODO (soloseng): should return the response associated to this request. +export async function getCombinedSignatureIfRequestExists( + db: Knex, + account: string, + blindedQuery: string, + // logger: Logger, + trx?: Knex.Transaction +): Promise { + // logger.debug(`Checking if request exists for account: ${account}, blindedQuery: ${blindedQuery}`) + const existingRequest = await tableWithLockForTrx(requests(db), trx) + .where({ + [REQUESTS_COLUMNS.address]: account, + [REQUESTS_COLUMNS.blindedQuery]: blindedQuery, + }) + .first() + .timeout(config.db.timeout) + + if (existingRequest) { + return existingRequest[REQUESTS_COLUMNS.combinedSignature] + } + return null +} + +export async function storeRequest( + db: Knex, + account: string, + blindedQuery: string, + combinedSignature: string, + logger?: Logger, //revert the `?` + trx?: Knex.Transaction //revert the `?` +): Promise { + logger!.debug( + `Storing salt request for: ${account}, blindedQuery: ${blindedQuery} with combinedSignature: ${combinedSignature}` + ) + await requests(db) + .transacting(trx!) + .insert(toPnpSignRequestRecord(account, blindedQuery, combinedSignature)) + .timeout(config.db.timeout) +} diff --git a/packages/phone-number-privacy/combiner/src/index.ts b/packages/phone-number-privacy/combiner/src/index.ts index 9fc648cbad0..b22b96614fe 100644 --- a/packages/phone-number-privacy/combiner/src/index.ts +++ b/packages/phone-number-privacy/combiner/src/index.ts @@ -1,6 +1,8 @@ -import { getContractKit } from '@celo/phone-number-privacy-common' +import { getContractKitWithAgent } from '@celo/phone-number-privacy-common' import * as functions from 'firebase-functions/v2/https' +import { Knex } from 'knex' import config from './config' +import { initDatabase } from './database/database' import { startCombiner } from './server' import { blockchainApiKey, minInstancesConfig, requestConcurency } from './utils/firebase-configs' @@ -14,6 +16,15 @@ export const combinerGen2 = functions.onRequest( memory: '512MiB', region: 'us-central1', }, - startCombiner(config, getContractKit(config.blockchain)) + async (req, res) => { + try { + const db: Knex = await initDatabase(config) + const app = startCombiner(db, config, getContractKitWithAgent(config.blockchain)) + + app(req, res) + } catch (e) { + res.status(500).send('Internal Server Error') + } + } ) export * from './config' 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 index 26af9325fec..2702633cb4a 100644 --- a/packages/phone-number-privacy/combiner/src/pnp/endpoints/quota/action.ts +++ b/packages/phone-number-privacy/combiner/src/pnp/endpoints/quota/action.ts @@ -1,5 +1,4 @@ import { - authenticateUser, CombinerEndpoint, DataEncryptionKeyFetcher, ErrorMessage, @@ -19,8 +18,11 @@ import { getKeyVersionInfo, sendFailure } from '../../../common/io' import { getCombinerVersion, OdisConfig } from '../../../config' import { logPnpSignerResponseDiscrepancies } from '../../services/log-responses' import { findCombinerQuotaState } from '../../services/threshold-state' +import { authenticateUser } from '../../../utils/authentication' +import { Knex } from 'knex' export function createPnpQuotaHandler( + db: Knex, signers: Signer[], config: OdisConfig, dekFetcher: DataEncryptionKeyFetcher @@ -33,7 +35,7 @@ export function createPnpQuotaHandler( return } - if (!(await authenticateUser(request, logger, dekFetcher))) { + if (!(await authenticateUser(db, request, logger, dekFetcher))) { sendFailure(WarningMessage.UNAUTHENTICATED_USER, 401, response) return } 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 index 23458061d87..044942379c1 100644 --- a/packages/phone-number-privacy/combiner/src/pnp/endpoints/sign/action.ts +++ b/packages/phone-number-privacy/combiner/src/pnp/endpoints/sign/action.ts @@ -1,5 +1,4 @@ import { - authenticateUser, CombinerEndpoint, DataEncryptionKeyFetcher, ErrorMessage, @@ -24,8 +23,13 @@ import { getKeyVersionInfo, requestHasSupportedKeyVersion, sendFailure } from '. import { getCombinerVersion, OdisConfig } from '../../../config' import { logPnpSignerResponseDiscrepancies } from '../../services/log-responses' import { findCombinerQuotaState } from '../../services/threshold-state' +import { Knex } from 'knex' +import { authenticateUser } from '../../../utils/authentication' + +import { storeRequest } from '../../../database/wrappers/request' export function createPnpSignHandler( + db: Knex, signers: Signer[], config: OdisConfig, dekFetcher: DataEncryptionKeyFetcher @@ -42,7 +46,7 @@ export function createPnpSignHandler( return } - if (!(await authenticateUser(request, logger, dekFetcher))) { + if (!(await authenticateUser(db, request, logger, dekFetcher))) { sendFailure(WarningMessage.UNAUTHENTICATED_USER, 401, response) return } @@ -102,6 +106,14 @@ export function createPnpSignHandler( request.body.blindedQueryPhoneNumber, logger ) + // TODO (soloseng): store validated request + const signMessageRequest: SignMessageRequest = request.body + await storeRequest( + db, + signMessageRequest.account, + signMessageRequest.blindedQueryPhoneNumber, + combinedSignature + ) return send( response, diff --git a/packages/phone-number-privacy/combiner/src/server.ts b/packages/phone-number-privacy/combiner/src/server.ts index cfb5a24eeed..ee3e852ddfc 100644 --- a/packages/phone-number-privacy/combiner/src/server.ts +++ b/packages/phone-number-privacy/combiner/src/server.ts @@ -7,7 +7,7 @@ import { OdisRequest, rootLogger, } from '@celo/phone-number-privacy-common' -import express, { RequestHandler } from 'express' +import express, { Express, RequestHandler } from 'express' import { Signer } from './common/combine' import { catchErrorHandler, @@ -21,10 +21,11 @@ import { createDomainQuotaHandler } from './domain/endpoints/quota/action' import { createDomainSignHandler } from './domain/endpoints/sign/action' import { createPnpQuotaHandler } from './pnp/endpoints/quota/action' import { createPnpSignHandler } from './pnp/endpoints/sign/action' +import { Knex } from 'knex' require('events').EventEmitter.defaultMaxListeners = 15 -export function startCombiner(config: CombinerConfig, kit: ContractKit) { +export function startCombiner(db: Knex, config: CombinerConfig, kit: ContractKit): Express { const logger = rootLogger(config.serviceName) logger.info('Creating combiner express server') @@ -64,8 +65,8 @@ export function startCombiner(config: CombinerConfig, kit: ContractKit) { ) const pnpSigners: Signer[] = JSON.parse(config.phoneNumberPrivacy.odisServices.signers) - const pnpQuota = createPnpQuotaHandler(pnpSigners, config.phoneNumberPrivacy, dekFetcher) - const pnpSign = createPnpSignHandler(pnpSigners, config.phoneNumberPrivacy, dekFetcher) + const pnpQuota = createPnpQuotaHandler(db, pnpSigners, config.phoneNumberPrivacy, dekFetcher) + const pnpSign = createPnpSignHandler(db, pnpSigners, config.phoneNumberPrivacy, dekFetcher) const domainSigners: Signer[] = JSON.parse(config.domains.odisServices.signers) const domainQuota = createDomainQuotaHandler(domainSigners, config.domains) diff --git a/packages/phone-number-privacy/combiner/src/utils/authentication.ts b/packages/phone-number-privacy/combiner/src/utils/authentication.ts new file mode 100644 index 00000000000..a5717cdf604 --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/utils/authentication.ts @@ -0,0 +1,231 @@ +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' +import { + AuthenticationMethod, + DataEncryptionKeyFetcher, + ErrorMessage, + ErrorType, + fetchEnv, + FULL_NODE_TIMEOUT_IN_MS, + PhoneNumberPrivacyRequest, + RETRY_COUNT, + RETRY_DELAY_IN_MS, + rootLogger, +} from '@celo/phone-number-privacy-common' +import { trimLeading0x } from '@celo/utils/lib/address' +import { verifySignature } from '@celo/utils/lib/signatureUtils' +import Logger from 'bunyan' +import crypto from 'crypto' +import { Request } from 'express' +import { Knex } from 'knex' +import { getDekSignerRecord, updateDekSignerRecord } from '../database/wrappers/account' + +/* + * 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( + db: Knex, + request: Request<{}, {}, R>, + logger: Logger, + fetchDEK: DataEncryptionKeyFetcher, + warnings: ErrorType[] = [] +): Promise { + logger.debug('Authenticating user') + + // https://tools.ietf.org/html/rfc7235#section-4.2 + const messageSignature = request.get('Authorization') + const message = JSON.stringify(request.body) + const signer = request.body.account + const authMethod = request.body.authenticationMethod + + if (!messageSignature || !signer) { + return false + } + + if (authMethod && authMethod === AuthenticationMethod.ENCRYPTION_KEY) { + let registeredEncryptionKey: string | undefined + try { + // first look for DEK in db + registeredEncryptionKey = await getDekSignerRecord(db, signer, logger) + + // verify DEK if found + if (registeredEncryptionKey) { + logger.info( + { dek: registeredEncryptionKey, account: signer }, + 'Found DEK for account in db' + ) + if (verifyDEKSignature(message, messageSignature, registeredEncryptionKey, logger)) { + return true + } + logger.warn({ account: signer }, 'Failed to verify account DEK signature found in db.') + } + } catch (error) { + const failureStatus = ErrorMessage.FAILING_CLOSED + logger.error({ + error, + warning: ErrorMessage.FAILURE_TO_GET_DEK, + failureStatus, + }) + warnings.push(ErrorMessage.FAILURE_TO_GET_DEK, failureStatus) + return false + } + + try { + registeredEncryptionKey = await fetchDEK(signer) + } 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 + const failureStatus = ErrorMessage.FAILING_CLOSED + logger.error({ + err, + warning: ErrorMessage.FAILURE_TO_GET_DEK, + failureStatus, + }) + warnings.push(ErrorMessage.FAILURE_TO_GET_DEK, failureStatus) + return false + } + + if (!registeredEncryptionKey) { + logger.warn({ account: signer }, 'Account does not have registered encryption key') + return false + } else { + logger.info({ dek: registeredEncryptionKey, account: signer }, 'Found DEK for account') + if (verifyDEKSignature(message, messageSignature, registeredEncryptionKey, logger)) { + await db.transaction(async (trx) => { + await updateDekSignerRecord(db, signer, registeredEncryptionKey!, logger, trx) + }) + return true + } + } + } + + // Fallback to previous signing pattern + logger.info( + { 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, + registeredEncryptionKey: string, + logger?: Logger +) { + logger = logger ?? rootLogger(fetchEnv('SERVICE_NAME')) + try { + // 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') + const key = ec.keyFromPublic(trimLeading0x(registeredEncryptionKey), 'hex') + const parsedSig = JSON.parse(messageSignature) + // 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 + } + return false + } catch (err) { + logger?.error('Failed to verify signature with DEK') + logger?.error({ err, dek: registeredEncryptionKey }) + return false + } +} + +export async function getDataEncryptionKey( + address: string, + contractKit: ContractKit, + logger: Logger, + fullNodeTimeoutMs: number, + fullNodeRetryCount: number, + fullNodeRetryDelayMs: number +): Promise { + try { + const res = await retryAsyncWithBackOffAndTimeout( + async () => { + const accountWrapper: AccountsWrapper = await contractKit.contracts.getAccounts() + return accountWrapper.getDataEncryptionKey(address) + }, + fullNodeRetryCount, + [], + fullNodeRetryDelayMs, + 1.5, + fullNodeTimeoutMs + ) + return res + } catch (error) { + logger.error('Failed to retrieve DEK: ' + error) + logger.error(ErrorMessage.FULL_NODE_ERROR) + throw error + } +} + +export async function isVerified( + account: string, + hashedPhoneNumber: string, + contractKit: ContractKit, + logger: Logger +): Promise { + try { + const res = await retryAsyncWithBackOffAndTimeout( + async () => { + const attestationsWrapper: AttestationsWrapper = + await contractKit.contracts.getAttestations() + const { + isVerified: _isVerified, + completed, + numAttestationsRemaining, + total, + } = await attestationsWrapper.getVerifiedStatus(hashedPhoneNumber, account) + + logger.debug({ + account, + isVerified: _isVerified, + completedAttestations: completed, + remainingAttestations: numAttestationsRemaining, + totalAttestationsRequested: total, + }) + return _isVerified + }, + RETRY_COUNT, + [], + RETRY_DELAY_IN_MS, + 1.5, + FULL_NODE_TIMEOUT_IN_MS + ) + return res + } catch (error) { + logger.error('Failed to get verification status: ' + error) + logger.error(ErrorMessage.FULL_NODE_ERROR) + logger.warn('Assuming user is verified') + return true + } +} diff --git a/packages/phone-number-privacy/combiner/src/utils/firebase-configs.ts b/packages/phone-number-privacy/combiner/src/utils/firebase-configs.ts index 4a76f8b5951..f553f0836b1 100644 --- a/packages/phone-number-privacy/combiner/src/utils/firebase-configs.ts +++ b/packages/phone-number-privacy/combiner/src/utils/firebase-configs.ts @@ -1,9 +1,12 @@ import { + DB_POOL_MAX_SIZE, + DB_TIMEOUT, FULL_NODE_TIMEOUT_IN_MS, RETRY_COUNT, RETRY_DELAY_IN_MS, } from '@celo/phone-number-privacy-common' import { defineBoolean, defineInt, defineSecret, defineString } from 'firebase-functions/params' +import { SupportedDatabase } from '../config' const defaultServiceName = 'odis-combiner' @@ -65,3 +68,14 @@ export const domainFullNodeRetryCount = defineInt('DOMAIN_FULL_NODE_RETRY_COUNT' export const domainFullNodeDelaysMs = defineInt('DOMAIN_FULL_NODE_DELAY_MS', { default: RETRY_DELAY_IN_MS, }) + +// DB +export const dbType = defineString('DB_TYPE', { default: SupportedDatabase.Postgres.toString() }) +export const dbUsername = defineString('DB_USERNAME') +export const dbPassword = defineSecret('DB_PASSWORD') +export const dbName = defineString('DB_NAME') +export const dbHost = defineString('DB_HOST') +export const dbPort = defineInt('DB_PORT', { default: undefined }) +export const dbSsl = defineBoolean('DB_SSL', { default: true }) +export const dbPoolMaxSize = defineInt('DB_POOL_MAX_SIZE', { default: DB_POOL_MAX_SIZE }) +export const dbTimeout = defineInt('DB_TIMEOUT', { default: DB_TIMEOUT }) diff --git a/packages/phone-number-privacy/combiner/src/utils/utils.ts b/packages/phone-number-privacy/combiner/src/utils/utils.ts new file mode 100644 index 00000000000..deb6a5a490f --- /dev/null +++ b/packages/phone-number-privacy/combiner/src/utils/utils.ts @@ -0,0 +1,9 @@ +import { Knex } from 'knex' + +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/combiner/test/integration/domain.test.ts b/packages/phone-number-privacy/combiner/test/integration/domain.test.ts index c997f5b5eea..43b0e40fdb6 100644 --- a/packages/phone-number-privacy/combiner/test/integration/domain.test.ts +++ b/packages/phone-number-privacy/combiner/test/integration/domain.test.ts @@ -44,6 +44,7 @@ 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' +import { initDatabase as initCombinerDatabase } from '../../src/database/database' import { serverClose } from '../utils' const { @@ -238,6 +239,7 @@ describe('domainService', () => { let signer2: Server | HttpsServer let signer3: Server | HttpsServer let app: any + let db: Knex const signerMigrationsPath = '../signer/dist/common/database/migrations' @@ -271,11 +273,12 @@ describe('domainService', () => { [`${DefaultKeyName.DOMAINS}-3`, DOMAINS_THRESHOLD_DEV_PK_SHARE_3_V3], ]) ) - - app = startCombiner(combinerConfig, getContractKitWithAgent(combinerConfig.blockchain)) }) beforeEach(async () => { + db = await initCombinerDatabase(combinerConfig) + app = startCombiner(db, combinerConfig, getContractKitWithAgent(combinerConfig.blockchain)) + signerDB1 = await initSignerDatabase(signerConfig, signerMigrationsPath) signerDB2 = await initSignerDatabase(signerConfig, signerMigrationsPath) signerDB3 = await initSignerDatabase(signerConfig, signerMigrationsPath) @@ -285,9 +288,12 @@ describe('domainService', () => { await signerDB1?.destroy() await signerDB2?.destroy() await signerDB3?.destroy() + await serverClose(signer1) await serverClose(signer2) await serverClose(signer3) + + await db?.destroy() }) describe('when signers are operating correctly', () => { @@ -419,6 +425,7 @@ describe('domainService', () => { ) configWithApiDisabled.domains.enabled = false const appWithApiDisabled = startCombiner( + db, configWithApiDisabled, getContractKitWithAgent(configWithApiDisabled.blockchain) ) @@ -568,6 +575,7 @@ describe('domainService', () => { ) configWithApiDisabled.domains.enabled = false const appWithApiDisabled = startCombiner( + db, configWithApiDisabled, getContractKitWithAgent(configWithApiDisabled.blockchain) ) @@ -898,6 +906,7 @@ describe('domainService', () => { ) configWithApiDisabled.domains.enabled = false const appWithApiDisabled = startCombiner( + db, configWithApiDisabled, getContractKitWithAgent(configWithApiDisabled.blockchain) ) @@ -1220,13 +1229,16 @@ describe('domainService', () => { ], ]) ) + }) + + beforeEach(async () => { + db = await initCombinerDatabase(combinerConfig) app = startCombiner( + db, combinerConfigLargerN, getContractKitWithAgent(combinerConfigLargerN.blockchain) ) - }) - beforeEach(async () => { signerDB1 = await initSignerDatabase(signerConfig, signerMigrationsPath) signerDB2 = await initSignerDatabase(signerConfig, signerMigrationsPath) signerDB3 = await initSignerDatabase(signerConfig, signerMigrationsPath) @@ -1246,11 +1258,14 @@ describe('domainService', () => { await signerDB3?.destroy() await signerDB4?.destroy() await signerDB5?.destroy() + await serverClose(signer1) await serverClose(signer2) await serverClose(signer3) await serverClose(signer4) await serverClose(signer5) + + await db?.destroy() }) it('Should respond with 200 on valid request', async () => { diff --git a/packages/phone-number-privacy/combiner/test/integration/pnp.test.ts b/packages/phone-number-privacy/combiner/test/integration/pnp.test.ts index a84d5140950..20e97315612 100644 --- a/packages/phone-number-privacy/combiner/test/integration/pnp.test.ts +++ b/packages/phone-number-privacy/combiner/test/integration/pnp.test.ts @@ -36,6 +36,8 @@ import { Knex } from 'knex' import request from 'supertest' import config, { getCombinerVersion } from '../../src/config' import { startCombiner } from '../../src/server' + +import { initDatabase as initCombinerDatabase } from '../../src/database/database' import { getBlindedPhoneNumber, serverClose } from '../utils' const { @@ -190,6 +192,7 @@ describe('pnpService', () => { let signer2: Server | HttpsServer let signer3: Server | HttpsServer let app: any + let db: Knex // Used by PNP_SIGN tests for various configurations of signers let userSeed: Uint8Array @@ -203,7 +206,7 @@ describe('pnpService', () => { const message = Buffer.from('test message', 'utf8') - // In current setup, the same mocked kit is used for the combiner and signers + // In current setup, the same mocked kit is used for the app and signers const mockKit = newKit('dummyKit') const sendPnpSignRequest = async ( @@ -296,10 +299,11 @@ describe('pnpService', () => { [`${DefaultKeyName.PHONE_NUMBER_PRIVACY}-3`, PNP_THRESHOLD_DEV_PK_SHARE_3_V3], ]) ) - app = startCombiner(combinerConfig, mockKit) }) beforeEach(async () => { + db = await initCombinerDatabase(combinerConfig) + app = startCombiner(db, combinerConfig, mockKit) signerDB1 = await initSignerDatabase(signerConfig, signerMigrationsPath) signerDB2 = await initSignerDatabase(signerConfig, signerMigrationsPath) signerDB3 = await initSignerDatabase(signerConfig, signerMigrationsPath) @@ -319,9 +323,12 @@ describe('pnpService', () => { await signerDB1?.destroy() await signerDB2?.destroy() await signerDB3?.destroy() + await serverClose(signer1) await serverClose(signer2) await serverClose(signer3) + + await db?.destroy() }) describe('when signers are operating correctly', () => { @@ -590,7 +597,7 @@ describe('pnpService', () => { JSON.stringify(combinerConfig) ) configWithApiDisabled.phoneNumberPrivacy.enabled = false - const appWithApiDisabled = startCombiner(configWithApiDisabled, mockKit) + const appWithApiDisabled = startCombiner(db, configWithApiDisabled, mockKit) const req = { account: ACCOUNT_ADDRESS1, } @@ -881,7 +888,7 @@ describe('pnpService', () => { JSON.stringify(combinerConfig) ) configWithApiDisabled.phoneNumberPrivacy.enabled = false - const appWithApiDisabled = startCombiner(configWithApiDisabled, mockKit) + const appWithApiDisabled = startCombiner(db, configWithApiDisabled, mockKit) const authorization = getPnpRequestAuthorization(req, PRIVATE_KEY1) const res = await sendPnpSignRequest(req, authorization, appWithApiDisabled) @@ -907,6 +914,7 @@ describe('pnpService', () => { JSON.stringify(combinerConfig) ) const appWithFailOpenDisabled = startCombiner( + db, combinerConfigWithFailOpenDisabled, mockKit ) @@ -923,7 +931,7 @@ describe('pnpService', () => { }) }) - // For testing combiner code paths when signers do not behave as expected + // For testing app code paths when signers do not behave as expected describe('when signers are not operating correctly', () => { beforeEach(() => { mockOdisPaymentsTotalPaidCUSD.mockReturnValue(onChainPaymentsDefault) @@ -1233,12 +1241,14 @@ describe('pnpService', () => { ], ]) ) - app = startCombiner(combinerConfigLargerN, mockKit) }) let req: SignMessageRequest beforeEach(async () => { + db = await initCombinerDatabase(combinerConfig) + app = startCombiner(db, combinerConfigLargerN, mockKit) + signerDB1 = await initSignerDatabase(signerConfig, signerMigrationsPath) signerDB2 = await initSignerDatabase(signerConfig, signerMigrationsPath) signerDB3 = await initSignerDatabase(signerConfig, signerMigrationsPath) @@ -1266,11 +1276,14 @@ describe('pnpService', () => { await signerDB3?.destroy() await signerDB4?.destroy() await signerDB5?.destroy() + await serverClose(signer1) await serverClose(signer2) await serverClose(signer3) await serverClose(signer4) await serverClose(signer5) + + await db?.destroy() }) it('Should respond with 200 on valid request', async () => {