Files
inji-wallet/components/VC/common/VCUtils.tsx
abhip2565 0713bbb5c4 [INJIMOB-3532] add sd jwt vp support (#2082)
* [INJIMOB-3513] add sd jwt vp support

Signed-off-by: Abhishek Paul <paul.apaul.abhishek.ap@gmail.com>

* [INJIMOB-3513] add bridge logic and sd-jwt signing for ovp

Signed-off-by: Abhishek Paul <paul.apaul.abhishek.ap@gmail.com>

* [INJIMOB-3532] add: support of OVP share in iOS

Signed-off-by: KiruthikaJeyashankar <kiruthikavjshankar@gmail.com>

* [INJIMOB-3532] add sd-jwt ovp ui

Signed-off-by: Abhishek Paul <paul.apaul.abhishek.ap@gmail.com>

* [INJIMOB-3532] refactor: optimize wallet_metadata creation logic

Signed-off-by: KiruthikaJeyashankar <kiruthikavjshankar@gmail.com>

* [INJIMOB-3532] refactor: fixed alignement issues and crash bug

Signed-off-by: Abhishek Paul <paul.apaul.abhishek.ap@gmail.com>

---------

Signed-off-by: Abhishek Paul <paul.apaul.abhishek.ap@gmail.com>
Signed-off-by: KiruthikaJeyashankar <kiruthikavjshankar@gmail.com>
Co-authored-by: KiruthikaJeyashankar <kiruthikavjshankar@gmail.com>
2025-09-18 18:50:53 +05:30

718 lines
20 KiB
TypeScript

import {
Credential,
CredentialSubject,
CredentialTypes,
IssuerWellknownResponse,
VerifiableCredential,
} from '../../../machines/VerifiableCredential/VCMetaMachine/vc';
import i18n, { getLocalizedField } from '../../../i18n';
import { Row } from '../../ui';
import { Text } from 'react-native';
import { VCItemField } from './VCItemField';
import React from 'react';
import { Theme } from '../../ui/styleUtils';
import { CREDENTIAL_REGISTRY_EDIT } from 'react-native-dotenv';
import { VCVerification } from '../../VCVerification';
import { MIMOTO_BASE_URL } from '../../../shared/constants';
import { VCItemDetailsProps } from '../Views/VCDetailView';
import {
getDisplayObjectForCurrentLanguage,
getMatchingCredentialIssuerMetadata,
} from '../../../shared/openId4VCI/Utils';
import { VCFormat } from '../../../shared/VCFormat';
import { displayType } from '../../../machines/Issuers/IssuersMachine';
import { Image } from 'react-native-elements/dist/image/Image';
import Icon from 'react-native-vector-icons/FontAwesome';
export const CARD_VIEW_DEFAULT_FIELDS = ['fullName'];
export const DETAIL_VIEW_DEFAULT_FIELDS = [
'fullName',
'gender',
'phone',
'dateOfBirth',
'email',
'address',
];
//todo UIN & VID to be removed once we get the fields in the wellknown endpoint
export const CARD_VIEW_ADD_ON_FIELDS = ['UIN', 'VID'];
export const DETAIL_VIEW_ADD_ON_FIELDS = [
'status',
'idType',
'credentialRegistry',
];
export const DETAIL_VIEW_BOTTOM_SECTION_FIELDS = [
'email',
'address',
'credentialRegistry',
];
export const BOTTOM_SECTION_FIELDS_WITH_DETAILED_ADDRESS_FIELDS = [
...getAddressFields(),
'email',
'credentialRegistry',
];
function iterateMsoMdocFor(
credential,
namespace: string,
element: 'elementIdentifier' | 'elementValue',
fieldName: string,
) {
const foundItem = credential['issuerSigned']['nameSpaces'][namespace].find(
element => {
return element.elementIdentifier === fieldName;
},
);
return foundItem[element];
}
export const getFieldValue = (
verifiableCredential: Credential,
field: string,
wellknown: any,
props: any,
display: Display,
format: string,
) => {
switch (field) {
case 'status':
return (
<VCVerification
display={display}
vcMetadata={props.verifiableCredentialData.vcMetadata}
/>
);
case 'idType':
return getCredentialType(wellknown);
case 'credentialRegistry':
return props?.vc?.credentialRegistry;
case 'address':
return getLocalizedField(
getFullAddress(verifiableCredential?.credentialSubject),
);
default: {
if (format === VCFormat.ldp_vc) {
const fieldValue = verifiableCredential?.credentialSubject[field];
if (Array.isArray(fieldValue) && typeof fieldValue[0] !== 'object') {
return fieldValue.join(', ');
}
return getLocalizedField(fieldValue);
} else if (format === VCFormat.mso_mdoc) {
const splitField = field.split('~');
if (splitField.length > 1) {
const [namespace, fieldName] = splitField;
return iterateMsoMdocFor(
verifiableCredential,
namespace,
'elementValue',
fieldName,
);
}
}
else if (format === VCFormat.vc_sd_jwt || format === VCFormat.dc_sd_jwt) {
const fieldParts = field.split('.');
let value: any = verifiableCredential?.fullResolvedPayload;
for (let i = 0; i < fieldParts.length; i++) {
const part = fieldParts[i];
if (!value) break;
value = value[part];
// If we hit an array and we still have more path to go...
if (Array.isArray(value) && i < fieldParts.length - 1) {
const remainingPath = fieldParts.slice(i + 1);
value = value.map(item => {
let inner = item;
for (const p of remainingPath) {
inner = inner?.[p];
}
return inner;
});
break;
}
}
if (Array.isArray(value) && typeof value[0] !== 'object') {
return value.join(', ');
}
if (Array.isArray(value) && typeof value[0] === 'object') {
if ('language' in value[0] &&
'value' in value[0]) {
return getLocalizedField(value)
}
else
return null;
}
if (typeof value === 'object' && !Array.isArray(value)) {
return null;
}
return getLocalizedField(value?.toString());
}
}
}
};
export const getFieldName = (
field: string,
wellknown: any,
format: string,
): string => {
if (wellknown) {
if (format === VCFormat.ldp_vc) {
const credentialDefinition = wellknown.credential_definition;
if (!credentialDefinition) {
console.error(
'Credential definition is not available for the selected credential type',
);
}
let fieldObj = credentialDefinition?.credentialSubject?.[field];
if (fieldObj) {
if (fieldObj.display && fieldObj.display.length > 0) {
const newFieldObj = fieldObj.display.map(obj => ({
language: obj.locale,
value: obj.name,
}));
return getLocalizedField(newFieldObj);
}
return field;
}
} else if (format === VCFormat.mso_mdoc) {
const splitField = field.split('~');
if (splitField.length > 1) {
const [namespace, fieldName] = splitField;
const fieldObj = wellknown.claims?.[namespace]?.[fieldName];
if (fieldObj) {
if (fieldObj.display && fieldObj.display.length > 0) {
const newFieldObj = fieldObj.display.map(obj => ({
language: obj.locale,
value: obj.name,
}));
return getLocalizedField(newFieldObj);
}
return fieldName;
}
}
}
else if (format === VCFormat.vc_sd_jwt || format === VCFormat.dc_sd_jwt) {
const pathParts = field.split('.');
let currentObj = wellknown.claims;
for (const part of pathParts) {
if (!currentObj || typeof currentObj !== 'object') break;
currentObj = currentObj[part];
}
if (
currentObj &&
typeof currentObj === 'object' &&
currentObj.display &&
Array.isArray(currentObj.display)
) {
const newFieldObj = currentObj.display.map((obj: any) => ({
language: obj.locale,
value: obj.name,
}));
return getLocalizedField(newFieldObj);
}
return formatKeyLabel(pathParts[pathParts.length - 1]);
}
}
return formatKeyLabel(field);
};
const ID = ['id'];
const IMAGE_KEYS = ['face', 'photo', 'picture', 'portrait', 'image'];
const EXCLUDED_FIELDS_FOR_RENDERING = [...ID, ...IMAGE_KEYS, 'cnf'];
const shouldExcludeField = (field: string): boolean => {
const normalized = field.includes('~')
? field.split('~')[1]
: field.includes('.') || field.includes('[')
? field
.split('.')
.pop()
?.replace(/\[\d+\]/g, '') ?? field
: field;
return EXCLUDED_FIELDS_FOR_RENDERING.includes(normalized);
};
export function getFaceField(obj: any): string | null {
if (typeof obj !== 'object' || obj === null) return null;
for (const [key, value] of Object.entries(obj)) {
const normalizedKey = key.toLowerCase();
if (IMAGE_KEYS.includes(normalizedKey) && typeof value === 'string') {
return value;
}
if (typeof value === 'object') {
const found = getFaceField(value);
if (found) return found;
}
}
return null;
}
export function getAddressFields() {
return [
'addressLine1',
'addressLine2',
'addressLine3',
'city',
'province',
'region',
'postalCode',
];
}
function getFullAddress(credential: CredentialSubject) {
if (!credential) {
return '';
}
const fields = getAddressFields();
return fields
.map(field => getLocalizedField(credential[field]))
.filter(Boolean)
.join(', ');
}
export const formatKeyLabel = (key: string): string => {
return key
.replace(/\[\d+\]/g, '') // Remove [0], [1], etc.
.replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase → spaced
.split(/[_\s]+/) // snake_case → spaced
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
const renderFieldRecursively = (
key: string,
value: any,
fieldNameColor: string,
fieldValueColor: string,
parentKey = '',
depth = 0,
renderedFields: Set<string>,
disclosedKeys: string[],
): JSX.Element[] => {
const fullKey = parentKey ? `${parentKey}.${key}` : key;
let shortKey =
fullKey
.split('.')
.pop()
?.replace(/\[\d+\]/g, '') ?? key;
if (renderedFields.has(fullKey)) return [];
if (shouldExcludeField(shortKey)) return [];
if (value === null || value === undefined) return [];
// Handle arrays
if (Array.isArray(value)) {
const label = formatKeyLabel(key);
const arrayFullKey = fullKey;
const isArrayDisclosed = disclosedKeys.includes(arrayFullKey);
return value.flatMap((item, index) => {
const itemKey = `${key}[${index}]`;
const itemFullKey = parentKey ? `${parentKey}.${itemKey}` : itemKey;
const isItemDisclosed = disclosedKeys.includes(itemFullKey);
const showDisclosureIcon = isArrayDisclosed || isItemDisclosed;
return [
<Row
key={`section-${itemFullKey}`}
align="flex-start"
style={{
marginTop: 8,
marginBottom: 4,
}}>
<Text
style={{
paddingLeft: depth * 12,
fontWeight: '600',
color: fieldNameColor,
}}>
{label} {value.length > 1 ? index + 1 : ''}
</Text>
{showDisclosureIcon && (
<Icon name="share-square-o" size={14} color="#666" style={{marginLeft:2}} />
)}
</Row>,
...renderFieldRecursively(
itemKey,
item,
fieldNameColor,
fieldValueColor,
parentKey,
depth + 1,
renderedFields,
disclosedKeys
),
];
});
}
// Handle objects
if (typeof value === 'object') {
return Object.entries(value).flatMap(([childKey, childValue]) =>
renderFieldRecursively(
childKey,
childValue,
fieldNameColor,
fieldValueColor,
fullKey,
depth + 1,
renderedFields,
disclosedKeys
),
);
}
// Handle primitive values
let displayValue: string | JSX.Element = String(value);
// Image rendering
if (typeof value === 'string' && value.startsWith('data:image')) {
displayValue = (
<Image
source={{ uri: value }}
style={{ width: 100, height: 100, borderRadius: 8 }}
resizeMode="contain"
/>
);
} else if (
value?.startsWith?.('http') &&
key.toLowerCase().includes('image')
) {
displayValue = (
<Image
source={{ uri: value }}
style={{ width: 100, height: 100, borderRadius: 8 }}
resizeMode="contain"
/>
);
} else if (
typeof value === 'number' &&
['iat', 'nbf', 'exp'].includes(shortKey.toLowerCase())
) {
displayValue = new Date(value * 1000).toLocaleString();
} else if (
typeof value === 'string' &&
/^\d+$/.test(value) &&
['iat', 'nbf', 'exp'].includes(shortKey.toLowerCase())
) {
const timestamp = parseInt(value, 10);
displayValue = new Date(timestamp * 1000).toLocaleString();
} else if (/^\d{4}-\d{2}-\d{2}T/.test(displayValue)) {
const date = new Date(displayValue);
displayValue = date.toLocaleString();
}
else if (displayValue.length > 100) {
displayValue = displayValue.slice(0, 60) + '...';
}
const publicKeyLabelMap: Record<string, string> = {
iss: 'Issuer',
sub: 'Subject',
aud: 'Audience',
exp: 'Expires At',
nbf: 'Not Before',
iat: 'Issued At',
jti: 'JWT ID',
vct: 'Verifiable Credential Type',
};
if (shortKey in publicKeyLabelMap) {
shortKey = publicKeyLabelMap[shortKey];
}
const label = formatKeyLabel(shortKey);
const isDisclosed = disclosedKeys.includes(fullKey);
return [
<Row
key={`extra-${fullKey}`}
style={{
flexDirection: 'row',
flex: 1,
paddingLeft: depth * 12,
}}
align="space-between"
margin="0 8 15 0">
<VCItemField
key={`extra-${fullKey}`}
fieldName={label}
fieldValue={displayValue}
fieldNameColor={fieldNameColor}
fieldValueColor={fieldValueColor}
testID={`extra-${fullKey}`}
isDisclosed={isDisclosed}
/>
</Row>,
];
};
export const fieldItemIterator = (
fields: any[],
wellknownFieldsFlag: boolean,
verifiableCredential: VerifiableCredential | Credential,
wellknown: any,
display: Display,
isBottomSectionFields: boolean,
props: VCItemDetailsProps,
): JSX.Element[] => {
const fieldNameColor = display.getTextColor(Theme.Colors.DetailsLabel);
const fieldValueColor = display.getTextColor(Theme.Colors.Details);
const disclosedKeys = verifiableCredential.disclosedKeys || [];
const renderedFields = new Set<string>();
const renderedMainFields = fields.map(field => {
const fieldName = getFieldName(
field,
wellknown,
props.verifiableCredentialData.vcMetadata.format,
);
const fieldValue = getFieldValue(
verifiableCredential,
field,
wellknown,
props,
display,
props.verifiableCredentialData.vcMetadata.format,
);
if (fieldValue == null) {
return null;
}
renderedFields.add(field);
if (
(field === 'credentialRegistry' &&
CREDENTIAL_REGISTRY_EDIT === 'false') ||
!fieldValue
) {
return null;
}
const isDisclosed = disclosedKeys.includes(field);
return (
<Row
key={field}
style={{ flexDirection: 'row', flex: 1 }}
align="space-between"
margin="0 8 15 0">
<VCItemField
key={field}
fieldName={fieldName}
fieldValue={fieldValue}
fieldNameColor={fieldNameColor}
fieldValueColor={fieldValueColor}
testID={field}
isDisclosed={isDisclosed}
/>
</Row>
);
});
let renderedExtraFields: JSX.Element[] = [];
DETAIL_VIEW_BOTTOM_SECTION_FIELDS.forEach(item => renderedFields.add(item));
if (!wellknownFieldsFlag || verifiableCredential.fullResolvedPayload && !isBottomSectionFields) {
const renderedAll: JSX.Element[] = [];
// Extra fields from credentialSubject
const credentialSubjectFields =
(verifiableCredential.credentialSubject as Record<string, any>) || verifiableCredential.fullResolvedPayload || {};
const renderedSubjectFields = Object.entries(credentialSubjectFields)
.filter(([key]) => !renderedFields.has(key))
.flatMap(([key, value]) =>
renderFieldRecursively(key, value, fieldNameColor, fieldValueColor, '', 0, renderedFields, disclosedKeys),
);
renderedAll.push(...renderedSubjectFields);
// Render fields from nameSpaces (mso_mdoc)
const nameSpaces: Record<string, any> =
verifiableCredential.nameSpaces ??
verifiableCredential.issuerSigned?.nameSpaces ??
verifiableCredential.issuerAuth?.nameSpaces ??
{};
const renderedNamespaceFields = Object.entries(nameSpaces).flatMap(
([namespace, entries]) => {
if (!Array.isArray(entries)) return [];
return [
<VCItemField
key={`ns-title-${namespace}`}
fieldName={namespace}
fieldValue=""
fieldNameColor={fieldNameColor}
fieldValueColor={fieldValueColor}
testID={`ns-title-${namespace}`}
/>,
...entries.flatMap((entry, index) =>
renderFieldRecursively(
entry.elementIdentifier,
entry.elementValue,
fieldNameColor,
fieldValueColor,
namespace,
1,
renderedFields,
disclosedKeys
),
),
];
},
);
renderedAll.push(...renderedNamespaceFields);
renderedExtraFields = renderedAll;
}
return [...renderedMainFields, ...renderedExtraFields];
};
export const isVCLoaded = (
verifiableCredential: Credential | null
) => {
return verifiableCredential != null;
};
export const getMosipLogo = () => {
return {
url: `${MIMOTO_BASE_URL}/inji/mosip-logo.png`,
alt_text: 'a square logo of mosip',
};
};
/**
*
* @param wellknown (either supportedCredential's wellknown or whole well known response of issuer)
* @param credentialConfigurationId
* @returns credential type translations (Eg - National ID)
*
*/
export const getCredentialType = (
supportedCredentialsWellknown: CredentialTypes,
): string => {
if (!!!supportedCredentialsWellknown) {
return i18n.t('VcDetails:identityCard');
}
if (supportedCredentialsWellknown['display']) {
const wellknownDisplayProperty = getDisplayObjectForCurrentLanguage(
supportedCredentialsWellknown.display,
);
return wellknownDisplayProperty.name;
}
if (supportedCredentialsWellknown.format === VCFormat.ldp_vc) {
const types = supportedCredentialsWellknown.credential_definition
.type as string[];
return types[types.length - 1];
} else {
return i18n.t('VcDetails:identityCard');
}
};
export const getCredentialTypeFromWellKnown = (
wellknown: IssuerWellknownResponse,
credentialConfigurationId: string | undefined = undefined,
): string => {
try {
if (credentialConfigurationId !== undefined) {
const supportedCredentialsWellknown = getMatchingCredentialIssuerMetadata(
wellknown,
credentialConfigurationId,
);
return getCredentialType(supportedCredentialsWellknown);
}
console.error(
'credentialConfigurationId not available for fetching the Credential type',
);
throw new Error(
`Invalid credentialConfigurationId - ${credentialConfigurationId} passed`,
);
} catch (error) {
return i18n.t('VcDetails:identityCard');
}
};
export class Display {
private readonly textColor: string | undefined = undefined;
private readonly backgroundColor: { backgroundColor: string };
private readonly backgroundImage: { uri: string } | undefined = undefined;
private defaultBackgroundColor = Theme.Colors.whiteBackgroundColor;
constructor(wellknown: any) {
const wellknownDisplayProperty = wellknown?.display
? getDisplayObjectForCurrentLanguage(wellknown.display)
: {};
if (!!!Object.keys(wellknownDisplayProperty).length) {
this.backgroundColor = {
backgroundColor: this.defaultBackgroundColor,
};
return;
}
const display = wellknownDisplayProperty as displayType;
this.backgroundColor = {
backgroundColor: display.background_color ?? this.defaultBackgroundColor,
};
this.backgroundImage = display.background_image;
this.textColor = display.text_color;
}
getTextColor(defaultColor: string): string {
return this.textColor ?? defaultColor;
}
getBackgroundColor(): { backgroundColor: string } {
return this.backgroundColor;
}
getBackgroundImage(defaultBackgroundImage: string) {
return this.backgroundImage ?? defaultBackgroundImage;
}
}
export function getIssuerAuthenticationAlorithmForMdocVC(
proofType: any,
): string {
return PROOF_TYPE_ALGORITHM_MAP[proofType] || '';
}
export function getMdocAuthenticationAlorithm(issuerAuth: any): string {
const deviceKey = issuerAuth?.deviceKeyInfo?.deviceKey;
if (!deviceKey) return '';
const keyType = deviceKey['1'];
const curve = deviceKey['-1'];
return keyType === ProtectedAlgorithm.EC2 && curve === ProtectedCurve.P256
? 'ES256'
: '';
}
const ProtectedAlgorithm = {
EC2: 2,
};
const ProtectedCurve = {
P256: 1,
};
const PROOF_TYPE_ALGORITHM_MAP = {
[-7]: 'ES256',
};