import { authenticateUser , ErrorMessage , GetContactMatchesRequest , getDataEncryptionKey , hasValidAccountParam , hasValidContactPhoneNumbersParam , hasValidIdentifier , hasValidUserPhoneNumberParam , isVerified , WarningMessage , } from '@celo/phone-number-privacy-common' ; import { trimLeading0x } from '@celo/utils/lib/address' ; import Logger from 'bunyan' ; import { ec as EC } from 'elliptic' ; 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' ; const ec = new EC ( 'secp256k1' ) ; interface ContactMatch { phoneNumber : string ; } export interface VerifiedPhoneNumberDekSignature { signedUserPhoneNumber : string ; dekSigner : string ; } export async function handleGetContactMatches ( request , response ) { const logger = response . locals . logger ; try { if ( ! isValidGetContactMatchesInput ( request . body ) ) { respondWithError ( response , 400 , WarningMessage . INVALID_INPUT , logger ) ; return ; } let isAuthenticated = true ; try { isAuthenticated = await authenticateUser ( request , getContractKit ( ) , logger ) ; } catch { logger . error ( 'Forno error caught in handleGetContactMatches line 57' ) ; 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 ) ) { let _isVerified = true ; try { _isVerified = await isVerified ( account , hashedPhoneNumber , getContractKit ( ) , logger ) ; } catch { logger . error ( 'Forno error caught in handleGetContactMatches line 80' ) ; 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' ) ; } let verifiedPhoneNumberDekSig ; if ( signedUserPhoneNumber ) { 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' ) ; } if ( dekSigner ) { if ( ! verifyDEKSignature ( userPhoneNumber , signedUserPhoneNumber , dekSigner , logger ) ) { 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 , userPhoneNumber , contactPhoneNumbers , response , logger , verifiedPhoneNumberDekSig ? ) { const matchedContacts = ( 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 , logger ) { return getDidMatchmaking ( account , logger ) . catch ( ( err ) => { logger . warn ( 'Failed to determine if user has performed matchmaking.' ) ; throw err ; } ) ; } async function isInvalidReplay ( account , userPhoneNumber , logger , signedUserPhoneNumber ? ) { if ( ! ( await isReplay ( account , logger ) ) ) { return false ; } if ( ! signedUserPhoneNumber ) { 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 ; } export function verifyDEKSignature ( message , messageSignature , registeredEncryptionKey , logger ? ) { try { const key = ec . keyFromPublic ( trimLeading0x ( registeredEncryptionKey ) , 'hex' ) ; return key . verify ( message , JSON . parse ( messageSignature ) ) ; } catch ( err ) { if ( logger ) { logger . error ( 'Failed to verify signature with DEK' ) ; logger . error ( { err , dek : registeredEncryptionKey } ) ; } return false ; } } async function userHasNewDek ( account , userPhoneNumber , signedUserPhoneNumberRecord , logger ) { const dekSignerRecord = await getDekSignerRecord ( account , logger ) ; const isKeyRotation = ! ! dekSignerRecord && verifyDEKSignature ( userPhoneNumber , signedUserPhoneNumberRecord , dekSignerRecord , logger ) ; if ( isKeyRotation ) { logger . info ( { account , dekSignerRecord , } , 'User has rotated their dek since first requesting matches.' ) ; } return isKeyRotation ; } function isValidGetContactMatchesInput ( requestBody ) { return ( hasValidAccountParam ( requestBody ) && hasValidUserPhoneNumberParam ( requestBody ) && hasValidContactPhoneNumbersParam ( requestBody ) && hasValidIdentifier ( requestBody ) ) ; } function shouldBypassVerificationForE2ETesting ( userPhoneNumber , account ) { return ( config . blockchain . provider === FORNO_ALFAJORES && E2E_TEST_PHONE_NUMBERS . includes ( userPhoneNumber ) && E2E_TEST_ACCOUNTS . includes ( account ) ) ; }