Skip to content

Commit

Permalink
implements url based chapi issuance
Browse files Browse the repository at this point in the history
  • Loading branch information
kezike committed Nov 3, 2023
1 parent f2b3791 commit f227d93
Show file tree
Hide file tree
Showing 19 changed files with 14,088 additions and 8,708 deletions.
23 changes: 11 additions & 12 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@
android:scheme="dccrequest"
android:host="present"/>
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="lcw.app"
android:pathPrefix="/mobile" />
</intent-filter>
<!--TODO: Add this filter, if you want to support sharing text into your app-->
<intent-filter>
<action android:name="android.intent.action.SEND" />
Expand All @@ -45,7 +54,8 @@
</intent-filter>
</activity>
<activity android:name="net.openid.appauth.RedirectUriReceiverActivity"
tools:node="replace" android:exported="true">
tools:node="replace"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
Expand All @@ -54,17 +64,6 @@
android:scheme="dccrequest"
android:host="oauth" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data
android:scheme="http"
android:host="lcw.app"
android:pathPrefix="/mobile" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
Expand Down
9 changes: 4 additions & 5 deletions app/hooks/useLCWReceiveModule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import { clearGlobalModal, displayGlobalModal } from '../lib/globalModal';
import { NavigationUtil } from '../lib/navigationUtil';
import { navigationRef } from '../navigation';
import { stageCredentials } from '../store/slices/credentialFoyer';
import { useAppDispatch } from './useAppDispatch';
import { useDynamicStyles } from './useDynamicStyles';
import { useAppDispatch, useDynamicStyles } from '.';

