Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(Feature) Gender verification. Issue basic identity certificate #466

Merged
merged 15 commits into from
Mar 20, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/server/goodid/aws.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// @flow

import REK from 'aws-sdk/clients/rekognition'
import { once } from 'lodash'

import conf from '../server.config'

export const getRecognitionClient = once(() => {
const { awsSesAccessKey, awsSesSecretAccessKey, awsSesRegion } = conf

if (!awsSesAccessKey || !awsSesRegion || !awsSesSecretAccessKey) {
throw new Error('Missing AWS configuration')
}

return new REK({
region: awsSesRegion,
accessKeyId: awsSesAccessKey,
secretAccessKey: awsSesSecretAccessKey
})
})

export const detectFaces = async imageBase64 => {
const payload = {
Attributes: ['AGE_RANGE', 'GENDER'],
Image: {
Bytes: Buffer.from(imageBase64, 'base64')
}
}

return getRecognitionClient().detectFaces(payload).promise()
}
136 changes: 134 additions & 2 deletions src/server/goodid/goodid-middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ import { wrapAsync } from '../utils/helpers'
import requestRateLimiter from '../utils/requestRateLimiter'
import { get } from 'lodash'
import { Credential } from './veramo'
import createEnrollmentProcessor from '../verification/processor/EnrollmentProcessor'
import { enrollmentNotFoundMessage } from '../verification/utils/constants'

