diff --git a/packages/credentials/src/V1/KiltCredentialV1.ts b/packages/credentials/src/V1/KiltCredentialV1.ts index 557322099..4cc2d5c09 100644 --- a/packages/credentials/src/V1/KiltCredentialV1.ts +++ b/packages/credentials/src/V1/KiltCredentialV1.ts @@ -327,15 +327,83 @@ export function fromInput({ const cachingCTypeLoader = newCachingCTypeLoader() +async function loadNestedCTypeDefinitions( + cType: ICType, + cTypeLoader: (id: string) => Promise +): Promise> { + const fetchedCTypeIds = new Set() + const fetchedCTypeDefinitions = new Set() + + async function processValue(value: unknown): Promise { + if (typeof value !== 'object' || value === null) { + return + } + + if (Array.isArray(value)) { + await Promise.all(value.map(processValue)) + return + } + + // Check if value is an object with $ref + const objValue = value as Record + if ('$ref' in objValue) { + const ref = objValue.$ref + if (typeof ref === 'string' && ref.startsWith('kilt:ctype:')) { + const cTypeId = ref.split('#/')[0] + + if (!fetchedCTypeIds.has(cTypeId)) { + fetchedCTypeIds.add(cTypeId) + const referencedCType = await cTypeLoader(cTypeId) + + if (referencedCType === undefined || referencedCType === null) { + throw new Error(`Failed to load referenced CType: ${cTypeId}`) + } + + fetchedCTypeDefinitions.add(referencedCType) + + const { properties } = referencedCType + if (properties !== undefined && properties !== null) { + await Promise.all(Object.values(properties).map(processValue)) + } + } + } + return + } + + // Process all values in the object + await Promise.all(Object.values(objValue).map(processValue)) + } + + const { properties } = cType + if (properties !== undefined && properties !== null) { + await Promise.all(Object.values(properties).map(processValue)) + } + + return fetchedCTypeDefinitions +} + /** * Validates the claims in the VC's `credentialSubject` against a CType definition. + * Supports both nested and non-nested CType validation. + * For non-nested CTypes: + * - Validates claims directly against the CType schema. + * For nested CTypes: + * - Automatically detects nested structure through $ref properties. + * - Fetches referenced CTypes from the blockchain. + * - Performs validation against the main CType and all referenced CTypes. * * @param credential A {@link KiltCredentialV1} type verifiable credential. * @param credential.credentialSubject The credentialSubject to be validated. * @param credential.type The credential's types. * @param options Options map. - * @param options.cTypes One or more CType definitions to be used for validation. If `loadCTypes` is set to `false`, validation will fail if the definition of the credential's CType is not given. - * @param options.loadCTypes A function to load CType definitions that are not in `cTypes`. Defaults to using the {@link newCachingCTypeLoader | CachingCTypeLoader}. If set to `false` or `undefined`, no additional CTypes will be loaded. + * @param options.cTypes One or more CType definitions to be used for validation. If loadCTypes is set to false, validation will fail if the definition of the credential's CType is not given. + * @param options.loadCTypes A function to load CType definitions that are not in cTypes. Defaults to using the {@link newCachingCTypeLoader | CachingCTypeLoader}. If set to false or undefined, no additional CTypes will be loaded. + * + * @throws {Error} If the credential type does not contain a valid CType id. + * @throws {Error} If required CType definitions cannot be loaded. + * @throws {Error} If claims do not follow the expected CType format. + * @throws {Error} If referenced CTypes in nested structure cannot be fetched from the blockchain. + * @throws {Error} If validation fails against the CType schema. */ export async function validateSubject( { @@ -347,14 +415,13 @@ export async function validateSubject( loadCTypes = cachingCTypeLoader, }: { cTypes?: ICType[]; loadCTypes?: false | CTypeLoader } = {} ): Promise { - // get CType id referenced in credential const credentialsCTypeId = type.find((str) => str.startsWith('kilt:ctype:') ) as ICType['$id'] if (!credentialsCTypeId) { throw new Error('credential type does not contain a valid CType id') } - // check that we have access to the right schema + let cType = cTypes?.find(({ $id }) => $id === credentialsCTypeId) if (!cType) { if (typeof loadCTypes !== 'function') { @@ -368,7 +435,6 @@ export async function validateSubject( } } - // normalize credential subject to form expected by CType schema const expandedClaims: Record = jsonLdExpandCredentialSubject(credentialSubject) delete expandedClaims['@id'] @@ -385,6 +451,42 @@ export async function validateSubject( [key.substring(vocab.length)]: value, } }, {}) - // validates against CType (also validates CType schema itself) - CType.verifyClaimAgainstSchema(claims, cType) + + // Create a type-safe loader function + const effectiveLoader = async (id: string): Promise => { + // Ensure the ID is in the correct format + if (!id.startsWith('kilt:ctype:0x')) { + throw new Error(`Invalid CType ID format: ${id}`) + } + + const typedId = id as `kilt:ctype:0x${string}` + + if (typeof loadCTypes === 'function') { + return loadCTypes(typedId) + } + const found = cTypes.find((ct) => ct.$id === typedId) + if (found) { + return found + } + throw new Error( + `CType ${id} not found in provided cTypes array and CType loading is disabled` + ) + } + // Load all nested CTypes + const referencedCTypes = await loadNestedCTypeDefinitions( + cType, + effectiveLoader + ) + + // Convert Set to Array and filter out any undefined or null values + const validCTypes = Array.from(referencedCTypes).filter( + (ctype): ctype is ICType => ctype !== undefined && ctype !== null + ) + + // Verify if all CTypes were fetched successfully + if (validCTypes.length === referencedCTypes.size) { + await CType.verifyClaimAgainstNestedSchemas(cType, validCTypes, claims) + } else { + throw new Error('Some referenced CTypes could not be fetched') + } }