From 78ab9df6c090830f927265ec56e2d6e9f0c7b803 Mon Sep 17 00:00:00 2001 From: Phan Shi Yu Date: Mon, 13 May 2024 14:32:35 +0800 Subject: [PATCH] fix: move attachments into credentialSubject (#290) --- src/4.0/__tests__/documentBuilder.test.ts | 74 +++++++++-------- src/4.0/__tests__/guard.test.ts | 17 ++-- src/4.0/__tests__/obfuscate.test.ts | 83 ++++++++++--------- src/4.0/digest.ts | 4 +- src/4.0/documentBuilder.ts | 18 +--- .../__generated__/v4-document.schema.json | 63 ++++++-------- .../v4-signed-wrapped-document.schema.json | 63 ++++++-------- .../v4-wrapped-document.schema.json | 63 ++++++-------- src/4.0/types.ts | 27 +++--- src/4.0/wrap.ts | 2 +- src/shared/@types/document.ts | 8 +- 11 files changed, 203 insertions(+), 219 deletions(-) diff --git a/src/4.0/__tests__/documentBuilder.test.ts b/src/4.0/__tests__/documentBuilder.test.ts index 6c83c714..a218db75 100644 --- a/src/4.0/__tests__/documentBuilder.test.ts +++ b/src/4.0/__tests__/documentBuilder.test.ts @@ -254,15 +254,17 @@ describe(`DocumentBuilder`, () => { test("given svg rendering method, should be added into the document", async () => { const signed = await new DocumentBuilder({ - credentialSubject: { name: "John Doe" }, + credentialSubject: { + name: "John Doe", + attachments: [ + { + data: "data", + filename: "file", + mimeType: "application/pdf", + }, + ], + }, name: "Diploma", - attachments: [ - { - data: "data", - fileName: "file", - mimeType: "application/pdf", - }, - ], }) .svgRenderer({ type: "EMBEDDED", @@ -288,15 +290,17 @@ describe(`DocumentBuilder`, () => { test("given no rendering method, should reflect in the output document", async () => { const signed = await new DocumentBuilder({ - credentialSubject: { name: "John Doe" }, + credentialSubject: { + name: "John Doe", + attachments: [ + { + data: "data", + filename: "file", + mimeType: "application/pdf", + }, + ], + }, name: "Diploma", - attachments: [ - { - data: "data", - fileName: "file", - mimeType: "application/pdf", - }, - ], }) .noRenderer() .noRevocation() @@ -312,15 +316,17 @@ describe(`DocumentBuilder`, () => { test("given attachment is added, should be added into the document", async () => { const signed = await new DocumentBuilder({ - credentialSubject: { name: "John Doe" }, + credentialSubject: { + name: "John Doe", + attachments: [ + { + data: "data", + filename: "file", + mimeType: "application/pdf", + }, + ], + }, name: "Diploma", - attachments: [ - { - data: "data", - fileName: "file", - mimeType: "application/pdf", - }, - ], }) .embeddedRenderer({ rendererUrl: "https://example.com", @@ -334,11 +340,11 @@ describe(`DocumentBuilder`, () => { }) .wrapAndSign({ signer: SAMPLE_SIGNING_KEYS }); - expect(signed.attachments).toMatchInlineSnapshot(` + expect(signed.credentialSubject.attachments).toMatchInlineSnapshot(` [ { "data": "data", - "fileName": "file", + "filename": "file", "mimeType": "application/pdf", }, ] @@ -446,14 +452,16 @@ describe(`DocumentBuilder`, () => { expect(() => { try { new DocumentBuilder({ - credentialSubject: { name: "John Doe" }, + credentialSubject: { + name: "John Doe", + attachments: [ + { + data: "data", + fileName: "file", + } as any, + ], + }, name: "Diploma", - attachments: [ - { - data: "data", - fileName: "file", - } as any, - ], }); } catch (e) { error = e; diff --git a/src/4.0/__tests__/guard.test.ts b/src/4.0/__tests__/guard.test.ts index 65c9b226..f966d4eb 100644 --- a/src/4.0/__tests__/guard.test.ts +++ b/src/4.0/__tests__/guard.test.ts @@ -6,13 +6,16 @@ import { wrapDocument } from "../wrap"; const RAW_DOCUMENT = { ...RAW_DOCUMENT_DID, - attachments: [ - { - mimeType: "image/png", - fileName: "aaa", - data: "abcd", - }, - ], + credentialSubject: { + ...RAW_DOCUMENT_DID.credentialSubject, + attachments: [ + { + mimeType: "image/png", + filename: "aaa", + data: "abcd", + }, + ], + }, } satisfies V4Document; describe("V4.0 guard", () => { diff --git a/src/4.0/__tests__/obfuscate.test.ts b/src/4.0/__tests__/obfuscate.test.ts index 2b315d2c..b6d68f31 100644 --- a/src/4.0/__tests__/obfuscate.test.ts +++ b/src/4.0/__tests__/obfuscate.test.ts @@ -9,7 +9,7 @@ import { RAW_DOCUMENT_DID, SIGNED_WRAPPED_DOCUMENT_DID_OBFUSCATED, WRAPPED_DOCUM import { hashLeafNode } from "../digest"; import { getObfuscatedData, isObfuscated } from "../../shared/utils"; -const makeV4RawDocument = >(props: T) => +const makeV4RawDocument = >(props: T) => ({ ...RAW_DOCUMENT_DID, ...(props as T), @@ -134,7 +134,7 @@ describe("privacy", () => { }); test("given an entire array of objects to remove, should remove the array itself, then for every item, add each of its key's hash into privacy.obfuscated", async () => { - const PATH_TO_REMOVE = "attachments"; + const PATH_TO_REMOVE = "credentialSubject.attachments"; const wrappedDocument = await wrapDocument( makeV4RawDocument({ credentialSubject: { @@ -142,19 +142,19 @@ describe("privacy", () => { { foo: "bar", doo: "foo" }, { foo: "baz", doo: "faz" }, ], + attachments: [ + { + mimeType: "image/png", + filename: "aaa", + data: "abcd", + }, + { + mimeType: "image/png", + filename: "bbb", + data: "abcd", + }, + ], }, - attachments: [ - { - mimeType: "image/png", - fileName: "aaa", - data: "abcd", - }, - { - mimeType: "image/png", - fileName: "bbb", - data: "abcd", - }, - ], }) ); const obfuscatedDocument = await obfuscateVerifiableCredential(wrappedDocument, PATH_TO_REMOVE); @@ -163,12 +163,12 @@ describe("privacy", () => { expect(verified).toBe(true); [ - "attachments[0].mimeType", - "attachments[0].fileName", - "attachments[0].data", - "attachments[1].mimeType", - "attachments[1].fileName", - "attachments[1].data", + "credentialSubject.attachments[0].mimeType", + "credentialSubject.attachments[0].filename", + "credentialSubject.attachments[0].data", + "credentialSubject.attachments[1].mimeType", + "credentialSubject.attachments[1].filename", + "credentialSubject.attachments[1].data", ].forEach((expectedRemovedField) => { const value = get(wrappedDocument, expectedRemovedField); const salt = findSaltByPath(wrappedDocument.proof.salts, expectedRemovedField); @@ -180,7 +180,7 @@ describe("privacy", () => { ); expect(findSaltByPath(obfuscatedDocument.proof.salts, expectedRemovedField)).toBeUndefined(); }); - expect(obfuscatedDocument.attachments).toBeUndefined(); + expect(obfuscatedDocument.credentialSubject.attachments).toBeUndefined(); expect(obfuscatedDocument.proof.privacy.obfuscated).toHaveLength(6); }); @@ -213,28 +213,33 @@ describe("privacy", () => { { foo: "bar", doo: "foo" }, { foo: "baz", doo: "faz" }, ], + attachments: [ + { + mimeType: "image/png", + filename: "aaa", + data: "abcd", + }, + { + mimeType: "image/png", + filename: "bbb", + data: "abcd", + }, + { + mimeType: "image/png", + filename: "ccc", + data: "abcd", + }, + ], }, - attachments: [ - { - mimeType: "image/png", - fileName: "aaa", - data: "abcd", - }, - { - mimeType: "image/png", - fileName: "bbb", - data: "abcd", - }, - { - mimeType: "image/png", - fileName: "ccc", - data: "abcd", - }, - ], }) ); - expect(() => obfuscateVerifiableCredential(wrappedDocument, ["attachments[0]", "attachments[2]"])).toThrow(); + expect(() => + obfuscateVerifiableCredential(wrappedDocument, [ + "credentialSubject.attachments[0]", + "credentialSubject.attachments[2]", + ]) + ).toThrow(); }); test("given a path to remove all elements in an object, should throw", async () => { diff --git a/src/4.0/digest.ts b/src/4.0/digest.ts index 8d5d9320..c2c85cb7 100644 --- a/src/4.0/digest.ts +++ b/src/4.0/digest.ts @@ -1,10 +1,10 @@ import { sortBy } from "lodash"; import { keccak256 } from "js-sha3"; -import { V4Document, Salt } from "./types"; +import { W3cVerifiableCredential, Salt } from "./types"; import { LeafValue, traverseAndFlatten } from "./traverseAndFlatten"; import { hashToBuffer } from "../shared/utils/hashing"; -export const digestCredential = (document: V4Document, salts: Salt[], obfuscatedData: string[]) => { +export const digestCredential = (document: W3cVerifiableCredential, salts: Salt[], obfuscatedData: string[]) => { // find all leaf nodes in the document and hash them // proof is not part of the digest const { proof: _, ...documentWithoutProof } = document; diff --git a/src/4.0/documentBuilder.ts b/src/4.0/documentBuilder.ts index bc611bdd..8d921f3e 100644 --- a/src/4.0/documentBuilder.ts +++ b/src/4.0/documentBuilder.ts @@ -15,8 +15,7 @@ import { ZodError, z } from "zod"; const SingleDocumentProps = z.object({ name: V4Document.shape.name.unwrap(), - credentialSubject: z.record(z.unknown()), - attachments: V4Document.shape.attachments, + credentialSubject: V4Document.shape.credentialSubject, }); const DocumentProps = z.union([SingleDocumentProps, z.array(SingleDocumentProps)]); @@ -84,14 +83,7 @@ type DocumentProps = { * * Maps to "credentialSubject" */ - credentialSubject: Record; - /** - * Optional attachments that will be rendered out of the box with OpenAttestation's - * Decentralised Renderer Components - * - * Maps to "attachments" - */ - attachments?: V4Document["attachments"]; + credentialSubject: z.infer; }; type State = { @@ -137,7 +129,7 @@ export class DocumentBuilder { if (!issuer) throw new Error("Issuer is required"); if (Array.isArray(data)) { const toWrap = data.map( - ({ name, credentialSubject, attachments }) => + ({ name, credentialSubject }) => ({ "@context": [ "https://www.w3.org/ns/credentials/v2", @@ -148,7 +140,6 @@ export class DocumentBuilder { name, credentialSubject, ...(renderMethod && { renderMethod }), - ...(attachments && { attachments }), ...(credentialStatus && { credentialStatus }), } satisfies V4Document) ); @@ -159,7 +150,7 @@ export class DocumentBuilder { // this should never happen if (!data) throw new Error("CredentialSubject is required"); - const { name, credentialSubject, attachments } = data; + const { name, credentialSubject } = data; return wrapDocument({ "@context": [ "https://www.w3.org/ns/credentials/v2", @@ -170,7 +161,6 @@ export class DocumentBuilder { name, credentialSubject, ...(renderMethod && { renderMethod }), - ...(attachments && { attachments }), ...(credentialStatus && { credentialStatus }), }) as unknown as WrappedReturn; }; diff --git a/src/4.0/jsonSchemas/__generated__/v4-document.schema.json b/src/4.0/jsonSchemas/__generated__/v4-document.schema.json index eb356cf3..e927c969 100644 --- a/src/4.0/jsonSchemas/__generated__/v4-document.schema.json +++ b/src/4.0/jsonSchemas/__generated__/v4-document.schema.json @@ -144,19 +144,38 @@ "format": "date-time" }, "credentialSubject": { - "anyOf": [ - { - "type": "object", - "additionalProperties": {} - }, - { + "type": "object", + "properties": { + "attachments": { "type": "array", "items": { "type": "object", - "additionalProperties": {} + "properties": { + "data": { + "type": "string", + "description": "Base64 encoding of this attachment" + }, + "filename": { + "type": "string", + "minLength": 1, + "description": "Name of this attachment, with appropriate extensions" + }, + "mimeType": { + "type": "string", + "minLength": 1, + "description": "Media type (or MIME type) of this attachment" + } + }, + "required": [ + "data", + "filename", + "mimeType" + ], + "additionalProperties": false } } - ] + }, + "additionalProperties": true }, "credentialStatus": { "anyOf": [ @@ -367,34 +386,6 @@ } ] } - }, - "attachments": { - "type": "array", - "items": { - "type": "object", - "properties": { - "data": { - "type": "string", - "description": "Base64 encoding of this attachment" - }, - "fileName": { - "type": "string", - "minLength": 1, - "description": "Name of this attachment, with appropriate extensions" - }, - "mimeType": { - "type": "string", - "minLength": 1, - "description": "Media type (or MIME type) of this attachment" - } - }, - "required": [ - "data", - "fileName", - "mimeType" - ], - "additionalProperties": false - } } }, "required": [ diff --git a/src/4.0/jsonSchemas/__generated__/v4-signed-wrapped-document.schema.json b/src/4.0/jsonSchemas/__generated__/v4-signed-wrapped-document.schema.json index 7787f7fe..4cced7a2 100644 --- a/src/4.0/jsonSchemas/__generated__/v4-signed-wrapped-document.schema.json +++ b/src/4.0/jsonSchemas/__generated__/v4-signed-wrapped-document.schema.json @@ -144,19 +144,38 @@ "format": "date-time" }, "credentialSubject": { - "anyOf": [ - { - "type": "object", - "additionalProperties": {} - }, - { + "type": "object", + "properties": { + "attachments": { "type": "array", "items": { "type": "object", - "additionalProperties": {} + "properties": { + "data": { + "type": "string", + "description": "Base64 encoding of this attachment" + }, + "filename": { + "type": "string", + "minLength": 1, + "description": "Name of this attachment, with appropriate extensions" + }, + "mimeType": { + "type": "string", + "minLength": 1, + "description": "Media type (or MIME type) of this attachment" + } + }, + "required": [ + "data", + "filename", + "mimeType" + ], + "additionalProperties": false } } - ] + }, + "additionalProperties": true }, "credentialStatus": { "anyOf": [ @@ -397,34 +416,6 @@ } ] } - }, - "attachments": { - "type": "array", - "items": { - "type": "object", - "properties": { - "data": { - "type": "string", - "description": "Base64 encoding of this attachment" - }, - "fileName": { - "type": "string", - "minLength": 1, - "description": "Name of this attachment, with appropriate extensions" - }, - "mimeType": { - "type": "string", - "minLength": 1, - "description": "Media type (or MIME type) of this attachment" - } - }, - "required": [ - "data", - "fileName", - "mimeType" - ], - "additionalProperties": false - } } }, "required": [ diff --git a/src/4.0/jsonSchemas/__generated__/v4-wrapped-document.schema.json b/src/4.0/jsonSchemas/__generated__/v4-wrapped-document.schema.json index 986b274d..7eb3e61b 100644 --- a/src/4.0/jsonSchemas/__generated__/v4-wrapped-document.schema.json +++ b/src/4.0/jsonSchemas/__generated__/v4-wrapped-document.schema.json @@ -144,19 +144,38 @@ "format": "date-time" }, "credentialSubject": { - "anyOf": [ - { - "type": "object", - "additionalProperties": {} - }, - { + "type": "object", + "properties": { + "attachments": { "type": "array", "items": { "type": "object", - "additionalProperties": {} + "properties": { + "data": { + "type": "string", + "description": "Base64 encoding of this attachment" + }, + "filename": { + "type": "string", + "minLength": 1, + "description": "Name of this attachment, with appropriate extensions" + }, + "mimeType": { + "type": "string", + "minLength": 1, + "description": "Media type (or MIME type) of this attachment" + } + }, + "required": [ + "data", + "filename", + "mimeType" + ], + "additionalProperties": false } } - ] + }, + "additionalProperties": true }, "credentialStatus": { "anyOf": [ @@ -389,34 +408,6 @@ } ] } - }, - "attachments": { - "type": "array", - "items": { - "type": "object", - "properties": { - "data": { - "type": "string", - "description": "Base64 encoding of this attachment" - }, - "fileName": { - "type": "string", - "minLength": 1, - "description": "Name of this attachment, with appropriate extensions" - }, - "mimeType": { - "type": "string", - "minLength": 1, - "description": "Media type (or MIME type) of this attachment" - } - }, - "required": [ - "data", - "fileName", - "mimeType" - ], - "additionalProperties": false - } } }, "required": [ diff --git a/src/4.0/types.ts b/src/4.0/types.ts index cc9dfafb..5db91a00 100644 --- a/src/4.0/types.ts +++ b/src/4.0/types.ts @@ -186,6 +186,13 @@ export const OscpResponderRevocation = z.object({ type: z.literal("OpenAttestationOcspResponder"), }); +// [Optional] Attachments +export const Attachment = z.object({ + data: z.string().describe("Base64 encoding of this attachment"), + filename: z.string().min(1).describe("Name of this attachment, with appropriate extensions"), + mimeType: z.string().min(1).describe("Media type (or MIME type) of this attachment"), +}); + export const RevocationStoreRevocation = z.object({ id: EthereumAddress.describe("Ethereum address of the revocation store contract"), type: z.literal("OpenAttestationRevocationStore"), @@ -217,22 +224,20 @@ export const V4Document = _W3cVerifiableCredential }), }), + credentialSubject: z + .object({ + attachments: z.array(Attachment).optional(), + }) + .passthrough() + .refine((obj) => Object.keys(obj).length > 0, { + message: "Must have at least one key", + }), + // [Optional] Credential Status credentialStatus: z.discriminatedUnion("type", [OscpResponderRevocation, RevocationStoreRevocation]).optional(), // [Optional] Render Method renderMethod: z.array(z.discriminatedUnion("type", [DecentralisedEmbeddedRenderer, SvgRenderer])).optional(), - - // [Optional] Attachments - attachments: z - .array( - z.object({ - data: z.string().describe("Base64 encoding of this attachment"), - fileName: z.string().min(1).describe("Name of this attachment, with appropriate extensions"), - mimeType: z.string().min(1).describe("Media type (or MIME type) of this attachment"), - }) - ) - .optional(), }) .strict(); diff --git a/src/4.0/wrap.ts b/src/4.0/wrap.ts index e8097abd..0aa12f57 100644 --- a/src/4.0/wrap.ts +++ b/src/4.0/wrap.ts @@ -61,7 +61,7 @@ export const wrapDocument = async ( const documentReadyForWrapping = { ...validatedRawDocument, - ...extractAndAssertAsV4DocumentProps(validatedRawDocument, ["issuer", "credentialStatus"]), + ...extractAndAssertAsV4DocumentProps(validatedRawDocument, ["issuer", "credentialStatus", "credentialSubject"]), "@context": finalContexts, type: finalTypes, } satisfies W3cVerifiableCredential; diff --git a/src/shared/@types/document.ts b/src/shared/@types/document.ts index 462a1291..e22acc25 100644 --- a/src/shared/@types/document.ts +++ b/src/shared/@types/document.ts @@ -18,15 +18,15 @@ export type WrappedDocument = T extends OpenA ? WrappedDocumentV2 : T extends OpenAttestationDocumentV3 ? WrappedDocumentV3 - : T extends V4Document - ? V4WrappedDocument + : T extends V4WrappedDocument + ? T : unknown; export type SignedWrappedDocument = T extends OpenAttestationDocumentV2 ? SignedWrappedDocumentV2 : T extends OpenAttestationDocumentV3 ? SignedWrappedDocumentV3 - : T extends V4Document - ? V4SignedWrappedDocument + : T extends V4SignedWrappedDocument + ? T : unknown; export enum SchemaId {