export default function addGoodIDMiddleware(app: Router, utils) {
const { Location, Gender, Age, Identity } = Credential

export default function addGoodIDMiddleware(app: Router, utils, storage) {
/**
* POST /goodid/certificate/location
* Content-Type: application/json
Expand Down Expand Up @@ -67,7 +71,7 @@ export default function addGoodIDMiddleware(app: Router, utils) {
const { longitude, latitude } = get(body, 'geoposition.coords', {})

const issueCertificate = async countryCode => {
const ceriticate = await utils.issueCertificate(gdAddress, Credential.Location, { countryCode })
const ceriticate = await utils.issueCertificate(gdAddress, Location, { countryCode })

res.json({ success: true, ceriticate })
}
Expand Down Expand Up @@ -110,6 +114,94 @@ export default function addGoodIDMiddleware(app: Router, utils) {
})
)

/**
* POST /goodid/certificate/identity
* Content-Type: application/json
* {
* "enrollmentIdentifier": "<v2 identifier string>",
* "fvSigner": "<v1 identifier string>", // optional
* "fvAgeCheck": "<none | strict | approximate>", // optional
* }
*
* HTTP/1.1 200 OK
* Content-Type: application/json
* {
* "success": true,
* "certificate": {
* "credential": {
* "credentialSubject": {
* "id": 'did:ethr:<g$ wallet address>',
* "gender": "<Male | Female>" // yep, AWS doesn't supports LGBT,
* "age": {
* "from": <years>, // "open" ranges also allowed, e.g. { to: 7 } or { from: 30 }
* "to": <years>, // this value includes to the range, "from 30" means 30 and older, if < 30 you will get "from 25 to 29"
* }
* },
* "issuer": {
* "id": 'did:key:<GoodServer's DID>',
* },
* "type": ["VerifiableCredential", "VerifiableIdentityCredential", "VerifiableAgeCredential", "VerifiableGenderCredential"],
* "@context": ["https://www.w3.org/2018/credentials/v1"],
* "issuanceDate": "2022-10-28T11:54:22.000Z",
* "proof": {
* "type": "JwtProof2020",
* "jwt": 'eyJhbGciOiJFUzI1NksiLCJ0eXAiOiJKV1QifQ.eyJ2YyI6eyJAY29udGV4dCI6WyJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSJdLCJ0eXBlIjpbIlZlcmlmaWFibGVDcmVkZW50aWFsIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7InlvdSI6IlJvY2sifX0sInN1YiI6ImRpZDp3ZWI6ZXhhbXBsZS5jb20iLCJuYmYiOjE2NjY5NTgwNjIsImlzcyI6ImRpZDpldGhyOmdvZXJsaToweDAzNTBlZWVlYTE0MTBjNWIxNTJmMWE4OGUwZmZlOGJiOGEwYmMzZGY4NjhiNzQwZWIyMzUyYjFkYmY5M2I1OWMxNiJ9.EPeuQBpkK13V9wu66SLg7u8ebY2OS8b2Biah2Vw-RI-Atui2rtujQkVc2t9m1Eqm4XQFECfysgQBdWwnSDvIjw',
* },
* },
* }
* }
*/
app.post(
'/goodid/certificate/identity',
requestRateLimiter(10, 1),
passport.authenticate('jwt', { session: false }),
wrapAsync(async (req, res) => {
const { user, body, log } = req
const { enrollmentIdentifier, fvSigner, fvAgeCheck } = body
const { gdAddress } = user

const enrollmentProcessor = createEnrollmentProcessor(storage, log)

try {
await enrollmentProcessor.verifyIdentifier(enrollmentIdentifier, gdAddress)

const { identifier, v1Identifier } = enrollmentProcessor.normalizeIdentifiers(enrollmentIdentifier, fvSigner)
const { exists, v1Exists } = await enrollmentProcessor.checkExistence(identifier, v1Identifier)

const faceIdentifier = exists ? identifier : v1Exists ? v1Identifier : null

if (!faceIdentifier) {
throw new Error(enrollmentNotFoundMessage)
}

const { auditTrailBase64 } = await enrollmentProcessor.getEnrollment(faceIdentifier, log)
const estimation = await utils.ageGenderCheck(auditTrailBase64)

if ((fvAgeCheck || 'none') !== 'none') {
// strict or approximate
estimation.age = await enrollmentProcessor.estimateAge(faceIdentifier, fvAgeCheck === 'strict', log)
}
johnsmith-gooddollar marked this conversation as resolved.
Show resolved Hide resolved

const ceriticate = await utils.issueCertificate(gdAddress, [Identity, Gender, Age], {
unique: true,
...estimation
})

res.json({ success: true, ceriticate })
} catch (exception) {
const { message } = exception

log.error('Failed to issue identity ceritifate:', message, exception, {
enrollmentIdentifier,
fvSigner,
fvAgeCheck
})

res.status(400).json({ success: false, error: message })
}
})
)

/**
* POST /goodid/certificate/verify
* Content-Type: application/json
Expand Down Expand Up @@ -163,3 +255,43 @@ export default function addGoodIDMiddleware(app: Router, utils) {
})
)
}

/*

app.post(
'/verify/agegender',
passport.authenticate('jwt', { session: false }),
requestRateLimiter(1, 1),
wrapAsync(async (req, res) => {
const { user, log } = req
let { v1Identifier, v2Identifier } = req.body
const { gdAddress } = user

const zoomProvider = getZoomProvider()

// for v2 identifier - verify that identifier is for the address we are going to whitelist
await verifyFVIdentifier(v2Identifier, gdAddress)

// TODO: processor & normalize
v2Identifier = v2Identifier.slice(0, 42)
v1Identifier = v1Identifier.replace('0x', '') // wallet will also supply the v1 identifier as fvSigner, we remove '0x' for public address

// here we check if wallet was registered using v1 of v2 identifier
const [recordV2, recordV1] = await Promise.all([
zoomProvider.getEnrollment(v2Identifier, log),
v1Identifier && zoomProvider.getEnrollment(v1Identifier, log)
])

const record = recordV2 || recordV1
if (!record) throw new Error('face record not found')
const { auditTrailBase64 } = record
const { FaceDetails } = await detectFaces(auditTrailBase64)
log.info({ FaceDetails })
await Promise.all([
// semaphore.enrollAge(FaceDetails[0].AgeRange),
// semaphore.enrollGender(FaceDetails[0].Gender.Value)
])

res.json({ ok: 1 })
})
)*/
johnsmith-gooddollar marked this conversation as resolved.
Show resolved Hide resolved
10 changes: 10 additions & 0 deletions src/server/goodid/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { PhoneNumberUtil } from 'google-libphonenumber'
import { substituteParams } from '../utils/axios'
import { flatten, get, toUpper } from 'lodash'
import { getAgent, getSubjectId } from './veramo'
import { detectFaces } from './aws'

export class GoodIDUtils {
constructor(httpApi, phoneNumberApi, getVeramoAgent) {
Expand Down Expand Up @@ -62,6 +63,15 @@ export class GoodIDUtils {
return phoneUtil.getRegionCodeForNumber(number)
}

async ageGenderCheck(imageBase64) {
const { FaceDetails } = await detectFaces(imageBase64)
const [{ AgeRange, Gender }] = FaceDetails
const { Value: gender } = Gender
const { Low: from, High: to } = AgeRange

return { gender, age: { from, to } }
johnsmith-gooddollar marked this conversation as resolved.
Show resolved Hide resolved
}

async issueCertificate(gdAddress, credentials, payload = {}) {
const agent = await this.getVeramoAgent()
const identifier = await agent.didManagerGetByAlias({ alias: 'default' })
Expand Down
2 changes: 1 addition & 1 deletion src/server/server-middlewares.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export default async (app: Router) => {

addStorageMiddlewares(app, UserDBPrivate)
addVerificationMiddlewares(app, VerificationAPI, UserDBPrivate)
addGoodIDMiddleware(app, GoodIDUtils)
addGoodIDMiddleware(app, GoodIDUtils, UserDBPrivate)

app.get(
'/strings',
Expand Down
34 changes: 25 additions & 9 deletions src/server/verification/api/ZoomAPI.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,19 @@ export class ZoomAPI {
return httpClientOptions
}

async estimateAge(enrollmentIdentifier, customLogger = null) {
const payload = {
externalDatabaseRefID: enrollmentIdentifier
}

try {
return await this.http.post(`/estimate-age-3d-v2`, payload, { customLogger })
} catch (exception) {
this._proceedEnrollmentNotFoundError(exception)
throw exception
}
}

_configureRequests() {
const { request } = this.http.interceptors

Expand Down Expand Up @@ -452,15 +465,7 @@ export class ZoomAPI {
try {
response = await this.http.post(`/3d-db/${operation}`, payload, { customLogger })
} catch (exception) {
const { message } = exception

if (/enrollment\s+does\s+not\s+exist/i.test(message)) {
assign(exception, {
message: enrollmentNotFoundMessage,
name: ZoomAPIError.FacemapNotFound
})
}

this._proceedEnrollmentNotFoundError(exception)
throw exception
}

Expand Down Expand Up @@ -488,6 +493,17 @@ export class ZoomAPI {
return response
}

_proceedEnrollmentNotFoundError(exception) {
const { message } = exception

if (/enrollment\s+does\s+not\s+exist/i.test(message)) {
assign(exception, {
message: enrollmentNotFoundMessage,
name: ZoomAPIError.FacemapNotFound
})
}
}

_createLoggingSafeCopy(payload) {
if (isArray(payload)) {
return payload.map(item => this._createLoggingSafeCopy(item))
Expand Down
60 changes: 55 additions & 5 deletions src/server/verification/processor/EnrollmentProcessor.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @flow
import { chunk, noop } from 'lodash'
import moment from 'moment'
import { toChecksumAddress } from 'web3-utils'

import Config from '../../server.config'
import { default as AdminWallet } from '../../blockchain/MultiWallet'
Expand All @@ -14,6 +15,10 @@ import EnrollmentSession from './EnrollmentSession'
import getZoomProvider from './provider/ZoomProvider'
import { DisposeAt, scheduleDisposalTask, DISPOSE_ENROLLMENTS_TASK, forEnrollment } from '../cron/taskUtil'

import { recoverPublickey } from '../../utils/eth'
import { FV_IDENTIFIER_MSG2 } from '../../login/login-middleware'
import { strcasecmp } from '../../utils/string'

// count of chunks pending tasks should (approximately) be split to
const DISPOSE_BATCH_AMOUNT = 10
// minimal & maximal chunk sizes
Expand Down Expand Up @@ -140,6 +145,46 @@ class EnrollmentProcessor {
}
}

async checkExistence(enrollmentIdentifier, v1EnrollmentIdentifier) {
const [exists, v1Exists] = await Promise.all([
this.isIdentifierExists(enrollmentIdentifier),
v1EnrollmentIdentifier && this.isIdentifierExists(v1EnrollmentIdentifier)
])

return { exists, v1Exists }
}

normalizeIdentifiers(enrollmentIdentifier, v1EnrollmentIdentifier = null) {
return {
identifier: enrollmentIdentifier.slice(0, 42),
v1Identifier: v1EnrollmentIdentifier ? v1EnrollmentIdentifier.replace('0x', '') : null
}
}

async verifyIdentifier(enrollmentIdentifier, gdAddress) {
// check v2, v2 identifier is expected to be the whole signature
if (enrollmentIdentifier.length < 42) {
return
}

const signer = recoverPublickey(
enrollmentIdentifier,
FV_IDENTIFIER_MSG2({ account: toChecksumAddress(gdAddress) }),
''
)

if (!strcasecmp(signer, gdAddress)) {
throw new Error(`identifier signer doesn't match user ${signer} != ${gdAddress}`)
}
}

async getEnrollment(enrollmentIdentifier: string, customLogger = null): Promise<any> {
johnsmith-gooddollar marked this conversation as resolved.
Show resolved Hide resolved
const { provider, logger } = this
const log = customLogger || logger

return provider.getEnrollment(enrollmentIdentifier, log)
}

async isIdentifierExists(enrollmentIdentifier: string) {
return this.provider.isEnrollmentExists(enrollmentIdentifier)
}
Expand Down Expand Up @@ -178,6 +223,13 @@ class EnrollmentProcessor {
await provider.dispose(enrollmentIdentifier, customLogger)
}

async estimateAge(enrollmentIdentifier, strict = false, customLogger = null) {
const { provider, logger } = this
const log = customLogger || logger

return provider.estimateAge(enrollmentIdentifier, strict, log)
}

async disposeEnqueuedEnrollments(
onProcessed: (identifier: string, exception?: Error) => void = noop,
customLogger = null
Expand All @@ -191,9 +243,7 @@ class EnrollmentProcessor {

if (keepEnrollments > 0) {
deletedAccountFilters.createdAt = {
$lte: moment()
.subtract(keepEnrollments, 'hours')
.toDate()
$lte: moment().subtract(keepEnrollments, 'hours').toDate()
}
}

Expand Down Expand Up @@ -292,8 +342,8 @@ const enrollmentProcessors = new WeakMap()

export default (storage, log) => {
if (!enrollmentProcessors.has(storage)) {
log = log || logger.child({ from: 'EnrollmentProcessor' })
const enrollmentProcessor = new EnrollmentProcessor(Config, storage, AdminWallet, log)
const processorLogger = log || logger.child({ from: 'EnrollmentProcessor' })
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually processor shouldn't encapsulate logger. it should be passed as optional param to each method.
this should be fixed in a separate PR.

const enrollmentProcessor = new EnrollmentProcessor(Config, storage, AdminWallet, processorLogger)

enrollmentProcessor.registerProvier(getZoomProvider())
enrollmentProcessors.set(storage, enrollmentProcessor)
Expand Down
Loading
Loading