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 , response ) { const 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 , response ) { const responses = [ ] ; const failedRequests = new Set < string > ( ) ; const errorCodes = new Map ( ) ; const blsCryptoClient = new BLSCryptographyClient ( ) ; const 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 ) => { 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 = 500 ; if ( err . name === 'AbortError' ) { if ( timedOut ) { status = 408 ; logger . error ( { signer : service } , ErrorMessage . TIMEOUT_FROM_SIGNER ) ; } else { 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 { } } handleMissingSignatures ( majorityErrorCode , response , logger ) ; } async function handleSuccessResponse ( data , status , response , responses , serviceUrl , blsCryptoClient , blindedQueryPhoneNumber , controller ) { const logger = response . locals . logger ; const signResponse = JSON . parse ( data ) as SignerResponse ; if ( ! signResponse . success ) { 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' ) ; if ( blsCryptoClient . hasSufficientSignatures ( ) ) { try { await blsCryptoClient . combinePartialBlindedSignatures ( blindedQueryPhoneNumber , logger ) ; controller . abort ( ) ; } catch { } } } function handleFailedResponse ( service , status , signerCount , failedRequests , response , controller , errorCodes ) { if ( status ) { errorCodes . set ( status , ( errorCodes . get ( status ) || 0 ) + 1 ) ; } const logger = response . locals . logger ; 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 , logger ) { const successfulResponses = responses . filter ( ( response ) => response . signMessageResponse && response . signMessageResponse . performedQueryCount && response . signMessageResponse . totalQuota && response . signMessageResponse . blockNumber ) ; if ( successfulResponses . length === 0 ) { return ; } 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 ) { 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 , request , controller , logger ) { 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 , request , controller , logger ) { 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 , logger ) { 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 ) => { if ( count > maxCount || ( count === maxCount && errorCode < maxErrorCode ) ) { maxCount = count ; maxErrorCode = errorCode ; } } ) ; return maxErrorCode > 0 ? maxErrorCode : null ; } function isValidGetSignatureInput ( requestBody ) { return ( hasValidAccountParam ( requestBody ) && hasValidBlindedPhoneNumberParam ( requestBody ) && identifierIsValidIfExists ( requestBody ) && isBodyReasonablySized ( requestBody ) ) ; } function handleMissingSignatures ( majorityErrorCode , response , logger ) { if ( majorityErrorCode === 403 ) { respondWithError ( response , 403 , WarningMessage . EXCEEDED_QUOTA , logger ) ; } else { respondWithError ( response , majorityErrorCode || 500 , ErrorMessage . NOT_ENOUGH_PARTIAL_SIGNATURES , logger ) ; } }