Skip to content

Commit 459dbe6

Browse files
fix: nested ctype loading (#931)
Co-authored-by: Aybars Göktuğ Ayan <[email protected]>
1 parent d245399 commit 459dbe6

File tree

3 files changed

+145
-21
lines changed

3 files changed

+145
-21
lines changed

packages/credentials/src/V1/KiltCredentialV1.spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from '../../../../tests/testUtils/testData.js'
1515
import {
1616
credentialSchema,
17+
fromInput,
1718
validateStructure,
1819
validateSubject,
1920
} from './KiltCredentialV1.js'
@@ -32,6 +33,45 @@ it('it verifies valid claim against schema', async () => {
3233
await expect(validateSubject(VC, { cTypes: [cType] })).resolves.not.toThrow()
3334
})
3435

36+
it('it verifies valid claim against nested schema', async () => {
37+
const nestedCType = CType.fromProperties('nested', {
38+
prop: {
39+
$ref: cType.$id,
40+
},
41+
})
42+
const nestedVc = fromInput({
43+
cType: nestedCType.$id,
44+
claims: {
45+
prop: {
46+
name: 'Kurt',
47+
},
48+
},
49+
subject: VC.credentialSubject.id,
50+
issuer: VC.issuer,
51+
})
52+
53+
await expect(
54+
validateSubject(nestedVc, {
55+
cTypes: [nestedCType, cType],
56+
loadCTypes: false,
57+
})
58+
).resolves.not.toThrow()
59+
60+
await expect(
61+
validateSubject(nestedVc, {
62+
loadCTypes: CType.newCachingCTypeLoader([nestedCType, cType], () =>
63+
Promise.reject()
64+
),
65+
})
66+
).resolves.not.toThrow()
67+
68+
await expect(
69+
validateSubject(nestedVc, { cTypes: [nestedCType], loadCTypes: false })
70+
).rejects.toThrowErrorMatchingInlineSnapshot(
71+
`"This credential is based on CType kilt:ctype:0xf0fd09f9ed6233b2627d37eb5d6c528345e8945e0b610e70997ed470728b2ebf whose definition has not been passed to the validator, while automatic CType loading has been disabled."`
72+
)
73+
})
74+
3575
it('it detects schema violations', async () => {
3676
const credentialSubject = { ...VC.credentialSubject, name: 5 }
3777
await expect(

packages/credentials/src/V1/KiltCredentialV1.ts

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ import {
3131
jsonLdExpandCredentialSubject,
3232
spiritnetGenesisHash,
3333
} from './common.js'
34-
import { CTypeLoader, newCachingCTypeLoader } from '../ctype/CTypeLoader.js'
34+
import {
35+
type CTypeLoader,
36+
newCachingCTypeLoader,
37+
} from '../ctype/CTypeLoader.js'
3538

3639
export {
3740
credentialIdFromRootHash as idFromRootHash,
@@ -329,6 +332,13 @@ const cachingCTypeLoader = newCachingCTypeLoader()
329332

330333
/**
331334
* Validates the claims in the VC's `credentialSubject` against a CType definition.
335+
* Supports both nested and non-nested CType validation.
336+
* For non-nested CTypes:
337+
* - Validates claims directly against the CType schema.
338+
* For nested CTypes:
339+
* - Automatically detects nested structure through `$ref` properties.
340+
* - Fetches referenced CTypes via the `loadCTypes` funtion, if not included in `cTypes`.
341+
* - Performs validation against the main CType and all referenced CTypes.
332342
*
333343
* @param credential A {@link KiltCredentialV1} type verifiable credential.
334344
* @param credential.credentialSubject The credentialSubject to be validated.
@@ -354,26 +364,13 @@ export async function validateSubject(
354364
if (!credentialsCTypeId) {
355365
throw new Error('credential type does not contain a valid CType id')
356366
}
357-
// check that we have access to the right schema
358-
let cType = cTypes?.find(({ $id }) => $id === credentialsCTypeId)
359-
if (!cType) {
360-
if (typeof loadCTypes !== 'function') {
361-
throw new Error(
362-
`The definition for this credential's CType ${credentialsCTypeId} has not been passed to the validator and CType loading has been disabled`
363-
)
364-
}
365-
cType = await loadCTypes(credentialsCTypeId)
366-
if (cType.$id !== credentialsCTypeId) {
367-
throw new Error('failed to load correct CType')
368-
}
369-
}
370367

371368
// normalize credential subject to form expected by CType schema
372369
const expandedClaims: Record<string, unknown> =
373370
jsonLdExpandCredentialSubject(credentialSubject)
374371
delete expandedClaims['@id']
375372

376-
const vocab = `${cType.$id}#`
373+
const vocab = `${credentialsCTypeId}#`
377374
const claims = Object.entries(expandedClaims).reduce((obj, [key, value]) => {
378375
if (!key.startsWith(vocab)) {
379376
throw new Error(
@@ -385,6 +382,28 @@ export async function validateSubject(
385382
[key.substring(vocab.length)]: value,
386383
}
387384
}, {})
385+
386+
// Turn CType loader & ctypes array into combined loader function
387+
const combinedCTypeLoader = newCachingCTypeLoader(
388+
cTypes,
389+
typeof loadCTypes === 'function'
390+
? loadCTypes
391+
: (id) =>
392+
Promise.reject(
393+
new Error(
394+
`This credential is based on CType ${id} whose definition has not been passed to the validator, while automatic CType loading has been disabled.`
395+
)
396+
)
397+
)
398+
399+
const cType = await combinedCTypeLoader(credentialsCTypeId)
400+
401+
// Load all nested CTypes
402+
const referencedCTypes = await CType.loadNestedCTypeDefinitions(
403+
cType,
404+
combinedCTypeLoader
405+
)
406+
388407
// validates against CType (also validates CType schema itself)
389-
CType.verifyClaimAgainstSchema(claims, cType)
408+
CType.verifyClaimAgainstNestedSchemas(cType, referencedCTypes, claims)
390409
}

packages/credentials/src/ctype/CTypeLoader.ts

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
* found in the LICENSE file in the root directory of this source tree.
66
*/
77

8-
import { ICType } from '@kiltprotocol/types'
8+
import type { ICType } from '@kiltprotocol/types'
9+
import { SDKErrors } from '@kiltprotocol/utils'
910

1011
import { fetchFromChain } from './CType.chain.js'
12+
import { isICType, verifyDataStructure } from './CType.js'
1113

1214
export type CTypeLoader = (id: ICType['$id']) => Promise<ICType>
1315

14-
const loadCType: CTypeLoader = async (id) => {
16+
const chainCTypeLoader: CTypeLoader = async (id) => {
1517
return (await fetchFromChain(id)).cType
1618
}
1719

@@ -20,10 +22,13 @@ const loadCType: CTypeLoader = async (id) => {
2022
* Used in validating the credentialSubject of a {@link KiltCredentialV1} against the Claim Type referenced in its `type` field.
2123
*
2224
* @param initialCTypes An array of CTypes with which the cache is to be initialized.
23-
* @returns A function that takes a CType id and looks up a CType definition in an internal cache, and if not found, tries to fetch it from the KILT blochchain.
25+
* @param cTypeLoader A basic {@link CTypeLoader} to augment with a caching layer.
26+
* Defaults to loading CType definitions from the KILT blockchain.
27+
* @returns A function that takes a CType id and looks up a CType definition in an internal cache, and if not found, tries to fetch it from an external source.
2428
*/
2529
export function newCachingCTypeLoader(
26-
initialCTypes: ICType[] = []
30+
initialCTypes: ICType[] = [],
31+
cTypeLoader = chainCTypeLoader
2732
): CTypeLoader {
2833
const ctypes: Map<string, ICType> = new Map()
2934

@@ -32,9 +37,69 @@ export function newCachingCTypeLoader(
3237
})
3338

3439
async function getCType(id: ICType['$id']): Promise<ICType> {
35-
const ctype: ICType = ctypes.get(id) ?? (await loadCType(id))
40+
let ctype = ctypes.get(id)
41+
if (ctype) {
42+
return ctype
43+
}
44+
ctype = await cTypeLoader(id)
45+
verifyDataStructure(ctype)
46+
if (id !== ctype.$id) {
47+
throw new SDKErrors.CTypeIdMismatchError(ctype.$id, id)
48+
}
3649
ctypes.set(ctype.$id, ctype)
3750
return ctype
3851
}
3952
return getCType
4053
}
54+
55+
/**
56+
* Recursively traverses a (nested) CType's definition to load definitions of CTypes referenced within.
57+
*
58+
* @param cType A (nested) CType containg references to other CTypes.
59+
* @param cTypeLoader A function with which to load CType definitions.
60+
* @returns An array of CType definitions which were referenced in the original CType or in any of its composite CTypes.
61+
*/
62+
export async function loadNestedCTypeDefinitions(
63+
cType: ICType,
64+
cTypeLoader: CTypeLoader
65+
): Promise<ICType[]> {
66+
const fetchedCTypeIds = new Set<string>()
67+
const fetchedCTypeDefinitions: ICType[] = []
68+
69+
// Don't fetch the original CType
70+
fetchedCTypeIds.add(cType.$id)
71+
72+
async function extractRefsFrom(value: unknown): Promise<void> {
73+
if (typeof value !== 'object' || value === null) {
74+
return
75+
}
76+
77+
if ('$ref' in value) {
78+
const ref = (value as { $ref: unknown }).$ref
79+
if (typeof ref === 'string' && ref.startsWith('kilt:ctype:')) {
80+
const cTypeId = ref.split('#/')[0] as ICType['$id']
81+
82+
if (!fetchedCTypeIds.has(cTypeId)) {
83+
fetchedCTypeIds.add(cTypeId)
84+
const referencedCType = await cTypeLoader(cTypeId)
85+
86+
if (isICType(referencedCType)) {
87+
fetchedCTypeDefinitions.push(referencedCType)
88+
} else {
89+
throw new Error(`Failed to load referenced CType: ${cTypeId}`)
90+
}
91+
92+
await extractRefsFrom(referencedCType.properties)
93+
}
94+
}
95+
return
96+
}
97+
98+
// Process all values in the object. Also works for arrays
99+
await Promise.all(Object.values(value).map(extractRefsFrom))
100+
}
101+
102+
await extractRefsFrom(cType.properties)
103+
104+
return fetchedCTypeDefinitions
105+
}

0 commit comments

Comments
 (0)