diff --git a/components/FaceScanner.tsx b/components/FaceScanner.tsx index 644aaa0a..f7401096 100644 --- a/components/FaceScanner.tsx +++ b/components/FaceScanner.tsx @@ -16,6 +16,7 @@ import { createFaceScannerMachine, selectIsInvalid, selectIsCapturing, + selectIsVerifying, } from '../machines/faceScanner'; import { GlobalContext } from '../shared/GlobalContext'; import { selectIsActive } from '../machines/app'; @@ -37,6 +38,7 @@ export const FaceScanner: React.FC = (props) => { const isCheckingPermission = useSelector(service, selectIsCheckingPermission); const isScanning = useSelector(service, selectIsScanning); const isCapturing = useSelector(service, selectIsCapturing); + const isVerifying = useSelector(service, selectIsVerifying); const setCameraRef = useCallback( (node: Camera) => { @@ -79,7 +81,6 @@ export const FaceScanner: React.FC = (props) => { return ( - {t('takeSelfie')} = (props) => { /> - {isCapturing ? ( + {isCapturing || isVerifying ? ( ) : ( diff --git a/components/VcDetails.tsx b/components/VcDetails.tsx index d6b92bbe..183804f4 100644 --- a/components/VcDetails.tsx +++ b/components/VcDetails.tsx @@ -23,6 +23,10 @@ const VerifiedIcon: React.FC = () => { export const VcDetails: React.FC = (props) => { const { t, i18n } = useTranslation('VcDetails'); + if (props.vc?.verifiableCredential == null) { + return Loading details...; + } + return ( diff --git a/lib/mosip-inji-face-sdk/faceAuth.ios.ts b/lib/mosip-inji-face-sdk/faceAuth.ios.ts index accd4127..f15162e2 100644 --- a/lib/mosip-inji-face-sdk/faceAuth.ios.ts +++ b/lib/mosip-inji-face-sdk/faceAuth.ios.ts @@ -1,3 +1,4 @@ -export default function faceAuth(capturedImage: string, vcImage: string) { +export default async function faceAuth(capturedImage: string, vcImage: string) { // TODO: iOS implementation + return Promise.resolve(true); } diff --git a/locales/en.json b/locales/en.json index 45e065a2..4ec5e6a3 100644 --- a/locales/en.json +++ b/locales/en.json @@ -5,6 +5,7 @@ "deviceRefNumber": "Device reference number", "name": "Name" }, + "FaceScanner": {}, "OIDcAuth": { "title": "OIDC Authentication", "text": "To be replaced with the OIDC provider UI", @@ -153,6 +154,7 @@ "ReceiveVcScreen": { "header": "{{vcLabel}} details", "acceptRequest": "Accept request and receive {{vcLabel}}", + "acceptRequestAndVerify": "Accept request and verify", "reject": "Reject" }, "RequestScreen": { @@ -231,7 +233,11 @@ "rejected": { "title": "Notice", "message": "Your {{vcLabel}} was rejected by {{receiver}}" - }, + } + } + }, + "VerifyIdentityOverlay": { + "status": { "verifyingIdentity": "Verifying identity..." }, "errors": { @@ -250,6 +256,12 @@ "cancel": "Cancel", "save": "Save", "editLabel": "Edit {{label}}", - "tryAgain": "Try again" + "tryAgain": "Try again", + "camera": { + "errors": { + "missingPermission": "This app uses the camera to scan the QR code of another device." + }, + "allowAccess": "Allow access to camera" + } } } \ No newline at end of file diff --git a/machines/faceScanner.ts b/machines/faceScanner.ts index 76b1ab89..56f46c0c 100644 --- a/machines/faceScanner.ts +++ b/machines/faceScanner.ts @@ -214,6 +214,7 @@ export const createFaceScannerMachine = (vcImage: string) => }, verifyImage: (context) => { + context.cameraRef.pausePreview(); const rxDataURI = /data:(?[\w/\-.]+);(?\w+),(?.*)/; const matches = rxDataURI.exec(vcImage).groups; @@ -257,6 +258,10 @@ export function selectIsCapturing(state: State) { return state.matches('capturing'); } +export function selectIsVerifying(state: State) { + return state.matches('verifying'); +} + export function selectIsValid(state: State) { return state.matches('valid'); } diff --git a/machines/request.ts b/machines/request.ts index e1507f86..0c89f99d 100644 --- a/machines/request.ts +++ b/machines/request.ts @@ -28,6 +28,8 @@ import { offlineSend, SendVcResponseEvent, } from '../shared/smartshare'; +import { log } from 'xstate/lib/actions'; +// import { verifyPresentation } from '../shared/vcjs/verifyPresentation'; type SharingProtocol = 'OFFLINE' | 'ONLINE'; @@ -49,6 +51,7 @@ const model = createModel( { events: { ACCEPT: () => ({}), + ACCEPT_AND_VERIFY: () => ({}), REJECT: () => ({}), CANCEL: () => ({}), DISMISS: () => ({}), @@ -67,6 +70,9 @@ const model = createModel( VC_RESPONSE: (response: unknown) => ({ response }), SWITCH_PROTOCOL: (value: boolean) => ({ value }), GOTO_SETTINGS: () => ({}), + FACE_VALID: () => ({}), + FACE_INVALID: () => ({}), + RETRY_VERIFICATION: () => ({}), }, } ); @@ -79,6 +85,11 @@ export const requestMachine = model.createMachine( schema: { context: model.initialContext, events: {} as EventFrom, + services: {} as { + verifyVp: { + data: VC; + }; + }, }, id: 'request', initial: 'inactive', @@ -195,7 +206,7 @@ export const requestMachine = model.createMachine( DISCONNECT: 'disconnected', VC_RECEIVED: { target: 'reviewing', - actions: ['setIncomingVc'], + actions: 'setIncomingVc', }, }, initial: 'inProgress', @@ -219,12 +230,42 @@ export const requestMachine = model.createMachine( reviewing: { on: { ACCEPT: '.accepting', + ACCEPT_AND_VERIFY: '.verifyingIdentity', REJECT: '.rejected', CANCEL: '.rejected', }, initial: 'idle', states: { idle: {}, + verifyingIdentity: { + on: { + FACE_VALID: { + target: 'verifyingVp', + }, + FACE_INVALID: { + target: 'invalidIdentity', + }, + CANCEL: 'idle', + }, + }, + invalidIdentity: { + on: { + DISMISS: 'idle', + RETRY_VERIFICATION: 'verifyingIdentity', + }, + }, + verifyingVp: { + invoke: { + src: 'verifyVp', + onDone: { + target: 'accepting', + }, + onError: { + target: 'idle', + actions: log('Failed to verify Verifiable Presentation'), + }, + }, + }, accepting: { initial: 'requestingReceivedVcs', states: { @@ -358,8 +399,16 @@ export const requestMachine = model.createMachine( senderInfo: (_context, event) => event.senderInfo, }), - setIncomingVc: model.assign({ - incomingVc: (_context, event) => event.vc, + setIncomingVc: assign({ + incomingVc: (_context, event) => { + const vp = event.vc.verifiablePresentation; + return vp != null + ? { + ...event.vc, + verifiableCredential: vp.verifiableCredential[0], + } + : event.vc; + }, }), registerLoggers: assign({ @@ -594,6 +643,22 @@ export const requestMachine = model.createMachine( onlineSend(event); } }, + + verifyVp: (context) => async () => { + const vp = context.incomingVc.verifiablePresentation; + + // TODO + // const challenge = ? + // await verifyPresentation(vp, challenge); + + const vc: VC = { + ...context.incomingVc, + verifiablePresentation: null, + verifiableCredential: vp.verifiableCredential[0], + }; + + return Promise.resolve(vc); + }, }, guards: { @@ -638,6 +703,10 @@ export function selectSharingProtocol(state: State) { return state.context.sharingProtocol; } +export function selectIsIncomingVp(state: State) { + return state.context.incomingVc?.verifiablePresentation != null; +} + export function selectIsReviewing(state: State) { return state.matches('reviewing'); } @@ -650,6 +719,18 @@ export function selectIsRejected(state: State) { return state.matches('reviewing.rejected'); } +export function selectIsVerifyingIdentity(state: State) { + return state.matches('reviewing.verifyingIdentity'); +} + +export function selectIsVerifyingVp(state: State) { + return state.matches('reviewing.verifyingVp'); +} + +export function selectIsInvalidIdentity(state: State) { + return state.matches('reviewing.invalidIdentity'); +} + export function selectIsDisconnected(state: State) { return state.matches('disconnected'); } diff --git a/machines/request.typegen.ts b/machines/request.typegen.ts index c1187eb4..c23f72fa 100644 --- a/machines/request.typegen.ts +++ b/machines/request.typegen.ts @@ -4,6 +4,15 @@ export interface Typegen0 { '@@xstate/typegen': true; 'internalEvents': { '': { type: '' }; + 'done.invoke.request.reviewing.verifyingVp:invocation[0]': { + type: 'done.invoke.request.reviewing.verifyingVp:invocation[0]'; + data: unknown; + __tip: 'See the XState TS docs to learn how to strongly type this.'; + }; + 'error.platform.request.reviewing.verifyingVp:invocation[0]': { + type: 'error.platform.request.reviewing.verifyingVp:invocation[0]'; + data: unknown; + }; 'xstate.after(CLEAR_DELAY)#clearingConnection': { type: 'xstate.after(CLEAR_DELAY)#clearingConnection'; }; @@ -20,6 +29,7 @@ export interface Typegen0 { sendVcResponse: | 'done.invoke.accepted:invocation[0]' | 'done.invoke.request.reviewing.rejected:invocation[0]'; + verifyVp: 'done.invoke.request.reviewing.verifyingVp:invocation[0]'; }; 'missingImplementations': { actions: never; @@ -53,7 +63,9 @@ export interface Typegen0 { | 'xstate.after(CLEAR_DELAY)#clearingConnection' | 'xstate.init'; requestExistingVc: 'VC_RESPONSE'; - requestReceivedVcs: 'ACCEPT'; + requestReceivedVcs: + | 'ACCEPT' + | 'done.invoke.request.reviewing.verifyingVp:invocation[0]'; requestReceiverInfo: 'CONNECTED'; sendVcReceived: 'STORE_RESPONSE'; setIncomingVc: 'VC_RECEIVED'; @@ -74,6 +86,7 @@ export interface Typegen0 { receiveVc: 'EXCHANGE_DONE'; requestBluetooth: 'BLUETOOTH_DISABLED'; sendVcResponse: 'CANCEL' | 'REJECT' | 'STORE_RESPONSE'; + verifyVp: 'FACE_VALID'; }; 'eventsCausingGuards': { hasExistingVc: 'VC_RESPONSE'; @@ -104,8 +117,11 @@ export interface Typegen0 { | 'reviewing.accepting.requestingReceivedVcs' | 'reviewing.accepting.storingVc' | 'reviewing.idle' + | 'reviewing.invalidIdentity' | 'reviewing.navigatingToHome' | 'reviewing.rejected' + | 'reviewing.verifyingIdentity' + | 'reviewing.verifyingVp' | 'waitingForConnection' | 'waitingForVc' | 'waitingForVc.inProgress' @@ -117,8 +133,11 @@ export interface Typegen0 { | 'accepted' | 'accepting' | 'idle' + | 'invalidIdentity' | 'navigatingToHome' | 'rejected' + | 'verifyingIdentity' + | 'verifyingVp' | { accepting?: | 'mergingIncomingVc' diff --git a/machines/scan.ts b/machines/scan.ts index 09c859c7..c15389dd 100644 --- a/machines/scan.ts +++ b/machines/scan.ts @@ -28,6 +28,7 @@ import { import { check, PERMISSIONS, PermissionStatus } from 'react-native-permissions'; import { checkLocation, requestLocation } from '../shared/location'; import { CameraCapturedPicture } from 'expo-camera'; +import { log } from 'xstate/lib/actions'; const findingConnectionId = '#scan.findingConnection'; const checkingLocationServiceId = '#checkingLocationService'; @@ -42,6 +43,7 @@ const model = createModel( senderInfo: {} as DeviceInfo, receiverInfo: {} as DeviceInfo, selectedVc: {} as VC, + createdVp: null as VC, reason: '', loggers: [] as EmitterSubscription[], vcName: '', @@ -88,6 +90,11 @@ export const scanMachine = model.createMachine( schema: { context: model.initialContext, events: {} as EventFrom, + services: {} as { + createVp: { + data: VC; + }; + }, }, id: 'scan', initial: 'inactive', @@ -261,7 +268,7 @@ export const scanMachine = model.createMachine( actions: ['setSelectedVc'], }, VERIFY_AND_SELECT_VC: { - target: 'verifyingUserIdentity', + target: 'verifyingIdentity', actions: ['setSelectedVc'], }, CANCEL: 'idle', @@ -310,25 +317,38 @@ export const scanMachine = model.createMachine( }, rejected: {}, navigatingToHome: {}, - verifyingUserIdentity: { + verifyingIdentity: { on: { FACE_VALID: { - target: 'sendingVc', + target: 'creatingVp', }, FACE_INVALID: { - target: 'invalidUserIdentity', + target: 'invalidIdentity', }, CANCEL: 'selectingVc', }, }, - invalidUserIdentity: { + creatingVp: { + invoke: { + src: 'createVp', + onDone: { + actions: 'setCreatedVp', + target: 'sendingVc', + }, + onError: { + actions: log('Could not create Verifiable Presentation'), + target: 'selectingVc', + }, + }, + }, + invalidIdentity: { on: { DISMISS: 'selectingVc', - RETRY_VERIFICATION: 'verifyingUserIdentity', + RETRY_VERIFICATION: 'verifyingIdentity', }, }, }, - exit: ['disconnect', 'clearReason'], + exit: ['disconnect', 'clearReason', 'clearCreatedVp'], }, disconnected: { id: 'disconnected', @@ -348,7 +368,7 @@ export const scanMachine = model.createMachine( requestSenderInfo: sendParent('REQUEST_DEVICE_INFO'), setSenderInfo: model.assign({ - senderInfo: (_, event) => event.info, + senderInfo: (_context, event) => event.info, }), requestToEnableLocation: () => requestLocation(), @@ -380,16 +400,16 @@ export const scanMachine = model.createMachine( }), setReceiverInfo: model.assign({ - receiverInfo: (_, event) => event.receiverInfo, + receiverInfo: (_context, event) => event.receiverInfo, }), setReason: model.assign({ - reason: (_, event) => event.reason, + reason: (_context, event) => event.reason, }), clearReason: assign({ reason: '' }), - setSelectedVc: model.assign({ + setSelectedVc: assign({ selectedVc: (context, event) => { const reason = []; if (context.reason.trim() !== '') { @@ -399,6 +419,14 @@ export const scanMachine = model.createMachine( }, }), + setCreatedVp: assign({ + createdVp: (_context, event) => event.data, + }), + + clearCreatedVp: assign({ + createdVp: () => null, + }), + registerLoggers: assign({ loggers: (context) => { if (context.sharingProtocol === 'OFFLINE' && __DEV__) { @@ -558,7 +586,12 @@ export const scanMachine = model.createMachine( sendVc: (context) => (callback) => { let subscription: EmitterSubscription; - const vc = { ...context.selectedVc, tag: '' }; + const vp = context.createdVp; + const vc = { + ...(vp != null ? vp : context.selectedVc), + tag: '', + }; + const statusCallback = (status: SendVcStatus) => { if (typeof status === 'number') return; callback({ @@ -588,6 +621,26 @@ export const scanMachine = model.createMachine( }); } }, + + createVp: (context) => async () => { + // TODO + // const verifiablePresentation = await createVerifiablePresentation(...); + + const verifiablePresentation: VerifiablePresentation = { + '@context': [''], + 'proof': null, + 'type': 'VerifiablePresentation', + 'verifiableCredential': [context.selectedVc.verifiableCredential], + }; + + const vc: VC = { + ...context.selectedVc, + verifiableCredential: null, + verifiablePresentation, + }; + + return Promise.resolve(vc); + }, }, guards: { @@ -708,12 +761,12 @@ export function selectIsDone(state: State) { return state.matches('reviewing.navigatingToHome'); } -export function selectIsVerifyingUserIdentity(state: State) { - return state.matches('reviewing.verifyingUserIdentity'); +export function selectIsVerifyingIdentity(state: State) { + return state.matches('reviewing.verifyingIdentity'); } -export function selectIsInvalidUserIdentity(state: State) { - return state.matches('reviewing.invalidUserIdentity'); +export function selectIsInvalidIdentity(state: State) { + return state.matches('reviewing.invalidIdentity'); } export function selectIsCancelling(state: State) { diff --git a/machines/scan.typegen.ts b/machines/scan.typegen.ts index f25c5924..204a1987 100644 --- a/machines/scan.typegen.ts +++ b/machines/scan.typegen.ts @@ -3,6 +3,15 @@ export interface Typegen0 { '@@xstate/typegen': true; 'internalEvents': { + 'done.invoke.scan.reviewing.creatingVp:invocation[0]': { + type: 'done.invoke.scan.reviewing.creatingVp:invocation[0]'; + data: unknown; + __tip: 'See the XState TS docs to learn how to strongly type this.'; + }; + 'error.platform.scan.reviewing.creatingVp:invocation[0]': { + type: 'error.platform.scan.reviewing.creatingVp:invocation[0]'; + data: unknown; + }; 'xstate.after(3000)#scan.reviewing.cancelling': { type: 'xstate.after(3000)#scan.reviewing.cancelling'; }; @@ -15,6 +24,7 @@ export interface Typegen0 { 'invokeSrcNameMap': { checkLocationPermission: 'done.invoke.scan.checkingLocationService.checkingPermission:invocation[0]'; checkLocationStatus: 'done.invoke.checkingLocationService:invocation[0]'; + createVp: 'done.invoke.scan.reviewing.creatingVp:invocation[0]'; discoverDevice: 'done.invoke.scan.connecting:invocation[0]'; exchangeDeviceInfo: 'done.invoke.scan.exchangingDeviceInfo:invocation[0]'; monitorConnection: 'done.invoke.scan:invocation[0]'; @@ -28,6 +38,14 @@ export interface Typegen0 { delays: never; }; 'eventsCausingActions': { + clearCreatedVp: + | 'CANCEL' + | 'DISCONNECT' + | 'DISMISS' + | 'SCREEN_BLUR' + | 'SCREEN_FOCUS' + | 'xstate.after(3000)#scan.reviewing.cancelling' + | 'xstate.stop'; clearReason: | 'CANCEL' | 'DISCONNECT' @@ -71,6 +89,7 @@ export interface Typegen0 { requestSenderInfo: 'SCAN'; requestToEnableLocation: 'LOCATION_DISABLED' | 'LOCATION_REQUEST'; setConnectionParams: 'SCAN'; + setCreatedVp: 'done.invoke.scan.reviewing.creatingVp:invocation[0]'; setReason: 'UPDATE_REASON'; setReceiverInfo: 'EXCHANGE_DONE'; setScannedQrParams: 'SCAN'; @@ -80,11 +99,12 @@ export interface Typegen0 { 'eventsCausingServices': { checkLocationPermission: 'APP_ACTIVE' | 'LOCATION_ENABLED'; checkLocationStatus: 'CANCEL' | 'SCREEN_FOCUS'; + createVp: 'FACE_VALID'; discoverDevice: 'RECEIVE_DEVICE_INFO'; exchangeDeviceInfo: 'CONNECTED'; monitorConnection: 'SCREEN_BLUR' | 'SCREEN_FOCUS' | 'xstate.init'; sendDisconnect: 'CANCEL'; - sendVc: 'FACE_VALID' | 'SELECT_VC'; + sendVc: 'SELECT_VC' | 'done.invoke.scan.reviewing.creatingVp:invocation[0]'; }; 'eventsCausingGuards': { isQrOffline: 'SCAN'; @@ -94,9 +114,9 @@ export interface Typegen0 { CLEAR_DELAY: 'LOCATION_ENABLED'; CONNECTION_TIMEOUT: | 'CONNECTED' - | 'FACE_VALID' | 'RECEIVE_DEVICE_INFO' - | 'SELECT_VC'; + | 'SELECT_VC' + | 'done.invoke.scan.reviewing.creatingVp:invocation[0]'; }; 'matchesStates': | 'checkingLocationService' @@ -120,15 +140,16 @@ export interface Typegen0 { | 'reviewing' | 'reviewing.accepted' | 'reviewing.cancelling' + | 'reviewing.creatingVp' | 'reviewing.idle' - | 'reviewing.invalidUserIdentity' + | 'reviewing.invalidIdentity' | 'reviewing.navigatingToHome' | 'reviewing.rejected' | 'reviewing.selectingVc' | 'reviewing.sendingVc' | 'reviewing.sendingVc.inProgress' | 'reviewing.sendingVc.timeout' - | 'reviewing.verifyingUserIdentity' + | 'reviewing.verifyingIdentity' | { checkingLocationService?: | 'checkingPermission' @@ -141,13 +162,14 @@ export interface Typegen0 { reviewing?: | 'accepted' | 'cancelling' + | 'creatingVp' | 'idle' - | 'invalidUserIdentity' + | 'invalidIdentity' | 'navigatingToHome' | 'rejected' | 'selectingVc' | 'sendingVc' - | 'verifyingUserIdentity' + | 'verifyingIdentity' | { sendingVc?: 'inProgress' | 'timeout' }; }; 'tags': never; diff --git a/screens/Request/ReceiveVcScreen.strings.json b/screens/Request/ReceiveVcScreen.strings.json index 22b437d5..c7b85376 100644 --- a/screens/Request/ReceiveVcScreen.strings.json +++ b/screens/Request/ReceiveVcScreen.strings.json @@ -1,5 +1,6 @@ { "header": "{{vcLabel}} details", "acceptRequest": "Accept request and receive {{vcLabel}}", + "acceptRequestAndVerify": "Accept request and verify", "reject": "Reject" } \ No newline at end of file diff --git a/screens/Request/ReceiveVcScreen.tsx b/screens/Request/ReceiveVcScreen.tsx index da4032d7..489b68e3 100644 --- a/screens/Request/ReceiveVcScreen.tsx +++ b/screens/Request/ReceiveVcScreen.tsx @@ -1,37 +1,82 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; + import { DeviceInfoList } from '../../components/DeviceInfoList'; -import { Button, Column, Text } from '../../components/ui'; +import { Button, Column, Row, Text } from '../../components/ui'; import { Colors } from '../../components/ui/styleUtils'; import { VcDetails } from '../../components/VcDetails'; import { useReceiveVcScreen } from './ReceiveVcScreenController'; -import { useTranslation } from 'react-i18next'; +import { VerifyIdentityOverlay } from '../VerifyIdentityOverlay'; +import { MessageOverlay } from '../../components/MessageOverlay'; export const ReceiveVcScreen: React.FC = () => { const { t } = useTranslation('ReceiveVcScreen'); const controller = useReceiveVcScreen(); return ( - - - - - {t('header', { vcLabel: controller.vcLabel.singular })} - - + + + + + + {t('header', { vcLabel: controller.vcLabel.singular })} + + + + + {controller.isIncomingVp ? ( +