From 64c35a5c0ab3488eb4d8ec9046836f163263eb8c Mon Sep 17 00:00:00 2001 From: calebtuttle <1calebtuttle@gmail.com> Date: Wed, 2 Aug 2023 15:51:16 -0400 Subject: [PATCH] add idenfy --- src/index.js | 2 + src/init.js | 30 ++- src/routes/idenfy.js | 10 + src/services/idenfy/credentials.js | 418 +++++++++++++++++++++++++++++ src/services/idenfy/session.js | 74 +++++ 5 files changed, 532 insertions(+), 2 deletions(-) create mode 100644 src/routes/idenfy.js create mode 100644 src/services/idenfy/credentials.js create mode 100644 src/services/idenfy/session.js diff --git a/src/index.js b/src/index.js index 0a843e5..c8ae38c 100644 --- a/src/index.js +++ b/src/index.js @@ -3,6 +3,7 @@ import cors from "cors"; import registerVouched from "./routes/register-vouched.js"; import vouchedMisc from "./routes/vouched.js"; import veriff from "./routes/veriff.js"; +import idenfy from "./routes/idenfy.js"; import credentials from "./routes/credentials.js"; import proofMetadata from "./routes/proof-metadata.js"; import admin from "./routes/admin.js"; @@ -23,6 +24,7 @@ app.use("/vouched", vouchedMisc); app.use("/credentials", credentials); app.use("/proof-metadata", proofMetadata); app.use("/veriff", veriff); +app.use("/idenfy", idenfy); app.use("/admin", admin); app.get("/", (req, res) => { diff --git a/src/init.js b/src/init.js index 5873034..d12d9d4 100644 --- a/src/init.js +++ b/src/init.js @@ -84,6 +84,7 @@ async function initializeDailyVerificationCount(DailyVerificationCount) { }); const vouchedJobCount = resp.data?.total || 0; // TODO: Get total Veriff verifications + // TODO: Get total iDenfy verifications const newDailyVerificationCount = new DailyVerificationCount({ date: new Date().toISOString().slice(0, 10), vouched: { @@ -92,6 +93,9 @@ async function initializeDailyVerificationCount(DailyVerificationCount) { veriff: { sessionCount: 0, }, + idenfy: { + sessionCount: 0, + }, }); await newDailyVerificationCount.save(); } @@ -237,6 +241,12 @@ async function initializeMongoDb() { }, required: false, }, + idenfy: { + type: { + sessionCount: Number, + }, + required: false, + }, }); const DailyVerificationCount = mongoose.model( "DailyVerificationCount", @@ -256,7 +266,14 @@ async function initializeMongoDb() { const VerificationCollisionMetadataSchema = new Schema({ uuid: String, timestamp: Date, - sessionId: String, + sessionId: { + type: String, + required: false, + }, + scanRef: { + type: String, + required: false, + }, uuidConstituents: { firstName: { populated: Boolean, @@ -265,7 +282,16 @@ async function initializeMongoDb() { populated: Boolean, }, postcode: { - populated: Boolean, + populated: { + type: Boolean, + required: false, + }, + }, + address: { + populated: { + type: Boolean, + required: false, + }, }, dateOfBirth: { populated: Boolean, diff --git a/src/routes/idenfy.js b/src/routes/idenfy.js new file mode 100644 index 0000000..2069b12 --- /dev/null +++ b/src/routes/idenfy.js @@ -0,0 +1,10 @@ +import express from "express"; +import { getCredentials } from "../services/idenfy/credentials.js"; +import { createSession } from "../services/idenfy/session.js"; + +const router = express.Router(); + +router.get("/credentials", getCredentials); +router.post("/session", createSession); + +export default router; diff --git a/src/services/idenfy/credentials.js b/src/services/idenfy/credentials.js new file mode 100644 index 0000000..27b3c58 --- /dev/null +++ b/src/services/idenfy/credentials.js @@ -0,0 +1,418 @@ +import axios from "axios"; +import { strict as assert } from "node:assert"; +import { createHmac } from "crypto"; +import ethersPkg from "ethers"; +const { ethers } = ethersPkg; +import { poseidon } from "circomlibjs-old"; +import { UserVerifications, VerificationCollisionMetadata } from "../../init.js"; +import { issue } from "holonym-wasm-issuer"; +import { + sign, + createLeaf, + getDateAsInt, + logWithTimestamp, + hash, + generateSecret, +} from "../../utils/utils.js"; +import { newDummyUserCreds, countryCodeToPrime } from "../../utils/constants.js"; +// import { getPaymentStatus } from "../utils/paypal.js"; + +const idenfyApiKey = process.env.IDENFY_API_KEY; +const idenfyApiKeySecret = process.env.IDENFY_API_KEY_SECRET; + +function validateSession(statusData, verificationData, scanRef) { + if (!statusData) { + return { + error: "Failed to retrieve iDenfy session.", + log: `idenfy/credentials: Failed to retrieve iDenfy session ${scanRef}. Exiting.`, + }; + } + if (statusData.autoDocument !== "DOC_VALIDATED") { + return { + error: `Verification failed. Failed to auto validate document.`, + log: `idenfy/credentials: Verification failed. autoDocument: ${statusData.autoDocument}. Exiting.`, + }; + } + if (statusData.autoFace !== "FACE_MATCH") { + return { + error: `Verification failed. Failed to auto match face.`, + log: `idenfy/credentials: Verification failed. autoFace: ${statusData.autoFace}. Exiting.`, + }; + } + if (statusData.manualDocument !== "DOC_VALIDATED") { + return { + error: `Verification failed. Failed to manually validate document.`, + log: `idenfy/credentials: Verification failed. manualDocument: ${statusData.manualDocument}. Exiting.`, + }; + } + if (statusData.manualFace !== "FACE_MATCH") { + return { + error: `Verification failed. Failed to manually match face.`, + log: `idenfy/credentials: Verification failed. manualFace: ${statusData.manualFace}. Exiting.`, + }; + } + if (statusData.status !== "APPROVED") { + return { + error: `Verification failed. Status is ${statusData.status}. Expected 'APPROVED'.`, + log: `idenfy/credentials: Verification failed. manualFace: ${statusData.manualFace}. Exiting.`, + }; + } + // NOTE: We are allowing address fields (other than country) to be empty for now + const necessaryFields = ["docFirstName", "docLastName", "docDob", "docNationality"]; + for (const field of necessaryFields) { + if (!(field in verificationData)) { + return { + error: `Verification data missing necessary field: ${field}.`, + log: `idenfy/credentials: Verification data missing necessary field: ${field}. Exiting.`, + }; + } + } + return { success: true }; +} + +function extractCreds(verificationData) { + // ["docFirstName", "docLastName", "docDob", "docNationality"]; + const countryCode = countryCodeToPrime[verificationData.docNationality]; + assert.ok(countryCode, "Unsupported country"); + const birthdate = verificationData.docDob ?? ""; + const birthdateNum = birthdate ? getDateAsInt(birthdate) : 0; + const firstNameStr = verificationData.docFirstName ?? ""; + const firstNameBuffer = firstNameStr ? Buffer.from(firstNameStr) : Buffer.alloc(1); + // We keep middle name for backwards compatibility, though we don't parse it from firstName currently + // const middleNameStr = person.middleName ? person.middleName : ""; + // const middleNameBuffer = middleNameStr + // ? Buffer.from(middleNameStr) + // : Buffer.alloc(1); + const middleNameStr = ""; + const middleNameBuffer = Buffer.alloc(1); + const lastNameStr = verificationData.docLastName ?? ""; + const lastNameBuffer = lastNameStr ? Buffer.from(lastNameStr) : Buffer.alloc(1); + const nameArgs = [firstNameBuffer, middleNameBuffer, lastNameBuffer].map((x) => + ethers.BigNumber.from(x).toString() + ); + const nameHash = ethers.BigNumber.from(poseidon(nameArgs)).toString(); + // NOTE: We currently do not parse address fields from iDenfy response. We keep these + // fields (even if they are empty) for backwards compatibility. + const cityStr = ""; + const cityBuffer = cityStr ? Buffer.from(cityStr) : Buffer.alloc(1); + const subdivisionStr = ""; + const subdivisionBuffer = subdivisionStr + ? Buffer.from(subdivisionStr) + : Buffer.alloc(1); + // const streetNumber = Number(address?.houseNumber ? address.houseNumber : 0); + const streetNumber = 0; + const streetNameStr = ""; + const streetNameBuffer = streetNameStr + ? Buffer.from(streetNameStr) + : Buffer.alloc(1); + // const streetUnit = address?.unit?.includes("apt ") + // ? Number(address?.unit?.replace("apt ", "")) + // : address?.unit != null && typeof Number(address?.unit) == "number" + // ? Number(address?.unit) + // : 0; + const streetUnit = 0; + const addrArgs = [streetNumber, streetNameBuffer, streetUnit].map((x) => + ethers.BigNumber.from(x).toString() + ); + const streetHash = ethers.BigNumber.from(poseidon(addrArgs)).toString(); + // const zipCode = Number(address?.postcode ? address.postcode : 0); + const zipCode = 0; + const addressArgs = [cityBuffer, subdivisionBuffer, zipCode, streetHash].map((x) => + ethers.BigNumber.from(x) + ); + const addressHash = ethers.BigNumber.from(poseidon(addressArgs)).toString(); + // BIG NOTE: We are not including expiration date in issued credentials, but + // we might in the future. + // const expireDateSr = verificationData.docExpiry ?? ""; + const expireDateSr = ""; + const expireDateNum = expireDateSr ? getDateAsInt(expireDateSr) : 0; + const nameDobAddrExpireArgs = [ + nameHash, + birthdateNum, + addressHash, + expireDateNum, + ].map((x) => ethers.BigNumber.from(x).toString()); + const nameDobAddrExpire = ethers.BigNumber.from( + poseidon(nameDobAddrExpireArgs) + ).toString(); + return { + rawCreds: { + countryCode: countryCode, + firstName: firstNameStr, + middleName: middleNameStr, + lastName: lastNameStr, + city: cityStr, + subdivision: subdivisionStr, + // zipCode: address?.postcode ? address.postcode : 0, + zipCode: 0, + streetNumber: streetNumber, + streetName: streetNameStr, + streetUnit: streetUnit, + completedAt: new Date().toISOString().split("T")[0], + birthdate: birthdate, + expirationDate: expireDateSr, + }, + derivedCreds: { + nameDobCitySubdivisionZipStreetExpireHash: { + value: nameDobAddrExpire, + derivationFunction: "poseidon", + inputFields: [ + "derivedCreds.nameHash.value", + "rawCreds.birthdate", + "derivedCreds.addressHash.value", + "rawCreds.expirationDate", + ], + }, + streetHash: { + value: streetHash, + derivationFunction: "poseidon", + inputFields: [ + "rawCreds.streetNumber", + "rawCreds.streetName", + "rawCreds.streetUnit", + ], + }, + addressHash: { + value: addressHash, + derivationFunction: "poseidon", + inputFields: [ + "rawCreds.city", + "rawCreds.subdivision", + "rawCreds.zipCode", + "derivedCreds.streetHash.value", + ], + }, + nameHash: { + value: nameHash, + derivationFunction: "poseidon", + inputFields: [ + "rawCreds.firstName", + "rawCreds.middleName", + "rawCreds.lastName", + ], + }, + }, + fieldsInLeaf: [ + "issuer", + "secret", + "rawCreds.countryCode", + "derivedCreds.nameDobCitySubdivisionZipStreetExpireHash.value", + "rawCreds.completedAt", + "scope", + ], + }; +} + +async function saveCollisionMetadata(uuid, scanRef, verificationData) { + try { + const collisionMetadataDoc = new VerificationCollisionMetadata({ + uuid: uuid, + timestamp: new Date(), + scanRef: scanRef, + uuidConstituents: { + firstName: { + populated: !!verificationData.docFirstName, + }, + lastName: { + populated: !!verificationData.docLastName, + }, + // postcode: { + // populated: !!verificationData.addresses?.[0]?.postcode, + // }, + address: { + populated: !!verificationData.address, + }, + dateOfBirth: { + populated: !!verificationData.docDob, + }, + }, + }); + + await collisionMetadataDoc.save(); + } catch (err) { + console.log("Error recording collision metadata", err); + } +} + +async function saveUserToDb(uuid, scanRef) { + const userVerificationsDoc = new UserVerifications({ + govId: { + uuid: uuid, + sessionId: scanRef, + issuedAt: new Date(), + }, + }); + try { + await userVerificationsDoc.save(); + } catch (err) { + console.log(err); + logWithTimestamp( + "idenfy/credentials: Could not save userVerificationsDoc. Exiting" + ); + return { + error: + "An error occurred while trying to save object to database. Please try again.", + }; + } + return { success: true }; +} + +async function getIdenfySessionStatus(scanRef) { + try { + const resp = await axios.post( + "https://ivs.idenfy.com/api/v2/status", + { + scanRef, + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${Buffer.from( + `${idenfyApiKey}:${idenfyApiKeySecret}` + ).toString("base64")}`, + }, + } + ); + return resp.data; + } catch (err) { + console.error( + `Error getting iDenfy session status with scanRef ${scanRef}`, + err.message + ); + return {}; + } +} + +async function getIdenfySessionVerificationData(scanRef) { + try { + const resp = await axios.post( + "https://ivs.idenfy.com/api/v2/data", + { + scanRef, + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${Buffer.from( + `${idenfyApiKey}:${idenfyApiKeySecret}` + ).toString("base64")}`, + }, + } + ); + return resp.data; + } catch (err) { + console.error( + `Error getting iDenfy session data with scanRef ${scanRef}`, + err.message + ); + return {}; + } +} + +async function redactVeriffSession(sessionId) { + try { + const hmacSignature = crypto + .createHmac("sha256", veriffSecretKey) + .update(Buffer.from(sessionId, "utf8")) + .digest("hex") + .toLowerCase(); + const resp = await axios.delete(`https://api.veriff.me/v1/sessions/${sessionId}`, { + headers: { + "X-AUTH-CLIENT": veriffPublicKey, + "X-HMAC-SIGNATURE": hmacSignature, + "Content-Type": "application/json", + }, + }); + return resp.data; + } catch (err) { + console.log(err.message); + return {}; + } +} + +/** + * ENDPOINT + * + * Allows user to retrieve their Vouched verification info + */ +async function getCredentials(req, res) { + try { + if (process.env.ENVIRONMENT == "dev") { + const creds = newDummyUserCreds; + logWithTimestamp("idenfy/credentials: Generating signature"); + + const response = issue( + process.env.HOLONYM_ISSUER_PRIVKEY, + creds.rawCreds.countryCode.toString(), + creds.derivedCreds.nameDobCitySubdivisionZipStreetExpireHash.value + ); + response.metadata = newDummyUserCreds; + + return res.status(200).json(response); + } + + const scanRef = req.query?.scanRef; + if (!scanRef) { + logWithTimestamp("idenfy/credentials: No scanRef specified. Exiting."); + return res.status(400).json({ error: "No scanRef specified" }); + } + const statusData = await getIdenfySessionStatus(scanRef); + const verificationData = await getIdenfySessionVerificationData(scanRef); + + const validationResult = validateSession(statusData, verificationData, scanRef); + if (validationResult.error) { + logWithTimestamp(validationResult.log); + return res.status(400).json({ error: validationResult.error }); + } + + // Get UUID + const uuidConstituents = + (verificationData.docFirstName || "") + + (verificationData.docLastName || "") + + // (verificationData.addresses?.[0]?.postcode || "") + + // iDenfy doesn't parse postal code from address, so we use the whole address for now + (verificationData.address || "") + + (verificationData.docDob || ""); + const uuid = hash(Buffer.from(uuidConstituents)).toString("hex"); + + // Assert user hasn't registered yet + const user = await UserVerifications.findOne({ "govId.uuid": uuid }).exec(); + if (user) { + await saveCollisionMetadata(uuid, scanRef, verificationData); + + logWithTimestamp( + `idenfy/credentials: User has already registered. Exiting. UUID == ${uuid}` + ); + return res + .status(400) + .json({ error: `User has already registered. UUID: ${uuid}` }); + } + + // Store UUID for Sybil resistance + logWithTimestamp(`idenfy/credentials: Inserting user into database`); + const dbResponse = await saveUserToDb(uuid, scanRef); + if (dbResponse.error) return res.status(400).json(dbResponse); + + const creds = extractCreds(verificationData); + + logWithTimestamp("idenfy/credentials: Generating signature"); + + const response = issue( + process.env.HOLONYM_ISSUER_PRIVKEY, + creds.rawCreds.countryCode.toString(), + creds.derivedCreds.nameDobCitySubdivisionZipStreetExpireHash.value + ); + response.metadata = creds; + + await redactVeriffSession(scanRef); + + logWithTimestamp(`idenfy/credentials: Returning user whose UUID is ${uuid}`); + + return res.status(200).json(response); + } catch (err) { + console.error(err); + return res.status(500).send(); + } +} + +export { getCredentials }; diff --git a/src/services/idenfy/session.js b/src/services/idenfy/session.js new file mode 100644 index 0000000..e2e7fa0 --- /dev/null +++ b/src/services/idenfy/session.js @@ -0,0 +1,74 @@ +import axios from "axios"; +import { v4 as uuidV4 } from "uuid"; +import { DailyVerificationCount } from "../../init.js"; +import { logWithTimestamp, sendEmail } from "../../utils/utils.js"; + +async function createSession(req, res) { + try { + console.log("req.body: ", req.body); + console.log("req.query: ", req.query); + const sigDigest = req.body.sigDigest; // holoAuthSigDigest + + if (!sigDigest) { + return res.status(400).json({ error: "sigDigest is required" }); + } + + // Increment sessionCount in today's verification count doc. If doc doesn't exist, + // create it, and set iDenfy sessionCount to 1. + // findOneAndUpdate is used so that the operation is atomic. + const verificationCountDoc = await DailyVerificationCount.findOneAndUpdate( + { date: new Date().toISOString().slice(0, 10) }, + { $inc: { "idenfy.sessionCount": 1 } }, + { upsert: true, returnOriginal: false } + ).exec(); + const sessionCountToday = verificationCountDoc.idenfy.sessionCount; + + // Send 2 emails after 5k verifications + if (sessionCountToday > 5000 && sessionCountToday <= 5002) { + for (const email of ADMIN_EMAILS) { + const subject = "iDenfy session count for the day exceeded 5000!!"; + const message = `iDenfy session count for the day is ${sessionCountToday}.`; + await sendEmail(email, subject, message); + } + } + if (sessionCountToday > 5000) { + return res.status(503).json({ + error: + "We cannot service more verifications today. Please try again tomorrow.", + }); + } + + // Prepare request and create session + const reqBody = { + clientId: sigDigest, + }; + const config = { + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${Buffer.from( + `${process.env.IDENFY_API_KEY}:${process.env.IDENFY_API_KEY_SECRET}` + ).toString("base64")}`, + }, + }; + const resp = await axios.post( + "https://ivs.idenfy.com/api/v2/token", + reqBody, + config + ); + const session = resp?.data; + logWithTimestamp( + `POST idenfy/session: Created session with authToken ${session.authToken}` + ); + return res.status(200).json({ + url: `https://ivs.idenfy.com/api/v2/redirect?authToken=${session.authToken}`, + scanRef: session.scanRef, + }); + } catch (err) { + logWithTimestamp(`POST idenfy/session: Error creating session`); + console.log(err.message); + console.log(err?.response?.data); + return res.status(500).json({ error: "An unknown error occurred" }); + } +} + +export { createSession };