diff --git a/src/cr1/validator/index.ts b/src/cr1/validator/index.ts index e022373..b0bae7d 100644 --- a/src/cr1/validator/index.ts +++ b/src/cr1/validator/index.ts @@ -1,103 +1,97 @@ +import { RequestValidator, SecuredContentType, CredentialSchema, CredentialStatus, BitstringStatusListCredential, ValidationResult, VerifiableCredential, JsonSchemaError, TraceablePresentationValidationResult } from "../types"; -import { - RequestValidator, - SecuredContentType, - CredentialSchema, - CredentialStatus, - BitstringStatusListCredential, - ValidationResult, - VerifiableCredential, - JsonSchemaError, - TraceablePresentationValidationResult -} from "../types" +import { verifier } from "../verifier"; -import { verifier } from "../verifier" +import { decoder } from "../text"; -import { decoder } from "../text" +import { bs } from "../../cr1/status-list"; -import { bs } from '../../cr1/status-list' +import { conformance } from "./w3c"; -import { conformance } from './w3c' - -import { ajv } from "./ajv" +import { ajv } from "./ajv"; export const validator = ({ resolver }: RequestValidator) => { return { validate: async ({ type, content }: SecuredContentType) => { - const verified = await verifier({ resolver }).verify({ type, content }) + const verified = await verifier({ resolver }).verify({ type, content }); const validation: ValidationResult = { verified: true, content: verified, schema: {}, status: {}, - warnings: [] - } - const { credentialSchema, credentialStatus } = verified + warnings: [], + }; + const { credentialSchema, credentialStatus } = verified; if (credentialSchema) { - const schemas = (Array.isArray(credentialSchema) ? verified.credentialSchema : [credentialSchema]) as CredentialSchema[] + const schemas = (Array.isArray(credentialSchema) ? verified.credentialSchema : [credentialSchema]) as CredentialSchema[]; for (const schema of schemas) { - if (schema.type === 'JsonSchema') { + if (schema.type === "JsonSchema") { const credentialSchema = await resolver.resolve({ // prefer to resolve this one by id, instead of content id: schema.id, - type: 'application/schema+json', - purpose: 'schema-validation' - }) + type: "application/schema+json", + purpose: "schema-validation", + }); if (credentialSchema === true) { - validation.schema[schema.id] = { validation: 'ignored' } + validation.schema[schema.id] = { validation: "ignored" }; continue; } - const schemaContent = decoder.decode(credentialSchema.content) - const parsedSchemaContent = JSON.parse(schemaContent) + const schemaContent = decoder.decode(credentialSchema.content); + const parsedSchemaContent = JSON.parse(schemaContent); let valid: any; - let compiledSchemaValidator: any + let compiledSchemaValidator: any; try { - const maybeExistingSchema = ajv.getSchema(parsedSchemaContent.$id) - compiledSchemaValidator = maybeExistingSchema + const maybeExistingSchema = ajv.getSchema(parsedSchemaContent.$id); + compiledSchemaValidator = maybeExistingSchema; if (compiledSchemaValidator === undefined) { // only compile new schemas... // this assumes schemas do not change. - compiledSchemaValidator = ajv.compile(parsedSchemaContent) + compiledSchemaValidator = ajv.compile(parsedSchemaContent); } - valid = compiledSchemaValidator(verified) + valid = compiledSchemaValidator(verified); } catch (e) { - valid = false + valid = false; } - validation.schema[schema.id] = { validation: valid ? 'succeeded' : 'failed' } + validation.schema[schema.id] = { validation: valid ? "succeeded" : "failed" }; if (!valid) { - validation.schema[schema.id].errors = compiledSchemaValidator.errors as JsonSchemaError[] + if (compiledSchemaValidator === undefined) { + validation.schema[schema.id].errors = [{ message: "json schema has invalid syntax" }] as any; + } else { + validation.schema[schema.id].errors = compiledSchemaValidator.errors as JsonSchemaError[]; + } } } } } if (credentialStatus) { - const statuses = (Array.isArray(credentialStatus) ? verified.credentialStatus : [credentialStatus]) as CredentialStatus[] + const statuses = (Array.isArray(credentialStatus) ? verified.credentialStatus : [credentialStatus]) as CredentialStatus[]; for (const status of statuses) { - if (status.type === 'BitstringStatusListEntry') { + if (status.type === "BitstringStatusListEntry") { const statusListCredential = await resolver.resolve({ // prefer to resolve this one by id, instead of content id: status.statusListCredential, type: type, // we do not support mixed type credential and status lists! - purpose: 'status-check' - }) - const verified = await verifier({ resolver }).verify(statusListCredential) + purpose: "status-check", + }); + const verified = await verifier({ resolver }).verify(statusListCredential); // confirm purpose matches if (status.statusPurpose !== verified.credentialSubject.statusPurpose) { validation.status[`${status.id}`] = { [status.statusPurpose]: false, - errors: [{ - message: 'status list purpose does not match credential status' - }] - } + errors: [ + { + message: "status list purpose does not match credential status", + }, + ], + }; } else { - const bit = bs(verified.credentialSubject.encodedList).get(parseInt(status.statusListIndex, 10)) - validation.status[`${status.id}`] = { [status.statusPurpose]: bit } + const bit = bs(verified.credentialSubject.encodedList).get(parseInt(status.statusListIndex, 10)); + validation.status[`${status.id}`] = { [status.statusPurpose]: bit }; } - } } } - return conformance(validation) as T - } - } -} \ No newline at end of file + return conformance(validation) as T; + }, + }; +}; diff --git a/test/json-schema-tests/sanity-tests.test.ts b/test/json-schema-tests/sanity-tests.test.ts new file mode 100644 index 0000000..9e4b030 --- /dev/null +++ b/test/json-schema-tests/sanity-tests.test.ts @@ -0,0 +1,343 @@ +import * as jose from "jose"; +import yaml from "yaml"; + +import * as transmute from "../../src"; + +const alg = `ES256`; + +let privateKey: any; +let publicKey: any; + +const createTestCase = async (claimset: string, schema: string, publicKey: any, privateKey: any) => { + const issued = await transmute + .issuer({ + alg, + type: "application/vc+ld+json+sd-jwt", + signer: { + sign: async (bytes: Uint8Array) => { + const jws = await new jose.CompactSign(bytes).setProtectedHeader({ kid: `did:example:123#key-42`, alg }).sign( + await transmute.key.importKeyLike({ + type: "application/jwk+json", + content: privateKey, + }) + ); + return transmute.text.encoder.encode(jws); + }, + }, + }) + .issue({ + claimset: transmute.text.encoder.encode(claimset), + }); + const validator = await transmute.validator({ + resolver: { + resolve: async ({ id, type, content }) => { + if (id === `https://vendor.example/api/schemas/product-passport`) { + return { + type: `application/schema+json`, + content: transmute.text.encoder.encode(schema), + }; + } + if (id === `https://vendor.example/api/schemas/product-passport#broken`) { + return { + type: `application/schema+json`, + content: transmute.text.encoder.encode(schema), + }; + } + if (id === `https://vendor.example/credentials/status/3`) { + return { + type: `application/vc+ld+json+jwt`, + content: await transmute + .issuer({ + alg: "ES384", + type: "application/vc+ld+json+cose", + signer: { + sign: async (bytes: Uint8Array) => { + const jws = await new jose.CompactSign(bytes).setProtectedHeader({ kid: `did:example:123#key-42`, alg }).sign( + await transmute.key.importKeyLike({ + type: "application/jwk+json", + content: privateKey, + }) + ); + return transmute.text.encoder.encode(jws); + }, + }, + }) + .issue({ + claimset: transmute.text.encoder.encode( + ` +"@context": + - https://www.w3.org/ns/credentials/v2 +id: https://vendor.example/status/3#list +type: + - VerifiableCredential + - BitstringStatusListCredential +issuer: + id: did:example:123 +validFrom: 2024-07-11T20:15:51.249Z +credentialSubject: + id: https://vendor.example/status/3#list#list + type: BitstringStatusList + statusPurpose: revocation + encodedList: ${await transmute.status.bs(8).set(0, true).encode()} +`.trim() + ), + }), + }; + } + if (content != undefined && type === `application/vc+ld+json+sd-jwt`) { + return { + type: "application/jwk+json", + content: publicKey, + }; + } + if (content != undefined && type === `application/vc+ld+json+jwt`) { + return { + type: "application/jwk+json", + content: publicKey, + }; + } + console.log({ id, type, content }); + throw new Error("Resolver option not supported."); + }, + }, + }); + // call valdiate twice for sanity + const valid1 = await validator.validate({ + type: "application/vc+ld+json+sd-jwt", + content: issued, + }); + return valid1; +}; + +describe("json schema sanity tests", () => { + beforeAll(async () => { + privateKey = await transmute.key.generate({ + alg, + type: "application/jwk+json", + }); + publicKey = await transmute.key.publicFromPrivate({ + type: "application/jwk+json", + content: privateKey, + }); + }); + it("simple", async () => { + const claimset = ` +"@context": + - https://www.w3.org/ns/credentials/v2 + - https://vendor.example/api/context/v2 + +id: https://vendor.example/api/credentials/3732 +type: + - VerifiableCredential + - ExampleDegreeCredential +issuer: + id: did:example:123 + name: "Example University" +validFrom: "2024-07-11T20:15:51.249Z" +credentialSchema: + id: https://vendor.example/api/schemas/product-passport + type: JsonSchema +credentialSubject: + id: did:example:ebfeb1f712ebc6f1c276e12ec21 + degree: + type: ExampleBachelorDegree + subtype: Bachelor of Science and Arts +`; + const schema = ` +{ + "$id": "https://vendor.example/api/schemas/product-passport", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Example JSON Schema", + "description": "This is a test schema", + "type": "object", + "properties": { + "credentialSubject": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + } + } + } +} + `; + const valid1 = await createTestCase(claimset, schema, publicKey, privateKey); + expect(valid1.verified).toBe(true); + }); + + it("revocable invalid schema syntax", async () => { + const claimset = ` +"@context": + - https://www.w3.org/ns/credentials/v2 + - https://vendor.example/api/context/v2 + +id: https://vendor.example/api/credentials/3732 +type: + - VerifiableCredential + - ExampleDegreeCredential +issuer: + id: did:example:123 + name: "Example University" +validFrom: "2024-07-11T20:15:51.249Z" +credentialSchema: + id: https://vendor.example/api/schemas/product-passport#broken + type: JsonSchema +credentialStatus: + - id: https://vendor.example/credentials/status/3#0 + type: BitstringStatusListEntry + statusPurpose: revocation + statusListIndex: "0" + statusListCredential: "https://vendor.example/credentials/status/3" +credentialSubject: + id: did:example:ebfeb1f712ebc6f1c276e12ec21 + degree: + type: ExampleBachelorDegree + subtype: Bachelor of Science and Arts +`; + const schema = ` +{ + "$id": "https://vendor.example/api/schemas/product-passport#broken", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Example JSON Schema", + "description": "This is a test schema", + "type": "object", + "properties": { + "credentialStatus": { + "type": "array", + "items": [{ + "type": "object" + }] + }, + "credentialSubject": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + } + } + } +} + `; + const valid1 = await createTestCase(claimset, schema, publicKey, privateKey); + expect(valid1.verified).toBe(true); + expect(valid1.schema["https://vendor.example/api/schemas/product-passport#broken"].errors).toEqual([{ message: "json schema has invalid syntax" }]); + }); + + it("revocable valid schema syntax", async () => { + const claimset = ` +"@context": + - https://www.w3.org/ns/credentials/v2 + - https://vendor.example/api/context/v2 + +id: https://vendor.example/api/credentials/3732 +type: + - VerifiableCredential + - ExampleDegreeCredential +issuer: + id: did:example:123 + name: "Example University" +validFrom: "2024-07-11T20:15:51.249Z" +credentialSchema: + id: https://vendor.example/api/schemas/product-passport + type: JsonSchema +credentialStatus: + - id: https://vendor.example/credentials/status/3#0 + type: BitstringStatusListEntry + statusPurpose: revocation + statusListIndex: "0" + statusListCredential: "https://vendor.example/credentials/status/3" +credentialSubject: + id: did:example:ebfeb1f712ebc6f1c276e12ec21 + degree: + type: ExampleBachelorDegree + subtype: Bachelor of Science and Arts +`; + const schema = ` +{ + "$id": "https://vendor.example/api/schemas/product-passport", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Example JSON Schema", + "description": "This is a test schema", + "type": "object", + "properties": { + "credentialStatus": { + "type": "array", + "items": { + "type": "object" + } + }, + "credentialSubject": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + } + } + } +} + `; + const valid1 = await createTestCase(claimset, schema, publicKey, privateKey); + expect(valid1.verified).toBe(true); + expect(valid1.schema["https://vendor.example/api/schemas/product-passport"]).toEqual({ + validation: "succeeded", + }); + }); + + it("yaml valid schema syntax", async () => { + const claimset = ` +"@context": + - https://www.w3.org/ns/credentials/v2 + - https://vendor.example/api/context/v2 + +id: https://vendor.example/api/credentials/3732 +type: + - VerifiableCredential + - ExampleDegreeCredential +issuer: + id: did:example:123 + name: "Example University" +validFrom: "2024-07-11T20:15:51.249Z" +credentialSchema: + id: https://vendor.example/api/schemas/product-passport + type: JsonSchema +credentialStatus: + - id: https://vendor.example/credentials/status/3#0 + type: BitstringStatusListEntry + statusPurpose: revocation + statusListIndex: "0" + statusListCredential: "https://vendor.example/credentials/status/3" +credentialSubject: + id: did:example:ebfeb1f712ebc6f1c276e12ec21 + degree: + type: ExampleBachelorDegree + subtype: Bachelor of Science and Arts +`; + const schema = JSON.stringify( + yaml.parse(` +"$id": https://vendor.example/api/schemas/product-passport +"$schema": https://json-schema.org/draft/2020-12/schema +title: Example JSON Schema +description: This is a test schema +type: object +properties: + credentialStatus: + type: array + items: + type: object + credentialSubject: + type: object + properties: + id: + type: string + `) + ); + const valid1 = await createTestCase(claimset, schema, publicKey, privateKey); + expect(valid1.verified).toBe(true); + expect(valid1.schema["https://vendor.example/api/schemas/product-passport"]).toEqual({ + validation: "succeeded", + }); + }); +});