type LCWReceiveModule = NativeModule & {
getConstants: () => LCWReceiveModuleConstants;
Expand Down Expand Up @@ -69,7 +68,7 @@ export function useLCWReceiveModule(): void {
navigationRef.navigate('AcceptCredentialsNavigation', {
screen: 'ApproveCredentialsScreen',
params: {
rawProfileRecord,
rawProfileRecord
}
});
}
Expand All @@ -85,10 +84,10 @@ export function useLCWReceiveModule(): void {
const rawProfileRecord = await NavigationUtil.selectProfile();
await performDidAuthRequest(didAuthRequest, rawProfileRecord);

navigationRef.navigate('AcceptCredentialsNavigation',{
navigationRef.navigate('AcceptCredentialsNavigation', {
screen: 'ApproveCredentialsScreen',
params: {
rawProfileRecord,
rawProfileRecord
}
});
}
Expand Down
33 changes: 33 additions & 0 deletions app/lib/credentialRequest.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { authorize } from 'react-native-app-auth';

import { ChapiCredentialRequest, ChapiCredentialRequestParams } from '../types/chapi';
import { Credential } from '../types/credential';
import { DidRecordRaw } from '../model';

Expand All @@ -20,6 +21,38 @@ export function isCredentialRequestParams(params?: Record<string, unknown>): par
return issuer !== undefined && vc_request_url !== undefined;
}

export function getChapiCredentialRequest(params: Record<string, unknown>): ChapiCredentialRequest {
const { request: requestString } = (params as ChapiCredentialRequestParams);
if (!requestString) {
throw new Error('[getChapiCredentialRequest] The credential request was malformed.');
}
return JSON.parse(requestString);
}

export function isChapiCredentialRequestParams(params: Record<string, unknown>): params is ChapiCredentialRequestParams {
const request = getChapiCredentialRequest(params);
return isChapiCredentialRequest(request);
}

export function isChapiCredentialRequest(request: any): request is ChapiCredentialRequest {
const { credentialRequestOrigin, protocols } = (request || {} as ChapiCredentialRequest);

const hasChapiCredentialRequestFields = credentialRequestOrigin !== undefined && protocols !== undefined;
if (!hasChapiCredentialRequestFields) {
return false;
}

const hasChapiCredentialRequestProtocolFields = [
'OID4VCI',
'OID4VP',
'vcapi'
].some((field: string) => {
return !!protocols[field];
});

return hasChapiCredentialRequestProtocolFields;
}

export async function requestCredential(credentialRequestParams: CredentialRequestParams, didRecord: DidRecordRaw): Promise<Credential[]> {
const {
auth_type = 'code',
Expand Down
15 changes: 13 additions & 2 deletions app/lib/decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { fromQrCode, toQrCode } from '@digitalcredentials/vpqr';
import qs from 'query-string';

import { securityLoader } from '@digitalcredentials/security-document-loader';
import { ChapiCredentialResponse, ChapiDidAuthRequest } from '../types/chapi';
import { ChapiCredentialRequest, ChapiCredentialResponse, ChapiDidAuthRequest } from '../types/chapi';
import type { Credential, EducationalOperationalCredential, Subject } from '../types/credential';
import { VerifiablePresentation } from '../types/presentation';
import { CredentialRequestParams } from './credentialRequest';
import { CredentialRequestParams, getChapiCredentialRequest, isChapiCredentialRequestParams } from './credentialRequest';
import { isCredentialRequestParams } from './credentialRequest';
import { HumanReadableError } from './error';
import { isChapiCredentialResponse, isChapiDidAuthRequest, isVerifiableCredential, isVerifiablePresentation } from './verifiableObject';
Expand All @@ -29,6 +29,17 @@ export function queryParamsFrom(url: string): Record<string, unknown> {
return query;
}

export function credentialRequestFromChapiUrl(url: string): ChapiCredentialRequest {
const params = qs.parse(url.split('?')[1]);
const isValid = isChapiCredentialRequestParams(params);

if (!isValid) {
throw new HumanReadableError('[credentialRequestFromChapiUrl] The credential request was malformed.');
}

return getChapiCredentialRequest(params);
}

export function credentialRequestParamsFromQrText(text: string): CredentialRequestParams {
const params = qs.parse(text.split('?')[1]);
const isValid = isCredentialRequestParams(params);
Expand Down
14 changes: 13 additions & 1 deletion app/lib/deepLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Linking } from 'react-native';
import qs from 'query-string';

import { navigationRef, RootNavigationParamsList } from '../navigation';
import { credentialRequestFromChapiUrl } from './decode';
import { encodeQueryParams } from './encode';
import { onShareIntent } from './shareIntent';

Expand Down Expand Up @@ -59,11 +60,22 @@ function transformDeepLink(url: string): string {
return encodeQueryParams(url);
}

const redirectRequestRoute = (url: string) => {
const request = credentialRequestFromChapiUrl(url);
navigationRef.navigate('ExchangeCredentialsNavigation', {
screen: 'ExchangeCredentials',
params: { request }
});
};

function deepLinkConfigFor({ schemes, paths, onDeepLink }: DeepLinkConfigOptions): LinkingOptions<RootNavigationParamsList> {
return {
prefixes: schemes,
subscribe: (listener: (url: string) => void) => {
const onReceiveURL = ({ url }: { url: string }) => {
if (url.includes('request=')) {
redirectRequestRoute(url);
}
onDeepLink?.(url);
return listener(transformDeepLink(url));
};
Expand All @@ -76,7 +88,7 @@ function deepLinkConfigFor({ schemes, paths, onDeepLink }: DeepLinkConfigOptions
if (url !== null) {
await new Promise((res) => setTimeout(res, 100));
onDeepLink?.(url);
return transformDeepLink(url);
return transformDeepLink(url);
}
},
getStateFromPath: (path) => {
Expand Down
4 changes: 2 additions & 2 deletions app/lib/didAuthRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ProfileRecordRaw } from '../model';
import { makeSelectDidFromProfile, selectWithFactory } from '../store/selectorFactories';
import { Ed25519Signature2020 } from '@digitalcredentials/ed25519-signature-2020';
import { Ed25519VerificationKey2020 } from '@digitalcredentials/ed25519-verification-key-2020';
import { constructExchangeRequest, handleVcApiExchange } from './exchanges';
import { constructExchangeRequest, handleVcApiExchangeSimple } from './exchanges';
import store from '../store';
import { stageCredentials } from '../store/slices/credentialFoyer';
import { Credential } from '../types/credential';
Expand Down Expand Up @@ -40,7 +40,7 @@ export async function performDidAuthRequest(params: DidAuthRequestParams, rawPro
suite
});

const { verifiablePresentation } = await handleVcApiExchange({ url, request });
const { verifiablePresentation } = await handleVcApiExchangeSimple({ url, request });
const credentials = extractCredentialsFrom(verifiablePresentation);

if (credentials) {
Expand Down
133 changes: 129 additions & 4 deletions app/lib/exchanges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,76 @@ import uuid from 'react-native-uuid';
import vc from '@digitalcredentials/vc';
import { Ed25519Signature2020 } from '@digitalcredentials/ed25519-signature-2020';
import { securityLoader } from '@digitalcredentials/security-document-loader';
import { JSONPath } from 'jsonpath-plus';
import validator from 'validator';
import { VerifiablePresentation } from '../types/presentation';

const MAX_INTERACTIONS = 10;

// Different types of queries in verifiable presentation request
enum QueryType {
Example = 'QueryByExample',
Frame = 'QueryByFrame',
DidAuth = 'DIDAuthentication',
DidAuthLegacy = 'DIDAuth'
}

// Interact with VC-API exchange
const interactExchange = async (url: string, request={}): Promise<any> => {
const exchangeResponseRaw = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(request, undefined, 2)
});
return exchangeResponseRaw.json();
};

// Extend JSON path of credential by a literal value
const extendPath = (path: string, extension: string): string => {
const jsonPathResCharsRegex = /[$@*()\[\].:?]/g;
if (jsonPathResCharsRegex.test(extension)) {
// In order to escape reserved characters in a JSONPath in jsonpath-plus,
// you must prefix each occurrence thereof with a tick symbol (`)
extension = extension.replace(jsonPathResCharsRegex, (match: string) => '`' + match);
}
return `${path}.${extension}`;
};

// Check if credential matches QueryByExample VPR
const credentialMatchesVprExampleQuery = async (vprExample: any, credential: any, credentialPath='$'): Promise<boolean> => {
const credentialMatches = [];
for (let [vprExampleKey, vprExampleValue] of Object.entries(vprExample)) {
const newCredentialPath = extendPath(credentialPath, vprExampleKey);
// The result is always dumped into a single-element array
const [credentialScope] = JSONPath({ path: newCredentialPath, json: credential });
if (Array.isArray(vprExampleValue)) {
// Array query values require that matching credentials contain at least every values specified
// Note: This logic assumes that each array element is a literal value
if (!Array.isArray(credentialScope)) {
return false;
}
if (credentialScope.length < vprExampleValue.length) {
return false;
}
const credentialArrayMatches = vprExampleValue.every((vprExVal) => {
return !!credentialScope.includes(vprExVal);
});
credentialMatches.push(credentialArrayMatches);
} else if (typeof vprExampleValue === 'object' && vprExampleValue !== null) {
// Object query values will trigger a recursive call in order to handle nested queries
const credentialObjectMatches = await credentialMatchesVprExampleQuery(vprExampleValue, credential, newCredentialPath);
credentialMatches.push(credentialObjectMatches);
} else {
// Literal query values can be compared directly
const credentialLiteralMatches = credentialScope === vprExampleValue;
credentialMatches.push(credentialLiteralMatches);
}
}
return credentialMatches.every(matches => matches);
};

// Type definition for constructExchangeRequest function parameters
type ConstructExchangeRequestParameters = {
credentials?: unknown[];
Expand Down Expand Up @@ -47,14 +115,19 @@ export const constructExchangeRequest = async ({
return { verifiablePresentation: finalPresentation };
};

// Type definition for handleVcApiExchange function parameters
type HandleVcApiExchangeParameters = {
// Determine if any additional VC-API exchange interactions are required
const requiresAction = (exchangeResponse: any): boolean => {
return !!exchangeResponse.verifiablePresentationRequest;
};

// Type definition for handleVcApiExchangeSimple function parameters
type HandleVcApiExchangeSimpleParameters = {
url: string;
request: ExchangeRequest;
};

// Handle simplified vc api credential exchange workflow
export const handleVcApiExchange = async ({ url, request }: HandleVcApiExchangeParameters): Promise<ExchangeResponse> => {
// Handle simplified VC-API credential exchange workflow
export const handleVcApiExchangeSimple = async ({ url, request }: HandleVcApiExchangeSimpleParameters): Promise<ExchangeResponse> => {
const exchangeResponseRaw = await fetch(url, {
method: 'POST',
headers: {
Expand All @@ -65,3 +138,55 @@ export const handleVcApiExchange = async ({ url, request }: HandleVcApiExchangeP

return exchangeResponseRaw.json();
};

// Type definition for handleVcApiExchangeComplete function parameters
type HandleVcApiExchangeCompleteParameters = {
url: string;
request?: any;
holder: string;
suite: Ed25519Signature2020;
interactions?: number;
interactive?: boolean;
};

// Handle complete VC-API credential exchange workflow
export const handleVcApiExchangeComplete = async ({
url,
request={},
holder,
suite,
interactions=0,
interactive=false
}: HandleVcApiExchangeCompleteParameters): Promise<ExchangeResponse> => {
if (interactions === MAX_INTERACTIONS) {
throw new Error(`Request timed out after ${interactions} interactions`);
}
if (!validator.isURL(url + '')) {
throw new Error(`Received invalid interaction URL from issuer: ${url}`);
}

const exchangeResponse = await interactExchange(url, request);
if (!requiresAction(exchangeResponse)) {
return exchangeResponse;
}

let signed = false;
let credentials: any[] = [];
const { query, challenge, domain, interact } = exchangeResponse.verifiablePresentationRequest;
let queries = query;
if (!Array.isArray(queries)) {
queries = [query];
}
for (let query of queries) {
switch (query.type) {
case QueryType.DidAuthLegacy:
case QueryType.DidAuth:
signed = true;
break;
}
}

const exchangeRequest = await constructExchangeRequest({ credentials, challenge, domain, holder, suite, signed });
const exchangeUrl = interact?.service[0]?.serviceEndpoint ?? url;
return handleVcApiExchangeComplete({ url: exchangeUrl, request: exchangeRequest, holder, suite, interactions: interactions + 1, interactive });
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { StackScreenProps } from '@react-navigation/stack';
import { ChapiCredentialRequest } from '../../lib/chapi';

export type ExchangeCredentialsNavigationParamList = {
ExchangeCredentials: { request: ChapiCredentialRequest; };
};

export type ExchangeCredentialsProps = StackScreenProps<ExchangeCredentialsNavigationParamList, 'ExchangeCredentials'>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import { ExchangeCredentials } from '../../screens';
import { ExchangeCredentialsNavigationParamList } from '..';

const Stack = createStackNavigator<ExchangeCredentialsNavigationParamList>();

function ExchangeCredentialsNavigation() {
return (
<Stack.Navigator>
<Stack.Screen name="ExchangeCredentials" component={ExchangeCredentials} options={{ headerShown: false }} />
</Stack.Navigator>
);
}

export default ExchangeCredentialsNavigation;
Loading

0 comments on commit f227d93

Please sign in to comment.