diff --git a/.gitignore b/.gitignore index e1632f13..1ff9e852 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ yarn.lock *.iml /public /vc-test-suite +/.vscode \ No newline at end of file diff --git a/scripts/postInstall.js b/scripts/postInstall.js index d09dd7d3..3d561818 100644 --- a/scripts/postInstall.js +++ b/scripts/postInstall.js @@ -16,11 +16,6 @@ if (fs.existsSync(quicktype) && process.env.npm_config_production !== "true") { quicktype + " -s schema -o src/__generated__/schema.3.0.ts -t OpenAttestationDocument --just-types src/3.0/schema/schema.json --no-date-times" ); - console.log('"Creating types from src/4.0/schema/schema.json"'); - execSync( - quicktype + - " -s schema -o src/__generated__/schema.4.0.ts -t OpenAttestationDocument --just-types src/4.0/schema/schema.json --no-date-times" - ); } else { console.log("Not running quicktype"); } diff --git a/scripts/publishSchema.sh b/scripts/publishSchema.sh index 6064f6c4..41680371 100755 --- a/scripts/publishSchema.sh +++ b/scripts/publishSchema.sh @@ -11,7 +11,3 @@ cp src/2.0/schema/schema.json public/2.0/schema.json # Copy 3.0 schema to public folder mkdir -p public/3.0/ cp src/3.0/schema/schema.json public/3.0/schema.json - -# Copy 4.0 schema to public folder -mkdir -p public/4.0/ -cp src/4.0/schema/schema.json public/4.0/schema.json diff --git a/src/4.0/__tests__/digest.test.ts b/src/4.0/__tests__/digest.test.ts index 612e6d14..7d8609c5 100644 --- a/src/4.0/__tests__/digest.test.ts +++ b/src/4.0/__tests__/digest.test.ts @@ -1,12 +1,12 @@ import { cloneDeep } from "lodash"; import { digestCredential } from "../digest"; -import { WrappedDocument } from "../../4.0/types"; import { obfuscateVerifiableCredential } from "../obfuscate"; import { decodeSalt } from "../salt"; import sample from "../../../test/fixtures/v4/did-signed-wrapped.json"; +import { V4WrappedDocument } from "../types"; // TODO: remove unknown -const verifiableCredential = sample as unknown as WrappedDocument; +const verifiableCredential = sample as unknown as V4WrappedDocument; // Digest will change whenever sample document is regenerated const credentialRoot = "f49be3b06f7a7eb074775ad12aae43936084c86646e3640eae18e7aeca4f7468"; diff --git a/src/4.0/__tests__/guard.test.ts b/src/4.0/__tests__/guard.test.ts new file mode 100644 index 00000000..07be75b8 --- /dev/null +++ b/src/4.0/__tests__/guard.test.ts @@ -0,0 +1,273 @@ +import { W3cVerifiableCredential, V4Document, V4WrappedDocument, V4SignedWrappedDocument } from "../types"; + +const RAW_DOCUMENT: V4Document = { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://schemata.openattestation.com/com/openattestation/4.0/alpha-context.json", + ], + type: ["VerifiableCredential", "OpenAttestationCredential"], + validFrom: "2021-03-08T12:00:00+08:00", + name: "Republic of Singapore Driving Licence", + issuer: { + id: "did:ethr:0xB26B4941941C51a4885E5B7D3A1B861E54405f90", + type: "OpenAttestationIssuer", + name: "Government Technology Agency of Singapore (GovTech)", + identityProof: { identityProofType: "DNS-DID", identifier: "example.openattestation.com" }, + }, + renderMethod: [ + { + id: "https://demo-renderer.opencerts.io", + type: "OpenAttestationEmbeddedRenderer", + templateName: "GOVTECH_DEMO", + }, + ], + credentialSubject: { + id: "urn:uuid:a013fb9d-bb03-4056-b696-05575eceaf42", + type: ["DriversLicense"], + name: "John Doe", + licenses: [ + { + class: "3", + description: "Motor cars with unladen weight <= 3000kg", + effectiveDate: "2013-05-16T00:00:00+08:00", + }, + { + class: "3A", + description: "Motor cars with unladen weight <= 3000kg", + effectiveDate: "2013-05-16T00:00:00+08:00", + }, + ], + }, +}; + +const WRAPPED: V4WrappedDocument = { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://schemata.openattestation.com/com/openattestation/4.0/alpha-context.json", + ], + name: "Republic of Singapore Driving Licence", + type: ["VerifiableCredential", "OpenAttestationCredential"], + issuer: { + id: "did:ethr:0xB26B4941941C51a4885E5B7D3A1B861E54405f90", + type: "OpenAttestationIssuer", + name: "Government Technology Agency of Singapore (GovTech)", + identityProof: { + identityProofType: "DNS-DID", + identifier: "example.openattestation.com", + }, + }, + validFrom: "2021-03-08T12:00:00+08:00", + credentialSubject: { + id: "urn:uuid:a013fb9d-bb03-4056-b696-05575eceaf42", + type: ["DriversLicense"], + name: "John Doe", + licenses: [ + { + class: "3", + description: "Motor cars with unladen weight <= 3000kg", + effectiveDate: "2013-05-16T00:00:00+08:00", + }, + { + class: "3A", + description: "Motor cars with unladen weight <= 3000kg", + effectiveDate: "2013-05-16T00:00:00+08:00", + }, + ], + }, + renderMethod: [ + { + id: "https://demo-renderer.opencerts.io", + type: "OpenAttestationEmbeddedRenderer", + templateName: "GOVTECH_DEMO", + }, + ], + proof: { + type: "OpenAttestationMerkleProofSignature2018", + proofPurpose: "assertionMethod", + targetHash: "f065a97f2ec23ff1469dedbcf9e41916e2a5e46b001e512d2d27d10ee87d8433", + proofs: [], + merkleRoot: "f065a97f2ec23ff1469dedbcf9e41916e2a5e46b001e512d2d27d10ee87d8433", + salts: + "W3sidmFsdWUiOiIzMThlMDgwY2NjZWYwZmRjMWEyNjhjN2FjOTEwNmNiYzUwNTEyMzkyNTc0MDNhZTU2MmI3YTFhNmU5YjkzY2ZjIiwicGF0aCI6IkBjb250ZXh0WzBdIn0seyJ2YWx1ZSI6IjE2MTJkMDliZjUzNGZkMmZhNTFmYjlhOWI0NTk1MzE0NTlmNzU3NDA1ZmExMjllMjY3YWEwNTUxOTlhZDY2ZjAiLCJwYXRoIjoiQGNvbnRleHRbMV0ifSx7InZhbHVlIjoiY2NlMDA0ZGM0M2NiZGY0ZmE5NjRlN2Q3ZjQ0YzQ5ZTMzNDhiMzExZmQxYjc0MzhhYTI2ZjI5YmI2OGU5MGU3NCIsInBhdGgiOiJuYW1lIn0seyJ2YWx1ZSI6IjNhZmJmZDc0Mzg1YWQ2NzM0YjE3MDgyOGJhNWUyYWI1YzM4MTY1YTBmYWNlOTJlNGUwODRmNzkyMjFlZDJkMDAiLCJwYXRoIjoidHlwZVswXSJ9LHsidmFsdWUiOiIxYTMwZTE4MTI3N2I2ZjFlZTczOTlmMTQxZWU5MjY2MDIwMWUxYTRlYmZkODEzOTJiNjgxYTEwMjMxMTUzY2Q1IiwicGF0aCI6InR5cGVbMV0ifSx7InZhbHVlIjoiMWU4ZDRlODY5ZjVkMzE1YmIxMzcyZjRhOTQxYWNjNjgxMWY2OWIwZmRiMmFkOWM3MTRjMGJiNTZhMmMzMjdmZiIsInBhdGgiOiJpc3N1ZXIuaWQifSx7InZhbHVlIjoiYzVjN2MxNzVhNmU5ZTFlZDQ1OTkxYTExYjhiNjczZmRiNWU4ZTU4NDU5OTliM2FkODYwMjM5OTdiOWUzNWEzNyIsInBhdGgiOiJpc3N1ZXIudHlwZSJ9LHsidmFsdWUiOiIyMjNkZDJhOGMwZTZhOGJlMTE2ZmUxMzliM2Q0YjRkYWZlMTBkNWJiNWEwNmVjNzE2YjNhM2JiNDZlYjI3ZDNjIiwicGF0aCI6Imlzc3Vlci5uYW1lIn0seyJ2YWx1ZSI6IjM0YzJmMTU4OWM2MTFmYWZmOTE4MTAwODk3MGJiMGY1NTc3MWQ1ODI0ODQxOGY3OGVhOWM4NTk3NTgwZDM1ZTkiLCJwYXRoIjoiaXNzdWVyLmlkZW50aXR5UHJvb2YuaWRlbnRpdHlQcm9vZlR5cGUifSx7InZhbHVlIjoiYWE0MmVhNDE3MGJhNmRhMTVmZTdiMzIyZTUxMzc3ODA5M2E2NjE3MTE1NzZmOTA2M2JmY2Y4YzdlMGEzYjRhYSIsInBhdGgiOiJpc3N1ZXIuaWRlbnRpdHlQcm9vZi5pZGVudGlmaWVyIn0seyJ2YWx1ZSI6ImU5ZjNlMTJiYTUzZDA3M2Y0ZjNiZTU2ZDUzN2ZjYjIwZmI0ZTM0YzZkM2RlZGZkNTcyMzkzODAzOTViZGM4ZTkiLCJwYXRoIjoidmFsaWRGcm9tIn0seyJ2YWx1ZSI6IjhkNGIyMDYyZmFmNzRhNmZhNGE5NmE2OTQ0NzIyMWFjNjhjZTk0MjBjOWQ2ZTFhNDFjODM2MmI1ODQwMmI5ODQiLCJwYXRoIjoiY3JlZGVudGlhbFN1YmplY3QuaWQifSx7InZhbHVlIjoiOTMxNmM0YTRhZGJiMzQwYTBjYTViNDljNTZmNWIxZWMzNjA5N2UyMjg4YzJkY2I0YmU4ZjhiY2MxNDA4NjIyYyIsInBhdGgiOiJjcmVkZW50aWFsU3ViamVjdC50eXBlWzBdIn0seyJ2YWx1ZSI6IjJlZmY0Y2JlZjFkNmQ1ZTEwOTZhNDAyNGY4ODhmM2U2YTEwMWZkYjIxZTRjZmI1ZjdmYTA4Y2ZhZmZkMzk2YjciLCJwYXRoIjoiY3JlZGVudGlhbFN1YmplY3QubmFtZSJ9LHsidmFsdWUiOiI0YjAxYjNmZWIzOTcyYTBmMDBjNTJkNDU2YmYxZWY1ZWJkODI3YjkxMDFjMzI4NTQwNzExM2NiODZhODNiY2JmIiwicGF0aCI6ImNyZWRlbnRpYWxTdWJqZWN0LmxpY2Vuc2VzWzBdLmNsYXNzIn0seyJ2YWx1ZSI6IjcxMjE4ZTA2YTNlNGM1NTc5NTIwNzdkNzQ1NTJlZTMyNDIxNzMxMjU2YTcwZDI1Nzg3ZGQyNmFkMDk1YmY5NzciLCJwYXRoIjoiY3JlZGVudGlhbFN1YmplY3QubGljZW5zZXNbMF0uZGVzY3JpcHRpb24ifSx7InZhbHVlIjoiNWM2NzdhYzgwMGQ3MDRiZjUwYzZiYjAyN2Q0OTg1YmQ2OTg0Y2VhYjExNzUzOTk0ZWQ5YTI5OTIxYjhmYWRmNyIsInBhdGgiOiJjcmVkZW50aWFsU3ViamVjdC5saWNlbnNlc1swXS5lZmZlY3RpdmVEYXRlIn0seyJ2YWx1ZSI6ImVhMjY1MDM0OTFlYjhjZjNiN2EzMTg1MGEwZTM3OTQxMjFiN2YzOTQ5OTI1NmIyNGQ3OWNkODE3MDVjNDcxOTgiLCJwYXRoIjoiY3JlZGVudGlhbFN1YmplY3QubGljZW5zZXNbMV0uY2xhc3MifSx7InZhbHVlIjoiNzlhOWZiOTVkM2U5NTlkNjFlODFhNmQwMGY3ZTlkZjAwZTAzNTMzYTYyZDZlZDkyNWI4MTIyNjY5YThkYzZkNyIsInBhdGgiOiJjcmVkZW50aWFsU3ViamVjdC5saWNlbnNlc1sxXS5kZXNjcmlwdGlvbiJ9LHsidmFsdWUiOiI0YzhkOWQ0YzRkYjRhODU4NmJkODgzZmViZmNhNTUzNmYxYmVmNDJhM2NmYTJmYTQxNWY0YWFkZjIyZmY1MTlkIiwicGF0aCI6ImNyZWRlbnRpYWxTdWJqZWN0LmxpY2Vuc2VzWzFdLmVmZmVjdGl2ZURhdGUifSx7InZhbHVlIjoiNGNkOWMwOGU3NDE2MzU0YjhiNjBlYzA1YzQzZDY3YWMwOTAzNmQ4YWRlZGYyZGVjMjIxNWU1NmU4MGM1MDg5MCIsInBhdGgiOiJyZW5kZXJNZXRob2RbMF0uaWQifSx7InZhbHVlIjoiOGRjNGY3NDllN2JhZmZmZWJmM2FmOGY4ZDJjYjUwNTAzZGFmOGZhZTVkM2Q3YjhjNTNhZDM1NTFiMWM1NDI0MyIsInBhdGgiOiJyZW5kZXJNZXRob2RbMF0udHlwZSJ9LHsidmFsdWUiOiJkZjY0NmI2YjYwMTJmZWQxYzE5N2E5MjhjNGJjZTVhOWJlNTc0YjU4YmFhYjZkN2E4OTAwZDBiZDdkYjg4N2IyIiwicGF0aCI6InJlbmRlck1ldGhvZFswXS50ZW1wbGF0ZU5hbWUifV0=", + privacy: { + obfuscated: [], + }, + }, +}; + +const SIGNED_WRAPPED: V4SignedWrappedDocument = { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://schemata.openattestation.com/com/openattestation/4.0/alpha-context.json", + ], + type: ["VerifiableCredential", "OpenAttestationCredential"], + validFrom: "2021-03-08T12:00:00+08:00", + name: "Republic of Singapore Driving Licence", + issuer: { + id: "did:ethr:0xB26B4941941C51a4885E5B7D3A1B861E54405f90", + type: "OpenAttestationIssuer", + name: "Government Technology Agency of Singapore (GovTech)", + identityProof: { identityProofType: "DNS-DID", identifier: "example.openattestation.com" }, + }, + renderMethod: [ + { + id: "https://demo-renderer.opencerts.io", + type: "OpenAttestationEmbeddedRenderer", + templateName: "GOVTECH_DEMO", + }, + ], + credentialSubject: { + id: "urn:uuid:a013fb9d-bb03-4056-b696-05575eceaf42", + type: ["DriversLicense"], + name: "John Doe", + licenses: [ + { + class: "3", + description: "Motor cars with unladen weight <= 3000kg", + effectiveDate: "2013-05-16T00:00:00+08:00", + }, + { + class: "3A", + description: "Motor cars with unladen weight <= 3000kg", + effectiveDate: "2013-05-16T00:00:00+08:00", + }, + ], + }, + proof: { + type: "OpenAttestationMerkleProofSignature2018", + proofPurpose: "assertionMethod", + targetHash: "f49be3b06f7a7eb074775ad12aae43936084c86646e3640eae18e7aeca4f7468", + proofs: [], + merkleRoot: "f49be3b06f7a7eb074775ad12aae43936084c86646e3640eae18e7aeca4f7468", + salts: + "W3sidmFsdWUiOiJiMzAzYWIyNmEyNjI1MGQ2YWNkMmI1Yzk0NmY3NDdhMTdkOTRlZTZmZjVhNDE2Mjk5OTQ4MDA0Y2EwNWE3MTBiIiwicGF0aCI6IkBjb250ZXh0WzBdIn0seyJ2YWx1ZSI6IjBiNGRmOGVhZDkwMzMwNjMyYjhkNWNkYWVjOGRkZTI0NzQ0NjFkMTE2NzgwNzU4OTRiMmUwY2JmZDQ1M2ZlNTUiLCJwYXRoIjoiQGNvbnRleHRbMV0ifSx7InZhbHVlIjoiZGE2NDE1MGViNzViYzY2NzdkYTkxYTFhMTk3YWUzMmYwMDBlN2M3OTEyN2Q2M2EzMWRiMzg5MWQ3YjQxNTYwOCIsInBhdGgiOiJ0eXBlWzBdIn0seyJ2YWx1ZSI6Ijc4MDhkZTQyZjdkMWZkNzE4ZDFhMmRhMDUzZDA4ZmQ2Y2JjOTliZDU3YzhmZTQyN2MxNzllZWQ1YmRlY2IyMTQiLCJwYXRoIjoidHlwZVsxXSJ9LHsidmFsdWUiOiI2ZDM1ODU2ZTRkMjg1MjllZmIyZjE2ZmRjMDFlMWE5MjQ5NDlhYzE5NzkwYjAxNmJkM2EyOGUxNjg0Mjc4NzNlIiwicGF0aCI6InZhbGlkRnJvbSJ9LHsidmFsdWUiOiI2NTQ5ZTkzOGU3MjgxMTM3NzZkOTViNTdhOTg1ZTY0MmFmYmNhZWMzOTg3ODhlZGIzNmZiZDMwZDY4ODMxZWRhIiwicGF0aCI6Im5hbWUifSx7InZhbHVlIjoiZDVhOTMxMTk5ZjVmZjBiM2MxZjQ3NjhlODNiNWNiODVmYmM0Y2Q4MDY2YmM3OTlkZTVlYWFmODViOWNhNjM4ZCIsInBhdGgiOiJpc3N1ZXIuaWQifSx7InZhbHVlIjoiMTM3YmU4NzU2ZmU4YWUxMGI4ODI1ODQ5N2QyZjBkYmZlZDcwN2U5YTZlMzE1NDJiYjBiZGE3YjFhOTNhNmJmZCIsInBhdGgiOiJpc3N1ZXIudHlwZSJ9LHsidmFsdWUiOiI4NGUyOGZhMWM0MTQyYjg5M2ZiMTNkZjJjYTQyMTkxOGQwODEzNzNlYjc2ZTdiOWU5YTUwMzBmYzJhODBhNWZjIiwicGF0aCI6Imlzc3Vlci5uYW1lIn0seyJ2YWx1ZSI6IjdmYTQ0MDRlZTdhNTJjNGI3YWM3N2U1ODIwMDc2OTQ2YTU3MzNmMGJlNjIzOWJiZDUwNWZhYTY5MzVjNjkwMzUiLCJwYXRoIjoiaXNzdWVyLmlkZW50aXR5UHJvb2YuaWRlbnRpdHlQcm9vZlR5cGUifSx7InZhbHVlIjoiYzcxMGRjZDdjMjNmODhhYWY2ZDJjM2Q0ZDgxYzg3NzkzODcxYjI1NjI4MjJjN2YwNjliZmM4ZTA2ODdjNzRhYSIsInBhdGgiOiJpc3N1ZXIuaWRlbnRpdHlQcm9vZi5pZGVudGlmaWVyIn0seyJ2YWx1ZSI6ImI0NWU5ZjA5Mzg0NTVjMWY1YjhiMzJmN2E1MWZhMDkzOWYyYjg1ZmM4YWFkZDQzMzU2NjdlNzFjYzY2OGMwYmMiLCJwYXRoIjoicmVuZGVyTWV0aG9kWzBdLmlkIn0seyJ2YWx1ZSI6IjcyNDE3MDVlNTY5YjdmNjVlMmM1ZWZlMmYzZjIwYWVmMDdlMTdhZTIzNzI5NWRkYzJhYWM1MDAxZjI0YjAzNDkiLCJwYXRoIjoicmVuZGVyTWV0aG9kWzBdLnR5cGUifSx7InZhbHVlIjoiYTcyZDdlMmY3NTE4ZGZkYWY1N2JmMzI1Njg0YTNjY2Y3YmI2MDFkNjI0NGE0YzZmNDVhMzJmOTY5NjBiOGI1YiIsInBhdGgiOiJyZW5kZXJNZXRob2RbMF0udGVtcGxhdGVOYW1lIn0seyJ2YWx1ZSI6IjNhODI4MTc3MDQxZDI5MDk4NjkzNjhhZjQ3OGE2ZjJkYjU2MWIyYWQyZTY1YmYyNzlkNjE2MzAyNjc1MWJhMTciLCJwYXRoIjoiY3JlZGVudGlhbFN1YmplY3QuaWQifSx7InZhbHVlIjoiODgyMWY4YzI3OTlkMDVjMmFmODBlZGZmYTc5ODQxMTFiZWM4Y2Y2ZTU1YWZiOWIxMWY3ZGE0YjU4NDE1MmRiYyIsInBhdGgiOiJjcmVkZW50aWFsU3ViamVjdC50eXBlWzBdIn0seyJ2YWx1ZSI6IjBhMjUxOGExNzExMmE3YmY5OTY0M2FiYjc0YWI1ZTllZGViYmEzYzdlMGYzZDM5M2M5MGJjMGZiNDUzOWRmZmYiLCJwYXRoIjoiY3JlZGVudGlhbFN1YmplY3QubmFtZSJ9LHsidmFsdWUiOiIzMmZiN2JjM2NiZjFjMmZmYjcxYjQ2N2EzNWYyNTFmNGFiNzZkODA0MWUxYTNlMmQ4NzgwODc1NDBhZTQxYzIzIiwicGF0aCI6ImNyZWRlbnRpYWxTdWJqZWN0LmxpY2Vuc2VzWzBdLmNsYXNzIn0seyJ2YWx1ZSI6ImZhNTRjNzRkMTYzNzMwZTNlOWRmNzYyMGRhMTllYjcxNjNjNGQyMDNiMDZhYTU0NzZmNzBmMzRiMDMzN2Y4MTIiLCJwYXRoIjoiY3JlZGVudGlhbFN1YmplY3QubGljZW5zZXNbMF0uZGVzY3JpcHRpb24ifSx7InZhbHVlIjoiNmUxMWE0MjkyMjEyNDdmNTJiOTU0ZjYxYzc2MTI3ZGYzZWYzY2E4NTA0ZmZlNGUyZDk1NWFjOWNmMjBmNDU3NiIsInBhdGgiOiJjcmVkZW50aWFsU3ViamVjdC5saWNlbnNlc1swXS5lZmZlY3RpdmVEYXRlIn0seyJ2YWx1ZSI6ImU3M2I3ZTNiM2E3OTA4MmQyNWU0OTA5YjU4MzdkZGFjNzRmZDA4ZjVlNjljOTcxZDJlYmViZGY5OWEyN2Q1MmYiLCJwYXRoIjoiY3JlZGVudGlhbFN1YmplY3QubGljZW5zZXNbMV0uY2xhc3MifSx7InZhbHVlIjoiZjk1MzcxNWJjMzNlMTAzNzBjNGQ5MWUwMTZmN2M4MThjZWRjMGI1ZGRkMmZiODhmNGNiNGIzZTlhMzMwN2ZiMyIsInBhdGgiOiJjcmVkZW50aWFsU3ViamVjdC5saWNlbnNlc1sxXS5kZXNjcmlwdGlvbiJ9LHsidmFsdWUiOiJjM2NkZWNiZjNkOTgzZmUxNDRhNmI5NTJkZTY4ZmExYjUwZjUxOTQwZDgzMjY3MjQ5MTg1YWNmNTFiNmI2MDQ5IiwicGF0aCI6ImNyZWRlbnRpYWxTdWJqZWN0LmxpY2Vuc2VzWzFdLmVmZmVjdGl2ZURhdGUifV0=", + privacy: { obfuscated: [] }, + key: "did:ethr:0xe93502ce1A52C1c0e99A2eB6666263EA53dB0a5e#controller", + signature: + "0x170fbb2d5916a7b3a4863feb8b705f5560c0b42311b164b2da32e682a8633b6f2c332f963db8267ab9a1c3be16ba1091388ed70e6e2a4ec240f5c0865557c6aa1c", + }, +}; + +describe("v4 guard", () => { + describe("given a raw document", () => { + test("should pass w3c vc validation without removal of any data", () => { + const w3cVerifiableCredential: W3cVerifiableCredential = RAW_DOCUMENT; + const results = W3cVerifiableCredential.parse(w3cVerifiableCredential); + expect(results).toEqual(RAW_DOCUMENT); + }); + + test("should pass document validation without removal of any data", () => { + const results = V4Document.parse(RAW_DOCUMENT); + expect(results).toEqual(RAW_DOCUMENT); + }); + + test("should fail wrapped document validation", () => { + const results = V4WrappedDocument.safeParse(RAW_DOCUMENT); + expect(results.success).toBe(false); + expect((results as { error: unknown }).error).toMatchInlineSnapshot(` + [ZodError: [ + { + "code": "invalid_type", + "expected": "object", + "received": "undefined", + "path": [ + "proof" + ], + "message": "Required" + } + ]] + `); + }); + + test("should fail signed wrapped document validation", () => { + const results = V4SignedWrappedDocument.safeParse(RAW_DOCUMENT); + expect(results.success).toBe(false); + expect((results as { error: unknown }).error).toMatchInlineSnapshot(` + [ZodError: [ + { + "code": "invalid_type", + "expected": "object", + "received": "undefined", + "path": [ + "proof" + ], + "message": "Required" + } + ]] + `); + }); + }); + + describe("given a wrapped document", () => { + test("should pass w3c vc validation without removal of any data", () => { + const w3cVerifiableCredential: W3cVerifiableCredential = WRAPPED; + const results = W3cVerifiableCredential.parse(w3cVerifiableCredential); + expect(results).toEqual(WRAPPED); + }); + + test("should pass document validation without removal of any data", () => { + const v4Document: V4Document = WRAPPED; + const results = V4Document.parse(v4Document); + expect(results).toEqual(WRAPPED); + }); + + test("should pass wrapped document validation without removal of any data", () => { + const results = V4WrappedDocument.parse(WRAPPED); + expect(results).toEqual(WRAPPED); + }); + + test("should fail signed wrapped document validation", () => { + const results = V4SignedWrappedDocument.safeParse(WRAPPED); + expect(results.success).toBe(false); + expect((results as { error: unknown }).error).toMatchInlineSnapshot(` + [ZodError: [ + { + "code": "invalid_type", + "expected": "string", + "received": "undefined", + "path": [ + "proof", + "key" + ], + "message": "Required" + }, + { + "code": "invalid_type", + "expected": "string", + "received": "undefined", + "path": [ + "proof", + "signature" + ], + "message": "Required" + } + ]] + `); + }); + }); + + describe("given a signed wrapped document", () => { + test("should pass w3c vc validation without removal of any data", () => { + const w3cVerifiableCredential: W3cVerifiableCredential = SIGNED_WRAPPED; + const results = W3cVerifiableCredential.parse(w3cVerifiableCredential); + expect(results).toEqual(SIGNED_WRAPPED); + }); + + test("should pass document validation without removal of any data", () => { + const v4Document: V4Document = SIGNED_WRAPPED; + const results = V4Document.parse(v4Document); + expect(results).toEqual(SIGNED_WRAPPED); + }); + + test("should pass wrapped document validation without removal of any data", () => { + const v4WrappedDocument: V4WrappedDocument = SIGNED_WRAPPED; + const results = V4WrappedDocument.parse(v4WrappedDocument); + expect(results).toEqual(SIGNED_WRAPPED); + }); + + test("should pass signed wrapped document validation without removal of any data", () => { + const results = V4SignedWrappedDocument.parse(SIGNED_WRAPPED); + expect(results).toEqual(SIGNED_WRAPPED); + }); + }); +}); diff --git a/src/4.0/diagnose.ts b/src/4.0/diagnose.ts new file mode 100644 index 00000000..068b1de6 --- /dev/null +++ b/src/4.0/diagnose.ts @@ -0,0 +1,29 @@ +import { Diagnose } from "src/shared/utils/@types/diagnose"; +import { V4WrappedDocument, V4SignedWrappedDocument, V4Document } from "./types"; + +export const v4Diagnose: Diagnose = ({ document, kind, debug }) => { + let Validator: typeof V4Document | typeof V4WrappedDocument | typeof V4SignedWrappedDocument = V4Document; + if (kind === "raw") { + Validator = V4Document; + } else if (kind === "wrapped") { + Validator = V4WrappedDocument; + } else { + Validator = V4SignedWrappedDocument; + } + + const results = Validator.safeParse(document); + + if (results.success) { + return []; + } + + return results.error.errors.map(({ code, message, path }) => { + const errorMessage = `${code}: ${message} at ${path.join(".")}`; + if (debug) { + console.debug(errorMessage); + } + return { + message: errorMessage, + }; + }); +}; diff --git a/src/4.0/digest.ts b/src/4.0/digest.ts index 4cfc7359..1ce553b8 100644 --- a/src/4.0/digest.ts +++ b/src/4.0/digest.ts @@ -1,9 +1,8 @@ import { get, sortBy } from "lodash"; import { keccak256 } from "js-sha3"; -import { Salt } from "./types"; -import { OpenAttestationDocument } from "../__generated__/schema.4.0"; +import { V4Document, Salt } from "./types"; -export const digestCredential = (document: OpenAttestationDocument, salts: Salt[], obfuscatedData: string[]) => { +export const digestCredential = (document: V4Document, salts: Salt[], obfuscatedData: string[]) => { // Prepare array of hashes from visible data const hashedUnhashedDataArray = salts // Explictly allow falsy values (e.g. false, 0, etc.) as they can exist in the document diff --git a/src/4.0/obfuscate.ts b/src/4.0/obfuscate.ts index 423c352a..56456869 100644 --- a/src/4.0/obfuscate.ts +++ b/src/4.0/obfuscate.ts @@ -1,11 +1,10 @@ -import { OpenAttestationDocument } from "../__generated__/schema.4.0"; import { toBuffer } from "../shared/utils"; -import { WrappedDocument } from "./types"; import { cloneDeep, get, unset, pick } from "lodash"; import { decodeSalt, encodeSalt } from "./salt"; import { traverseAndFlatten } from "./traverseAndFlatten"; +import { V4Document, V4WrappedDocument } from "./types"; -const obfuscate = (_data: WrappedDocument, fields: string[] | string) => { +const obfuscate = (_data: V4WrappedDocument, fields: string[] | string) => { const data = cloneDeep(_data); // Prevents alteration of original data const fieldsAsArray = ([] as string[]).concat(fields); @@ -37,10 +36,10 @@ const obfuscate = (_data: WrappedDocument, fields: stri }; }; -export const obfuscateVerifiableCredential = ( - document: WrappedDocument, +export const obfuscateVerifiableCredential = ( + document: V4WrappedDocument, fields: string[] | string -): WrappedDocument => { +): V4WrappedDocument => { const { data, obfuscatedData } = obfuscate(document, fields); const currentObfuscatedData = document.proof.privacy.obfuscated; const newObfuscatedData = currentObfuscatedData.concat(obfuscatedData); @@ -53,5 +52,5 @@ export const obfuscateVerifiableCredential = ( obfuscated: newObfuscatedData, }, }, - }; + } satisfies V4WrappedDocument as V4WrappedDocument; }; diff --git a/src/4.0/schema/schema.json b/src/4.0/schema/schema.json deleted file mode 100644 index aca24d0e..00000000 --- a/src/4.0/schema/schema.json +++ /dev/null @@ -1,183 +0,0 @@ -{ - "title": "OpenAttestation v4.0 Schema", - "$id": "https://schemata.openattestation.com/com/openattestation/4.0/alpha-schema.json", - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "definitions": { - "@context": { - "description": "Used to define the short-hand names that are used throughout a JSON-LD document.", - "type": ["object", "string", "array", "null"] - }, - "id": { - "description": "Used to uniquely identify things that are being described in the document with IRIs or blank node identifiers.", - "type": "string", - "format": "uri" - }, - "type": { - "description": "Used to set the data type of a node or typed value.", - "type": ["string", "null", "array"] - }, - "credentialSchema": { - "type": "object", - "properties": { - "id": { "$ref": "#/definitions/id" }, - "type": { "$ref": "#/definitions/type" } - }, - "description": "A data schema that provide verifiers with enough information to determine whether the provided data conforms to the provided schema(s). More information in https://www.w3.org/TR/vc-data-model-2.0/#data-schemas" - }, - "renderMethod": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uri", - "description": "URL of a decentralised renderer to render this document", - "examples": ["https://demo-renderer.openattestation.com"] - }, - "type": { "$ref": "#/definitions/type", "examples": ["OpenAttestationEmbeddedRenderer"] }, - "templateName": { - "type": "string", - "description": "Template name to be use by template renderer to determine the template to use" - } - }, - "required": ["id", "templateName"] - }, - "credentialSubject": { - "type": "object", - "properties": { - "id": { "$ref": "#/definitions/id" }, - "type": { "$ref": "#/definitions/type" } - }, - "description": "A verifiable credential contains claims about one or more subjects. More information in https://www.w3.org/TR/vc-data-model-2.0/#credential-subject" - }, - "termsOfUse": { - "type": "object", - "properties": { - "type": { "$ref": "#/definitions/type" } - } - }, - "evidence": { - "type": "object", - "properties": { - "type": { "$ref": "#/definitions/type" } - } - }, - "proof": { - "type": "object", - "properties": { - "type": { "$ref": "#/definitions/type", "examples": ["OpenAttestationMerkleProofSignature2018"] }, - "proofPurpose": { "type": "string", "enum": ["assertionMethod"] }, - "targetHash": { "type": "string" }, - "proofs": { "type": "array", "items": { "type": "string" } }, - "merkleRoot": { "type": "string" }, - "salts": { "type": "string" }, - "privacy": { - "type": "object", - "properties": { "obfuscated": { "type": "array", "items": { "type": "string" } } } - }, - "key": { "type": "string" }, - "signature": { "type": "string" } - }, - "additionalProperties": false - } - }, - "properties": { - "@context": { "$ref": "#/definitions/@context" }, - "id": { "$ref": "#/definitions/id" }, - "type": { "$ref": "#/definitions/type", "examples": ["VerifiableCredential", "OpenAttestationCredential"] }, - "credentialSchema": { - "anyOf": [ - { "$ref": "#/definitions/credentialSchema" }, - { "type": "array", "items": { "$ref": "#/definitions/credentialSchema" } } - ] - }, - "validFrom": { - "type": "string", - "format": "date-time", - "description": "The date and time when this credential becomes valid", - "examples": ["2024-03-08T12:00:00+08:00"] - }, - "validUntil": { - "type": "string", - "format": "date-time", - "description": "The date and time when this credential expires", - "examples": ["2024-03-27T12:00:00+08:00"] - }, - "name": { - "type": "string", - "description": "Human readable name of this credential" - }, - "issuer": { - "oneOf": [ - { "type": "string" }, - { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uri", - "description": "URI when dereferenced, results in a document containing machine-readable information about the issuer that can be used to verify the information expressed in the credential. More information in https://www.w3.org/TR/vc-data-model/#issuer" - }, - "type": { "$ref": "#/definitions/type", "examples": ["OpenAttestationIssuer"] }, - "name": { - "type": "string", - "description": "Issuer's name" - }, - "identityProof": { - "type": "object", - "properties": { - "identityProofType": { - "type": "string", - "enum": ["DNS-TXT", "DNS-DID", "DID"] - }, - "identifier": { - "type": "string", - "description": "Identifier to be shown to end user upon verifying the identity" - } - }, - "required": ["identityProofType", "identifier"], - "additionalProperties": false - } - }, - "required": ["id"] - } - ] - }, - "credentialStatus": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uri", - "description": "URI to the status of the credential as explained by https://www.w3.org/TR/vc-data-model/#status", - "examples": ["https://ocsp-sandbox.openattestation.com"] - }, - "type": { "$ref": "#/definitions/type", "examples": ["OpenAttestationOcspResponder"] } - }, - "additionalProperties": false - }, - "renderMethod": { "type": "array", "items": { "$ref": "#/definitions/renderMethod" } }, - "credentialSubject": { - "anyOf": [ - { "$ref": "#/definitions/credentialSubject" }, - { "type": "array", "items": { "$ref": "#/definitions/credentialSubject" } } - ] - }, - "termsOfUse": { - "anyOf": [ - { "$ref": "#/definitions/termsOfUse" }, - { "type": "array", "items": { "$ref": "#/definitions/termsOfUse" } } - ] - }, - "evidence": { - "anyOf": [ - { "$ref": "#/definitions/evidence" }, - { "type": "array", "items": { "$ref": "#/definitions/evidence" } } - ] - }, - "proof": { - "anyOf": [{ "$ref": "#/definitions/proof" }, { "type": "array", "items": { "$ref": "#/definitions/proof" } }] - } - }, - "required": ["@context", "type", "issuer", "credentialSubject"] -} diff --git a/src/4.0/schema/schema.test.ts b/src/4.0/schema/schema.test.ts deleted file mode 100644 index ee1960b5..00000000 --- a/src/4.0/schema/schema.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* eslint-disable jest/no-try-expect,jest/no-conditional-expect */ -import { cloneDeep } from "lodash"; -import { _unsafe_use_it_at_your_own_risk_v4_alpha_wrapDocument as wrapDocumentV4 } from "../../index"; -import sample from "../../../test/fixtures/v4/did-raw.json"; -import { ContextUrl } from "../../shared/@types/document"; -import { OpenAttestationDocument } from "../../__generated__/schema.4.0"; - -const sampleVc = sample as OpenAttestationDocument; - -// eslint-disable-next-line jest/no-disabled-tests -describe("schema/4.0", () => { - it("should be valid with sample document", async () => { - const document = cloneDeep(sampleVc); - const wrappedDocument = await wrapDocumentV4(document); - expect(wrappedDocument["type"]).toStrictEqual(["VerifiableCredential", "OpenAttestationCredential"]); - expect(wrappedDocument["proof"]["type"]).toStrictEqual("OpenAttestationMerkleProofSignature2018"); - }); - - it("should be valid when adding any additional data", async () => { - const document = { ...cloneDeep(sampleVc), key1: "some" }; - const wrappedDocument = await wrapDocumentV4(document); - expect(wrappedDocument["key1"]).toStrictEqual("some"); - expect(wrappedDocument["type"]).toStrictEqual(["VerifiableCredential", "OpenAttestationCredential"]); - expect(wrappedDocument["proof"]["type"]).toStrictEqual("OpenAttestationMerkleProofSignature2018"); - }); - - describe("@context", () => { - it("should be invalid if @context contains one invalid URI", async () => { - expect.assertions(1); - const document = { ...cloneDeep(sampleVc), "@context": [ContextUrl.v2_vc, "bad string"] }; - await expect(wrapDocumentV4(document)).rejects.toMatchInlineSnapshot( - `[Error: Unable to interpret @context: {"name":"jsonld.InvalidUrl","details":{"code":"loading remote context failed","url":"bad string","cause":{}}}]` - ); - }); - }); -}); diff --git a/src/4.0/sign.ts b/src/4.0/sign.ts index 0519604e..3d3e6f3e 100644 --- a/src/4.0/sign.ts +++ b/src/4.0/sign.ts @@ -1,18 +1,18 @@ -import { OpenAttestationDocument, WrappedDocument, SignedWrappedDocument, SignedWrappedProof } from "./types"; import { sign } from "../shared/signer"; import { SigningKey, SUPPORTED_SIGNING_ALGORITHM } from "../shared/@types/sign"; import { isSignedWrappedV4Document } from "../shared/utils"; import { ethers } from "ethers"; +import { V4Document, V4WrappedDocument, V4SignedWrappedDocument } from "./types"; -export const signDocument = async ( - document: SignedWrappedDocument | WrappedDocument, +export const signDocument = async ( + document: V4SignedWrappedDocument | V4WrappedDocument, algorithm: SUPPORTED_SIGNING_ALGORITHM, keyOrSigner: SigningKey | ethers.Signer -): Promise> => { +): Promise> => { if (isSignedWrappedV4Document(document)) throw new Error("Document has been signed"); const merkleRoot = `0x${document.proof.merkleRoot}`; const signature = await sign(algorithm, merkleRoot, keyOrSigner); - const proof: SignedWrappedProof = { + const proof: V4SignedWrappedDocument["proof"] = { ...document.proof, key: SigningKey.guard(keyOrSigner) ? keyOrSigner.public : `did:ethr:${await keyOrSigner.getAddress()}#controller`, signature, diff --git a/src/4.0/types.ts b/src/4.0/types.ts index 424fb973..8ed7eaed 100644 --- a/src/4.0/types.ts +++ b/src/4.0/types.ts @@ -1,55 +1,265 @@ -// types generated by quicktype during postinstall phase -import { OpenAttestationDocument as OpenAttestationDocumentV4, ProofPurpose } from "../__generated__/schema.4.0"; -import { OpenAttestationHexString, SignatureAlgorithm } from "../shared/@types/document"; -import { Array, Literal, Record, Static, String, Union } from "runtypes"; - -export interface Salt { - value: string; - path: string; -} - -export const PrivacyObfuscation = Record({ - obfuscated: Array(OpenAttestationHexString), +import z from "zod"; +import { ContextUrl, ContextType } from "../shared/@types/document"; + +const BASE_TYPE = "VerifiableCredential" as const; + +// Custom URI validation function +const URI_REGEX = + /^(?=.)(?!https?:\/(?:$|[^/]))(?!https?:\/\/\/)(?!https?:[^/])(?:[a-zA-Z][a-zA-Z\d+-\.]*:(?:(?:\/\/(?:[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=:]*@)?(?:\[(?:(?:(?:[\dA-Fa-f]{1,4}:){6}(?:[\dA-Fa-f]{1,4}:[\dA-Fa-f]{1,4}|(?:(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|::(?:[\dA-Fa-f]{1,4}:){5}(?:[\dA-Fa-f]{1,4}:[\dA-Fa-f]{1,4}|(?:(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(?:[\dA-Fa-f]{1,4})?::(?:[\dA-Fa-f]{1,4}:){4}(?:[\dA-Fa-f]{1,4}:[\dA-Fa-f]{1,4}|(?:(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(?:(?:[\dA-Fa-f]{1,4}:){0,1}[\dA-Fa-f]{1,4})?::(?:[\dA-Fa-f]{1,4}:){3}(?:[\dA-Fa-f]{1,4}:[\dA-Fa-f]{1,4}|(?:(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(?:(?:[\dA-Fa-f]{1,4}:){0,2}[\dA-Fa-f]{1,4})?::(?:[\dA-Fa-f]{1,4}:){2}(?:[\dA-Fa-f]{1,4}:[\dA-Fa-f]{1,4}|(?:(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(?:(?:[\dA-Fa-f]{1,4}:){0,3}[\dA-Fa-f]{1,4})?::[\dA-Fa-f]{1,4}:(?:[\dA-Fa-f]{1,4}:[\dA-Fa-f]{1,4}|(?:(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(?:(?:[\dA-Fa-f]{1,4}:){0,4}[\dA-Fa-f]{1,4})?::(?:[\dA-Fa-f]{1,4}:[\dA-Fa-f]{1,4}|(?:(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(?:(?:[\dA-Fa-f]{1,4}:){0,5}[\dA-Fa-f]{1,4})?::[\dA-Fa-f]{1,4}|(?:(?:[\dA-Fa-f]{1,4}:){0,6}[\dA-Fa-f]{1,4})?::)|v[\dA-Fa-f]+\.[\w-\.~!\$&'\(\)\*\+,;=:]+)\]|(?:(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])|[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=]{1,255})(?::\d*)?(?:\/[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=:@]*)*)|\/(?:[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=:@]+(?:\/[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=:@]*)*)?|[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=:@]+(?:\/[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=:@]*)*|(?:\/\/\/[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=:@]*(?:\/[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=:@]*)*)))(?:\?[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=:@\/\?]*(?=#|$))?(?:#[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=:@\/\?]*)?$/; +const Uri = z.string().regex(URI_REGEX, { message: "Invalid URI" }); + +export const W3cVerifiableCredential = z.object({ + "@context": z.union([ + z.record(z.any()), + z.string(), + // If array: First item must be baseContext, while remaining items can be string or object + z.tuple([z.literal(ContextUrl.v2_vc)]).rest(z.union([z.string(), z.record(z.any())])), + ]), + + // [Optional] + name: z.string().optional(), + + // [Optional] If string: Must match uri pattern + id: Uri.optional(), + + type: z.union([ + z.string(), + // If array: Must have VerifiableCredential, while remaining items can be any string + z.array(z.string()).refine((types) => types.includes(BASE_TYPE), { message: `Type must include ${BASE_TYPE}` }), + ]), + + credentialSchema: z + .union([ + // If object: Must have id match uri pattern and type defined + z + .object({ + id: Uri, + type: z.string(), + }) + .passthrough(), + // If array: Every object must have id match uri pattern and type defined + z.array( + z + .object({ + id: Uri, + type: z.string(), + }) + .passthrough() + ), + ]) + .optional(), + + issuer: z.union([ + // If string: Must match uri pattern + Uri, + // If object: Must have id match uri pattern + z + .object({ + id: Uri, + }) + .passthrough(), + ]), + + validFrom: z.string().datetime({ offset: true }).optional(), + + validUntil: z.string().datetime({ offset: true }).optional(), + + credentialSubject: z.union([ + // If object: Cannot be empty (i.e. minimum 1 key) + z.record(z.any()).refine((obj) => Object.keys(obj).length > 0, { + message: "Must have at least one key", + }), + // If array: Every object cannot be empty (i.e. minimum 1 key) + z.array( + z.record(z.any()).refine((obj) => Object.keys(obj).length > 0, { + message: "Must have at least one key", + }) + ), + ]), + + credentialStatus: z + .object({ + // If id is present, id must match uri pattern (credentialStatus.id is optional and can be undefined) + id: Uri.optional(), + // Must have type defined + type: z.string(), + }) + .passthrough() + .optional(), + + // [Optional] This is at risk of being removed from the w3c spec + renderMethod: z.any().optional(), + + termsOfUse: z + .union([ + // If object: Must have type defined. If id is present, id must match uri pattern (termsOfUse.id is optional and can be undefined) + z + .object({ + id: Uri.optional(), + type: z.string(), + }) + .passthrough(), + // If array: Every object must have type defined. If id is present, id must match uri pattern (termsOfUse.id is optional and can be undefined) + z.array( + z + .object({ + id: Uri.optional(), + type: z.string(), + }) + .passthrough() + ), + ]) + .optional(), + + evidence: z + .union([ + // If object: Must have type defined. If id is present, id must match uri pattern (evidence.id is optional and can be undefined) + z + .object({ + id: Uri.optional(), + type: z.string(), + }) + .passthrough(), + // If array: Every object must have type defined. If id is present, id must match uri pattern (evidence.id is optional and can be undefined) + z.array( + z + .object({ + id: Uri.optional(), + type: z.string(), + }) + .passthrough() + ), + ]) + .optional(), + + proof: z + .union([ + // If object: Must have type defined + z + .object({ + type: z.string(), + }) + .passthrough(), + // If array: Every object must have type defined + z.array( + z + .object({ + type: z.string(), + }) + .passthrough() + ), + ]) + .optional(), }); -export type PrivacyObfuscation = Static; - -export const WrappedProof = Record({ - type: SignatureAlgorithm, - /* FIXME: No straightforward way to represent enum in runtypes */ - // proofPurpose: runtypesFromEnum(ProofPurpose), - // proofPurpose: ProofPurpose, - proofPurpose: Union(Literal(ProofPurpose.AssertionMethod)), - targetHash: String, - proofs: Array(String), - merkleRoot: String, - salts: String, - privacy: PrivacyObfuscation, + +const IdentityProofType = z.union([z.literal("DNS-TXT"), z.literal("DNS-DID"), z.literal("DID")]); +const Salt = z.object({ value: z.string(), path: z.string() }); + +export const V4Document = W3cVerifiableCredential.extend({ + "@context": z + + // Must be an array that starts with [baseContext, v4Context, ...] + .tuple([z.literal(ContextUrl.v2_vc), z.literal(ContextUrl.v4_alpha)]) + // Remaining items can be string or object + .rest(z.union([z.string(), z.record(z.any())])), + + type: z + // Must be an array that starts with [VerifiableCredential, OpenAttestationCredential, ...] + .tuple([z.literal(ContextType.BaseContext), z.literal(ContextType.V4AlphaContext)]) + // Remaining items can be string + .rest(z.string()), + + issuer: z.object({ + // Must have id match uri pattern + id: Uri, + type: z.literal("OpenAttestationIssuer"), + name: z.string(), + identityProof: z.object({ + identityProofType: IdentityProofType, + identifier: z.string(), + }), + }), + + // [Optional] OCSP Revocation + credentialStatus: z + .object({ + // Must have id match url pattern (OCSP endpoint) + id: z.string().url(), + type: z.literal("OpenAttestationOcspResponder"), + }) + .optional(), + + // [Optional] Render Method + renderMethod: z + .array( + z.discriminatedUnion("type", [ + /* OA Decentralised Embedded Renderer */ + z.object({ + // Must have id match url pattern + id: z.string().url(), + type: z.literal("OpenAttestationEmbeddedRenderer"), + templateName: z.string(), + }), + /* SVG Renderer (URL or Embedded) */ + z.object({ + // Must have id match url pattern or embeded SVG string + id: z.union([z.string(), z.string().url()]), + type: z.literal("SvgRenderingTemplate2023"), + name: z.string(), + digestMultibase: z.string(), + }), + ]) + ) + .optional(), +}).strict(); + +const WrappedProof = z.object({ + type: z.literal("OpenAttestationMerkleProofSignature2018"), + proofPurpose: z.literal("assertionMethod"), + targetHash: z.string(), + proofs: z.array(z.string()), + merkleRoot: z.string(), + salts: z.string(), + privacy: z.object({ obfuscated: z.array(z.string()) }), }); -export type WrappedProof = Static; - -export const WrappedProofStrict = WrappedProof.And( - Record({ - targetHash: OpenAttestationHexString, - merkleRoot: OpenAttestationHexString, - proofs: Array(OpenAttestationHexString), - }) -); -export type WrappedProofStrict = Static; - -export const SignedWrappedProof = WrappedProof.And( - Record({ - key: String, - signature: String, - }) -); -export type SignedWrappedProof = Static; - -export type WrappedDocument = T & { - proof: WrappedProof; -}; - -export type SignedWrappedDocument = T & { - proof: SignedWrappedProof; -}; - -export * from "../__generated__/schema.4.0"; +const WrappedDocumentExtrasShape = { proof: WrappedProof.passthrough() } as const; +export const V4WrappedDocument = V4Document.extend(WrappedDocumentExtrasShape).strict(); + +const SignedWrappedProof = WrappedProof.extend({ key: z.string(), signature: z.string() }); +const SignedWrappedDocumentExtrasShape = { proof: SignedWrappedProof } as const; +export const V4SignedWrappedDocument = V4Document.extend(SignedWrappedDocumentExtrasShape).strict(); + +export type W3cVerifiableCredential = z.infer; + +// AssertStricterOrEqual is used to ensure that we have zod extended from the base type while +// still being assignable to the base type. For example, if we accidentally extend and +// replaced '@context' to a boolean, this would fail the assertion. +export type V4Document = AssertStricterOrEqual>; + +export type V4WrappedDocument = Override< + T, + Pick, keyof typeof WrappedDocumentExtrasShape> +>; + +export type V4SignedWrappedDocument = Override< + T, + Pick, keyof typeof SignedWrappedDocumentExtrasShape> +>; + +type IdentityProofType = z.infer; + +export type Salt = z.infer; + +/** Overrides properties in the Target (a & b does not override a props with b props) */ +type Override, OverrideWith extends Record> = Omit< + Target, + keyof OverrideWith +> & + OverrideWith; + +/** Used to assert that StricterType is a stricter or equal version of LooserType, and most importantly, that + * StricterType is STILL assignable to LooserType. */ +type AssertStricterOrEqual = StricterType extends LooserType ? StricterType : never; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export type NoExtraProperties = NewObj extends Reference & infer _ExtraProps ? Reference : NewObj; diff --git a/src/4.0/validate/dataModel.ts b/src/4.0/validate/dataModel.ts deleted file mode 100644 index 10d38212..00000000 --- a/src/4.0/validate/dataModel.ts +++ /dev/null @@ -1,129 +0,0 @@ -import z from "zod"; - -import { ContextUrl } from "../../shared/@types/document"; - -const baseType = "VerifiableCredential"; - -// Custom URI validation function -const URI_REGEX = - /^(?=.)(?!https?:\/(?:$|[^/]))(?!https?:\/\/\/)(?!https?:[^/])(?:[a-zA-Z][a-zA-Z\d+-\.]*:(?:(?:\/\/(?:[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=:]*@)?(?:\[(?:(?:(?:[\dA-Fa-f]{1,4}:){6}(?:[\dA-Fa-f]{1,4}:[\dA-Fa-f]{1,4}|(?:(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|::(?:[\dA-Fa-f]{1,4}:){5}(?:[\dA-Fa-f]{1,4}:[\dA-Fa-f]{1,4}|(?:(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(?:[\dA-Fa-f]{1,4})?::(?:[\dA-Fa-f]{1,4}:){4}(?:[\dA-Fa-f]{1,4}:[\dA-Fa-f]{1,4}|(?:(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(?:(?:[\dA-Fa-f]{1,4}:){0,1}[\dA-Fa-f]{1,4})?::(?:[\dA-Fa-f]{1,4}:){3}(?:[\dA-Fa-f]{1,4}:[\dA-Fa-f]{1,4}|(?:(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(?:(?:[\dA-Fa-f]{1,4}:){0,2}[\dA-Fa-f]{1,4})?::(?:[\dA-Fa-f]{1,4}:){2}(?:[\dA-Fa-f]{1,4}:[\dA-Fa-f]{1,4}|(?:(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(?:(?:[\dA-Fa-f]{1,4}:){0,3}[\dA-Fa-f]{1,4})?::[\dA-Fa-f]{1,4}:(?:[\dA-Fa-f]{1,4}:[\dA-Fa-f]{1,4}|(?:(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(?:(?:[\dA-Fa-f]{1,4}:){0,4}[\dA-Fa-f]{1,4})?::(?:[\dA-Fa-f]{1,4}:[\dA-Fa-f]{1,4}|(?:(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(?:(?:[\dA-Fa-f]{1,4}:){0,5}[\dA-Fa-f]{1,4})?::[\dA-Fa-f]{1,4}|(?:(?:[\dA-Fa-f]{1,4}:){0,6}[\dA-Fa-f]{1,4})?::)|v[\dA-Fa-f]+\.[\w-\.~!\$&'\(\)\*\+,;=:]+)\]|(?:(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])|[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=]{1,255})(?::\d*)?(?:\/[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=:@]*)*)|\/(?:[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=:@]+(?:\/[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=:@]*)*)?|[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=:@]+(?:\/[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=:@]*)*|(?:\/\/\/[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=:@]*(?:\/[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=:@]*)*)))(?:\?[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=:@\/\?]*(?=#|$))?(?:#[\w-\.~%\dA-Fa-f!\$&'\(\)\*\+,;=:@\/\?]*)?$/; -const zodUri = z.string().regex(URI_REGEX, { message: "Invalid URI" }); - -export const inputVcModel = z.object({ - "@context": z.union([ - z.record(z.any()), - z.string(), - // If array: First item must be baseContext, while remaining items can be string or object - z.tuple([z.literal(ContextUrl.v2_vc)]).rest(z.union([z.string(), z.record(z.any())])), - ]), - - // [Optional] If string: Must match uri pattern - id: zodUri.optional(), - - type: z.union([ - z.string(), - // If array: Must have VerifiableCredential, while remaining items can be any string - z.array(z.string()).refine((types) => types.includes(baseType), { message: `Type must include ${baseType}` }), - ]), - - credentialSchema: z - .union([ - // If object: Must have id match uri pattern and type defined - z.object({ - id: zodUri, - type: z.string(), - }), - // If array: Every object must have id match uri pattern and type defined - z.array( - z.object({ - id: zodUri, - type: z.string(), - }) - ), - ]) - .optional(), - - issuer: z.union([ - // If string: Must match uri pattern - zodUri, - // If object: Must have id match uri pattern - z.object({ - id: zodUri, - }), - ]), - - validFrom: z.string().datetime({ offset: true }).optional(), - - validUntil: z.string().datetime({ offset: true }).optional(), - - credentialSubject: z.union([ - // If object: Cannot be empty (i.e. minimum 1 key) - z.record(z.any()).refine((obj) => Object.keys(obj).length > 0, { - message: "Must have at least one key", - }), - // If array: Every object cannot be empty (i.e. minimum 1 key) - z.array( - z.record(z.any()).refine((obj) => Object.keys(obj).length > 0, { - message: "Must have at least one key", - }) - ), - ]), - - credentialStatus: z - .object({ - // If id is present, id must match uri pattern (credentialStatus.id is optional and can be undefined) - id: zodUri.optional(), - // Must have type defined - type: z.string(), - }) - .optional(), - - termsOfUse: z - .union([ - // If object: Must have type defined. If id is present, id must match uri pattern (termsOfUse.id is optional and can be undefined) - z.object({ - id: zodUri.optional(), - type: z.string(), - }), - // If array: Every object must have type defined. If id is present, id must match uri pattern (termsOfUse.id is optional and can be undefined) - z.array( - z.object({ - id: zodUri.optional(), - type: z.string(), - }) - ), - ]) - .optional(), - - evidence: z - .union([ - // If object: Must have type defined. If id is present, id must match uri pattern (evidence.id is optional and can be undefined) - z.object({ - id: zodUri.optional(), - type: z.string(), - }), - // If array: Every object must have type defined. If id is present, id must match uri pattern (evidence.id is optional and can be undefined) - z.array( - z.object({ - id: zodUri.optional(), - type: z.string(), - }) - ), - ]) - .optional(), - - proof: z - .union([ - // If object: Must have type defined - z.object({ - type: z.string(), - }), - // If array: Every object must have type defined - z.array( - z.object({ - type: z.string(), - }) - ), - ]) - .optional(), -}); diff --git a/src/4.0/validate/index.ts b/src/4.0/validate/index.ts index a29ffd44..2edd280c 100644 --- a/src/4.0/validate/index.ts +++ b/src/4.0/validate/index.ts @@ -1,2 +1 @@ export * from "./context"; -export * from "./dataModel"; diff --git a/src/4.0/verify.ts b/src/4.0/verify.ts index ac413a25..1deaa011 100644 --- a/src/4.0/verify.ts +++ b/src/4.0/verify.ts @@ -1,9 +1,9 @@ -import { WrappedDocument } from "./types"; +import { V4WrappedDocument } from "./types"; import { digestCredential } from "./digest"; import { checkProof } from "../shared/merkle"; import { decodeSalt, salt } from "./salt"; -export const verify = (document: T): document is WrappedDocument => { +export const verify = (document: T): document is T => { if (!document.proof) { return false; } diff --git a/src/4.0/wrap.ts b/src/4.0/wrap.ts index 09f2a9ee..a310ee49 100644 --- a/src/4.0/wrap.ts +++ b/src/4.0/wrap.ts @@ -1,68 +1,89 @@ import { hashToBuffer, isStringArray } from "../shared/utils"; import { MerkleTree } from "../shared/merkle"; -import { ContextUrl } from "../shared/@types/document"; -import { WrappedDocument } from "./types"; +import { ContextType, ContextUrl } from "../shared/@types/document"; +import { NoExtraProperties, V4Document, V4WrappedDocument, W3cVerifiableCredential } from "./types"; import { digestCredential } from "../4.0/digest"; -import { WrapDocumentOptionV4 } from "../shared/@types/wrap"; -import { OpenAttestationDocument, ProofPurpose } from "../__generated__/schema.4.0"; import { encodeSalt, salt } from "./salt"; -import { interpretContexts, inputVcModel } from "./validate"; - -export const wrapDocument = async ( - credential: T, - options: WrapDocumentOptionV4 // eslint-disable-line @typescript-eslint/no-unused-vars -): Promise> => { - const document = { ...credential }; - - /* 1. Data model validation */ - const result = await inputVcModel.safeParseAsync(document); - if (!result.success) - throw new Error( - `Input document does not conform to Verifiable Credentials v2.0 Data Model: ${JSON.stringify( - result.error.issues - )}` - ); +import { interpretContexts } from "./validate"; + +export const wrapDocument = async ( + // NoExtraProperties prevents the user from passing in a document with extra properties, which is more aligned to our validation strategy of strict + document: NoExtraProperties +): Promise> => { + /* 1a. try OpenAttestation VC validation, since most user will be issuing oa v4*/ + const oav4context = await V4Document.pick({ "@context": true }).safeParseAsync(document); // Superficial check on user intention + let validatedRawDocument: W3cVerifiableCredential | undefined; + if (oav4context.success) { + const oav4 = await V4Document.safeParseAsync(document); + if (!oav4.success) { + throw new Error( + `Input document does not conform to OpenAttestation v4.0 Data Model: ${JSON.stringify(oav4.error.issues)}` + ); + } + validatedRawDocument = oav4.data; + } + + /* 1b. only if OA VC validation fail do we continue with W3C VC data model validation */ + if (!validatedRawDocument) { + const vc = await W3cVerifiableCredential.safeParseAsync(document); + if (!vc.success) + throw new Error( + `Input document does not conform to Verifiable Credentials v2.0 Data Model: ${JSON.stringify(vc.error.issues)}` + ); + + validatedRawDocument = vc.data; + } /* 2. Ensure provided @context are interpretable (e.g. valid @context URL, all types are mapped, etc.) */ - await interpretContexts(document); + await interpretContexts(validatedRawDocument); /* 3. Context validation */ // Ensure that required contexts are present and in the correct order // type: [Base, OA, ...] - const contexts = new Set([ContextUrl["v2_vc"], ContextUrl["v4_alpha"]]); - if (typeof document["@context"] === "string") { - contexts.add(document["@context"]); - } else if (isStringArray(document["@context"])) { - document["@context"].forEach((context) => contexts.add(context)); + const REQUIRED_CONTEXTS = [ContextUrl.v2_vc, ContextUrl.v4_alpha] as const; + const contexts = new Set(REQUIRED_CONTEXTS); + if (typeof validatedRawDocument["@context"] === "string") { + contexts.add(validatedRawDocument["@context"]); + } else if (isStringArray(validatedRawDocument["@context"])) { + validatedRawDocument["@context"].forEach((context) => contexts.add(context)); } - document["@context"] = Array.from(contexts); // Since JavaScript Sets preserve insertion order and duplicated inserts do not affect the order, we can do this + REQUIRED_CONTEXTS.forEach((c) => contexts.delete(c)); + const finalContexts: V4Document["@context"] = [...REQUIRED_CONTEXTS, ...Array.from(contexts)]; /* 4. Type validation */ // Ensure that required types are present and in the correct order // type: ["VerifiableCredential", "OpenAttestationCredential", ...] - const types = new Set(["VerifiableCredential", "OpenAttestationCredential"]); - if (typeof document["type"] === "string") { - types.add(document["type"]); - } else if (isStringArray(document["type"])) { - document["type"].forEach((type) => types.add(type)); + const REQUIRED_TYPES = [ContextType.BaseContext, ContextType.V4AlphaContext] as const; + const types = new Set([ContextType.BaseContext, ContextType.V4AlphaContext]); + if (typeof validatedRawDocument["type"] === "string") { + types.add(validatedRawDocument["type"]); + } else if (isStringArray(validatedRawDocument["type"])) { + types.forEach((type) => types.add(type)); } - document["type"] = Array.from(types); // Since JavaScript Sets preserve insertion order and duplicated inserts do not affect the order, we can do this + REQUIRED_TYPES.forEach((t) => types.delete(t)); + const finalTypes: V4Document["type"] = [...REQUIRED_TYPES, ...Array.from(types)]; + + const documentReadyForWrapping = { + ...validatedRawDocument, + ...extractAndAssertAsV4DocumentProps(validatedRawDocument, ["issuer", "credentialStatus"]), + "@context": finalContexts, + type: finalTypes, + } satisfies W3cVerifiableCredential; /* 5. OA wrapping */ - const salts = salt(document); - const digest = digestCredential(document, salts, []); + const salts = salt(documentReadyForWrapping); + const digest = digestCredential(documentReadyForWrapping, salts, []); const batchBuffers = [digest].map(hashToBuffer); const merkleTree = new MerkleTree(batchBuffers); const merkleRoot = merkleTree.getRoot().toString("hex"); const merkleProof = merkleTree.getProof(hashToBuffer(digest)).map((buffer: Buffer) => buffer.toString("hex")); - - const verifiableCredential: WrappedDocument = { - ...document, + const verifiableCredential: V4WrappedDocument = { + ...documentReadyForWrapping, proof: { type: "OpenAttestationMerkleProofSignature2018", - proofPurpose: ProofPurpose.AssertionMethod, + proofPurpose: "assertionMethod", targetHash: digest, proofs: merkleProof, merkleRoot, @@ -73,15 +94,15 @@ export const wrapDocument = async ( }, }; - return verifiableCredential; + return verifiableCredential as V4WrappedDocument; }; -export const wrapDocuments = async ( - documents: T[], - options: WrapDocumentOptionV4 -): Promise[]> => { +export const wrapDocuments = async ( + // NoExtraProperties prevents the user from passing in a document with extra properties, which is more aligned to our validation strategy of strict + documents: NoExtraProperties[] +): Promise[]> => { // create individual verifiable credential - const verifiableCredentials = await Promise.all(documents.map((document) => wrapDocument(document, options))); + const verifiableCredentials = await Promise.all(documents.map((document) => wrapDocument(document))); // get all the target hashes to compute the merkle tree and the merkle root const merkleTree = new MerkleTree( @@ -104,3 +125,20 @@ export const wrapDocuments = async ( }; }); }; + +/** Extract a set of properties from w3cVerifiableCredential but only include the ones + * that are defined in the original document. For example, if we extract + * "a" and "b" from { b: "something" } we should only get { b: "something" } NOT + * { a: undefined, b: "something" }. We also assert that the extracted properties + * are of V4Document type. + **/ +function extractAndAssertAsV4DocumentProps( + original: W3cVerifiableCredential, + keys: K[] +) { + const temp: Record = {}; + Object.entries(original).forEach(([k, v]) => { + if (keys.includes(k as K)) temp[k] = v; + }); + return temp as { [key in K]: V4Document[key] }; +} diff --git a/src/index.ts b/src/index.ts index 0f80d9d0..abcd820d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,8 +4,8 @@ import { getSchema } from "./shared/ajv"; import * as utils from "./shared/utils"; import { SchemaValidationError } from "./shared/utils"; import { validateSchema as validate } from "./shared/validate"; -import { SchemaId, WrappedDocument, OpenAttestationDocument } from "./shared/@types/document"; -import { WrapDocumentOptionV2, WrapDocumentOptionV3, WrapDocumentOptionV4 } from "./shared/@types/wrap"; +import { SchemaId, WrappedDocument, OpenAttestationDocument, SignedWrappedDocument } from "./shared/@types/document"; +import { WrapDocumentOptionV2, WrapDocumentOptionV3 } from "./shared/@types/wrap"; import { SigningKey, SUPPORTED_SIGNING_ALGORITHM } from "./shared/@types/sign"; import * as v2 from "./2.0/types"; @@ -26,13 +26,11 @@ import { obfuscateVerifiableCredential as obfuscateVerifiableCredentialV3 } from import { OpenAttestationDocument as OpenAttestationDocumentV3 } from "./__generated__/schema.3.0"; import * as v4 from "./4.0/types"; -import { WrappedDocument as WrappedDocumentV4 } from "./4.0/types"; -import { wrapDocument as wrapDocumentV4, wrapDocuments as wrapDocumentsV4 } from "./4.0/wrap"; import { signDocument as signDocumentV4 } from "./4.0/sign"; import { verify as verifyV4 } from "./4.0/verify"; import { digestCredential as digestCredentialV4 } from "./4.0/digest"; import { obfuscateVerifiableCredential as obfuscateVerifiableCredentialV4 } from "./4.0/obfuscate"; -import { OpenAttestationDocument as OpenAttestationDocumentV4 } from "./__generated__/schema.4.0"; +import { v4Diagnose } from "./4.0/diagnose"; export function wrapDocument( data: T, @@ -62,19 +60,10 @@ export function __unsafe__use__it__at__your__own__risks__wrapDocuments( - data: T, - options?: WrapDocumentOptionV4 -): Promise> { - return wrapDocumentV4(data, options ?? { version: SchemaId.v4 }); -} - -export function _unsafe_use_it_at_your_own_risk_v4_alpha_wrapDocuments( - dataArray: T[], - options?: WrapDocumentOptionV4 -): Promise[]> { - return wrapDocumentsV4(dataArray, options ?? { version: SchemaId.v4 }); -} +export { + wrapDocument as _unsafe_use_it_at_your_own_risk_v4_alpha_wrapDocument, + wrapDocuments as _unsafe_use_it_at_your_own_risk_v4_alpha_wrapDocuments, +} from "./4.0/wrap"; export const validateSchema = (document: WrappedDocument): boolean => { if (utils.isWrappedV2Document(document) || document?.version === SchemaId.v2) @@ -82,7 +71,7 @@ export const validateSchema = (document: WrappedDocument): boolean => { else if (utils.isWrappedV3Document(document) || document?.version === SchemaId.v3) return validate(document, getSchema(SchemaId.v3)).length === 0; else if (utils.isWrappedV4Document(document)) { - return validate(document, getSchema(SchemaId.v4)).length === 0; + return v4Diagnose({ document, kind: "wrapped", debug: false, mode: "strict" }).length === 0; } return validate(document, getSchema(`${document?.version || SchemaId.v2}`)).length === 0; @@ -97,9 +86,9 @@ export function verifySignature( document: WrappedDocument, fields: string[] | string ): WrappedDocument; -export function obfuscate( +export function obfuscate( document: WrappedDocument, fields: string[] | string ): WrappedDocument; @@ -135,34 +124,22 @@ export const isSchemaValidationError = (error: any): error is SchemaValidationEr return !!error.validationErrors; }; -export async function signDocument( - document: v2.SignedWrappedDocument | v2.WrappedDocument, - algorithm: SUPPORTED_SIGNING_ALGORITHM, - keyOrSigner: SigningKey | ethers.Signer -): Promise>; -export async function signDocument( - document: v3.SignedWrappedDocument | v3.WrappedDocument, - algorithm: SUPPORTED_SIGNING_ALGORITHM, - keyOrSigner: SigningKey | ethers.Signer -): Promise>; -export async function signDocument( - document: v4.SignedWrappedDocument | v4.WrappedDocument, +export async function signDocument( + document: WrappedDocument | SignedWrappedDocument, algorithm: SUPPORTED_SIGNING_ALGORITHM, keyOrSigner: SigningKey | ethers.Signer -): Promise>; -export async function signDocument( - document: any, - algorithm: SUPPORTED_SIGNING_ALGORITHM, - keyOrSigner: SigningKey | ethers.Signer -) { +): Promise> { // rj was worried it could happen deep in the code, so I moved it to the boundaries if (!SigningKey.guard(keyOrSigner) && !Signer.isSigner(keyOrSigner)) { throw new Error(`Either a keypair or ethers.js Signer must be provided`); } - if (utils.isWrappedV2Document(document)) return signDocumentV2(document, algorithm, keyOrSigner); - else if (utils.isWrappedV3Document(document)) return signDocumentV3(document, algorithm, keyOrSigner); - else if (utils.isWrappedV4Document(document)) return signDocumentV4(document, algorithm, keyOrSigner); + let results: unknown; + if (utils.isWrappedV2Document(document)) results = signDocumentV2(document, algorithm, keyOrSigner); + else if (utils.isWrappedV3Document(document)) results = signDocumentV3(document, algorithm, keyOrSigner); + else if (utils.isWrappedV4Document(document)) results = signDocumentV4(document, algorithm, keyOrSigner); + + if (results) return results as SignedWrappedDocument; // Unreachable code atm until utils.isWrappedV2Document & utils.isWrappedV3Document becomes more strict throw new Error("Unsupported document type: Only OpenAttestation v2, v3 or v4 documents can be signed"); diff --git a/src/shared/@types/document.ts b/src/shared/@types/document.ts index 0a62aa56..153dec84 100644 --- a/src/shared/@types/document.ts +++ b/src/shared/@types/document.ts @@ -1,7 +1,6 @@ // types generated by quicktype during postinstall phase import { OpenAttestationDocument as OpenAttestationDocumentV2 } from "../../__generated__/schema.2.0"; import { OpenAttestationDocument as OpenAttestationDocumentV3 } from "../../__generated__/schema.3.0"; -import { OpenAttestationDocument as OpenAttestationDocumentV4 } from "../../__generated__/schema.4.0"; import { SignedWrappedDocument as SignedWrappedDocumentV2, WrappedDocument as WrappedDocumentV2, @@ -10,39 +9,40 @@ import { SignedWrappedDocument as SignedWrappedDocumentV3, WrappedDocument as WrappedDocumentV3, } from "../../3.0/types"; -import { - SignedWrappedDocument as SignedWrappedDocumentV4, - WrappedDocument as WrappedDocumentV4, -} from "../../4.0/types"; import { Literal, Static, String } from "runtypes"; import { ethers } from "ethers"; +import { V4Document, V4SignedWrappedDocument, V4WrappedDocument } from "src/4.0/types"; -export type OpenAttestationDocument = OpenAttestationDocumentV2 | OpenAttestationDocumentV3 | OpenAttestationDocumentV4; +export type OpenAttestationDocument = OpenAttestationDocumentV2 | OpenAttestationDocumentV3 | V4Document; export type WrappedDocument = T extends OpenAttestationDocumentV2 ? WrappedDocumentV2 : T extends OpenAttestationDocumentV3 ? WrappedDocumentV3 - : T extends OpenAttestationDocumentV4 - ? WrappedDocumentV4 + : T extends V4Document + ? V4WrappedDocument : unknown; export type SignedWrappedDocument = T extends OpenAttestationDocumentV2 ? SignedWrappedDocumentV2 : T extends OpenAttestationDocumentV3 ? SignedWrappedDocumentV3 - : T extends OpenAttestationDocumentV4 - ? SignedWrappedDocumentV4 + : T extends V4Document + ? V4SignedWrappedDocument : unknown; export enum SchemaId { v2 = "https://schema.openattestation.com/2.0/schema.json", v3 = "https://schema.openattestation.com/3.0/schema.json", - v4 = "https://schemata.openattestation.com/com/openattestation/4.0/alpha-schema.json", // Note: Schema property is no longer placed in the OA v4 document } -export enum ContextUrl { - v2_vc = "https://www.w3.org/ns/credentials/v2", - v4_alpha = "https://schemata.openattestation.com/com/openattestation/4.0/alpha-context.json", -} +export const ContextUrl = { + v2_vc: "https://www.w3.org/ns/credentials/v2", + v4_alpha: "https://schemata.openattestation.com/com/openattestation/4.0/alpha-context.json", +} as const; + +export const ContextType = { + BaseContext: "VerifiableCredential", + V4AlphaContext: "OpenAttestationCredential", +} as const; export const OpenAttestationHexString = String.withConstraint( (value) => ethers.utils.isHexString(`0x${value}`, 32) || `${value} has not the expected length of 32 bytes` diff --git a/src/shared/@types/wrap.ts b/src/shared/@types/wrap.ts index 6d0c3244..8fa54e04 100644 --- a/src/shared/@types/wrap.ts +++ b/src/shared/@types/wrap.ts @@ -12,7 +12,3 @@ export interface WrapDocumentOptionV3 { externalSchemaId?: string; version: SchemaId.v3; } -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface WrapDocumentOptionV4 { - // If any, add options to wrap utility -} diff --git a/src/shared/ajv.ts b/src/shared/ajv.ts index 53879c1a..81e9405c 100644 --- a/src/shared/ajv.ts +++ b/src/shared/ajv.ts @@ -2,7 +2,6 @@ import Ajv from "ajv"; import addFormats from "ajv-formats"; import openAttestationSchemav2 from "../2.0/schema/schema.json"; import openAttestationSchemav3 from "../3.0/schema/schema.json"; -import openAttestationSchemav4 from "../4.0/schema/schema.json"; import { CurrentOptions } from "ajv/dist/core"; const defaultTransform = (schema: Record) => schema; @@ -17,7 +16,6 @@ export const buildAjv = ( ajv.addKeyword("deprecationMessage"); ajv.compile(transform(openAttestationSchemav2)); ajv.compile(transform(openAttestationSchemav3)); - ajv.compile(transform(openAttestationSchemav4)); return ajv; }; diff --git a/src/shared/utils/@types/diagnose.ts b/src/shared/utils/@types/diagnose.ts index 91fb12af..dfcdcf7e 100644 --- a/src/shared/utils/@types/diagnose.ts +++ b/src/shared/utils/@types/diagnose.ts @@ -1,2 +1,5 @@ export type Kind = "raw" | "wrapped" | "signed"; export type Mode = "strict" | "non-strict"; +export type Diagnose = (props: { kind: Kind; document: any; debug: boolean; mode?: Mode }) => { + message: string; +}[]; diff --git a/src/shared/utils/diagnose.ts b/src/shared/utils/diagnose.ts index bc857504..919553ff 100644 --- a/src/shared/utils/diagnose.ts +++ b/src/shared/utils/diagnose.ts @@ -1,23 +1,18 @@ import { logger } from "ethers"; -import { ContextUrl, SchemaId } from "../@types/document"; +import { SchemaId } from "../@types/document"; import { validateSchema as validate } from "../validate"; import { VerifiableCredentialWrappedProof as WrappedProofV3, VerifiableCredentialWrappedProofStrict as WrappedProofStrictV3, VerifiableCredentialSignedProof as SignedWrappedProofV3, } from "../../3.0/types"; -import { - WrappedProof as WrappedProofV4, - WrappedProofStrict as WrappedProofStrictV4, - SignedWrappedProof as SignedWrappedProofV4, -} from "../../4.0/types"; import { ArrayProof, Signature, SignatureStrict } from "../../2.0/types"; import { clone, cloneDeepWith } from "lodash"; import { buildAjv, getSchema } from "../ajv"; import { Kind, Mode } from "./@types/diagnose"; -import { isStringArray } from "./utils"; +import { v4Diagnose } from "../../4.0/diagnose"; -type Version = "2.0" | "3.0" | "4.0"; +export type Version = "2.0" | "3.0" | "4.0"; interface DiagnoseError { message: string; @@ -90,34 +85,33 @@ export const diagnose = ({ return handleError(debug, "The document must be an object"); } - const versionToSchemaId: Record = { + const versionToSchemaId: Record = { "2.0": SchemaId.v2, "3.0": SchemaId.v3, - "4.0": SchemaId.v4, + "4.0": undefined, }; - const errors = validate( - document, - getSchema(versionToSchemaId[version], mode === "non-strict" ? ajv : undefined), - kind - ); + const schemaId = versionToSchemaId[version]; + if (schemaId) { + const errors = validate(document, getSchema(schemaId, mode === "non-strict" ? ajv : undefined), kind); - if (errors.length > 0) { - // TODO this can be improved later - return handleError( - debug, - `The document does not match OpenAttestation schema ${version}`, - ...errors.map((error) => `${error.instancePath || "document"} - ${error.message}`) - ); + if (errors.length > 0) { + // TODO this can be improved later + return handleError( + debug, + `The document does not match OpenAttestation schema ${version}`, + ...errors.map((error) => `${error.instancePath || "document"} - ${error.message}`) + ); + } } - if (kind === "raw") { + if (kind === "raw" && version !== "4.0") { return []; } switch (version) { case "4.0": - return diagnoseV4({ mode, debug, document, kind }); + return v4Diagnose({ debug, document, kind }); case "3.0": return diagnoseV3({ mode, debug, document, kind }); case "2.0": @@ -190,100 +184,3 @@ const diagnoseV3 = ({ kind, document, debug, mode }: { kind: Kind; document: any } return []; }; - -const diagnoseV4 = ({ - kind, - document, - debug, - mode, -}: { - kind: Exclude; - document: any; - debug: boolean; - mode: Mode; -}) => { - /* Wrapped document checks */ - try { - // 1. Since OA v4 has deprecated a few properties from v2/v3, check that they are not used - const deprecatedProperties = ["version", "openAttestationMetadata"]; - const documentProperties = Object.keys(document); - const deprecatedDocumentProperties = documentProperties.filter((p) => deprecatedProperties.includes(p)); - - if (deprecatedDocumentProperties.length > 0) { - return handleError( - debug, - `The document has outdated properties previously used in v2/v3. The following properties are no longer in use in a v4 document: ${deprecatedDocumentProperties}` - ); - } - - // 2. Ensure that required @contexts are present - // @context: [Base, OA, ...] - const contexts = [ContextUrl.v2_vc, ContextUrl.v4_alpha]; - if (isStringArray(document["@context"])) { - for (let i = 0; i < contexts.length; i++) { - if (document["@context"][i] !== contexts[i]) { - return handleError( - debug, - `The document @context contains an unexpected value or in the wrong order. Expected "${contexts}" but received "${document["@context"]}"` - ); - } - } - } else { - return handleError( - debug, - `The document @context should be an array of string values. Expected "${contexts}" but received "${document["@context"]}"` - ); - } - - // 3. Ensure that required types are present - // type: ["VerifiableCredential", "OpenAttestationCredential", ...] - const types = ["VerifiableCredential", "OpenAttestationCredential"]; - if (isStringArray(document["type"])) { - for (let i = 0; i < types.length; i++) { - if (document["type"][i] !== types[i]) { - return handleError( - debug, - `The document type contains an unexpected value or in the wrong order. Expected "${types}" but received "${document["type"]}"` - ); - } - } - } else { - return handleError( - debug, - `The document type should be an array of string values. Expected "${types}" but received "${document["type"]}"` - ); - } - - // 4. Check proof object - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - if (mode === "strict") { - WrappedProofStrictV4.check(document.proof); - } else { - WrappedProofV4.check(document.proof); - } - } catch (e) { - if (e instanceof Error) { - return handleError(debug, e.message); - } else { - console.error(e); - } - } - - /* Signed & wrapped document checks */ - if (kind === "signed") { - if (!document.proof) { - return handleError(debug, `The document does not have a proof`); - } - try { - SignedWrappedProofV4.check(document.proof); - } catch (e) { - if (e instanceof Error) { - return handleError(debug, e.message); - } else { - console.error(e); - } - } - } - - return []; -}; diff --git a/src/shared/utils/guard.ts b/src/shared/utils/guard.ts index ffd0dedd..dfe8e136 100644 --- a/src/shared/utils/guard.ts +++ b/src/shared/utils/guard.ts @@ -7,10 +7,7 @@ import { OpenAttestationDocument as OpenAttestationDocumentV3, WrappedDocument as WrappedDocumentV3, } from "../../3.0/types"; -import { - OpenAttestationDocument as OpenAttestationDocumentV4, - WrappedDocument as WrappedDocumentV4, -} from "../../4.0/types"; +import { V4WrappedDocument, V4SignedWrappedDocument, V4Document } from "../../4.0/types"; import { diagnose } from "./diagnose"; import { Mode } from "./@types/diagnose"; @@ -46,7 +43,7 @@ export const isRawV3Document = ( export const isRawV4Document = ( document: any, { mode }: { mode: Mode } = { mode: "non-strict" } -): document is OpenAttestationDocumentV4 => { +): document is V4Document => { return diagnose({ version: "4.0", kind: "raw", document, debug: false, mode }).length === 0; }; @@ -55,10 +52,10 @@ export const isRawV4Document = ( * @param document * @param mode strict or non-strict. Strict will perform additional check on the data. For instance strict validation will ensure that a target hash is a 32 bytes hex string while non strict validation will just check that target hash is a string. */ -export const isWrappedV2Document = ( +export const isWrappedV2Document = ( document: any, { mode }: { mode: Mode } = { mode: "non-strict" } -): document is WrappedDocumentV2 => { +): document is WrappedDocumentV2 => { return diagnose({ version: "2.0", kind: "wrapped", document, debug: false, mode }).length === 0; }; @@ -67,10 +64,10 @@ export const isWrappedV2Document = ( * @param document * @param mode strict or non-strict. Strict will perform additional check on the data. For instance strict validation will ensure that a target hash is a 32 bytes hex string while non strict validation will just check that target hash is a string. */ -export const isWrappedV3Document = ( +export const isWrappedV3Document = ( document: any, { mode }: { mode: Mode } = { mode: "non-strict" } -): document is WrappedDocumentV3 => { +): document is WrappedDocumentV3 => { return diagnose({ version: "3.0", kind: "wrapped", document, debug: false, mode }).length === 0; }; @@ -79,10 +76,10 @@ export const isWrappedV3Document = ( * @param document * @param mode strict or non-strict. Strict will perform additional check on the data. For instance strict validation will ensure that a target hash is a 32 bytes hex string while non strict validation will just check that target hash is a string. */ -export const isWrappedV4Document = ( - document: any, +export const isWrappedV4Document = ( + document: unknown, { mode }: { mode: Mode } = { mode: "non-strict" } -): document is WrappedDocumentV4 => { +): document is V4WrappedDocument => { return diagnose({ version: "4.0", kind: "wrapped", document, debug: false, mode }).length === 0; }; @@ -115,9 +112,9 @@ export const isSignedWrappedV3Document = ( * @param document * @param mode strict or non-strict. Strict will perform additional check on the data. For instance strict validation will ensure that a target hash is a 32 bytes hex string while non strict validation will just check that target hash is a string. */ -export const isSignedWrappedV4Document = ( - document: any, +export const isSignedWrappedV4Document = ( + document: unknown, { mode }: { mode: Mode } = { mode: "non-strict" } -): document is SignedWrappedDocument => { +): document is T => { return diagnose({ version: "4.0", kind: "signed", document, debug: false, mode }).length === 0; }; diff --git a/src/shared/utils/utils.ts b/src/shared/utils/utils.ts index 50670550..d992735a 100644 --- a/src/shared/utils/utils.ts +++ b/src/shared/utils/utils.ts @@ -10,11 +10,9 @@ import * as v3 from "../../__generated__/schema.3.0"; import { WrappedDocument as WrappedDocumentV3 } from "../../3.0/types"; import { OpenAttestationDocument as OpenAttestationDocumentV3 } from "../../__generated__/schema.3.0"; -import * as v4 from "../../__generated__/schema.4.0"; -import { WrappedDocument as WrappedDocumentV4 } from "../../4.0/types"; -import { OpenAttestationDocument as OpenAttestationDocumentV4 } from "../../__generated__/schema.4.0"; +import { V4WrappedDocument } from "../../4.0/types"; -import { OpenAttestationDocument, WrappedDocument } from "../@types/document"; +import { OpenAttestationDocument, WrappedDocument, SchemaId, ContextUrl } from "../@types/document"; import { isRawV2Document, isWrappedV2Document, @@ -23,6 +21,7 @@ import { isRawV4Document, isWrappedV4Document, } from "./guard"; +import { Version } from "./diagnose"; export type Hash = string | Buffer; type Extract

= P extends WrappedDocumentV2 ? T : never; @@ -187,8 +186,8 @@ export const isDocumentRevokable = (document: any): boolean => { } else if (isWrappedV4Document(document)) { if (typeof document.issuer === "string" || !document.credentialStatus) return false; const isDidRevokableV4 = - document.issuer.identityProof?.identityProofType === v4.IdentityProofType.DNSDid - ? document.credentialStatus.type === "OpenAttestationOcspResponder" // TODO: Create suggested typings e.g. "v4.CredentialStatusType.OpenAttestationOcspResponder" + document.issuer.identityProof?.identityProofType === "DNS-DID" + ? document.credentialStatus.type === "OpenAttestationOcspResponder" : false; // TODO: OA v4 issuer schema not updated to support document store issuance yet // const isDocumentStoreRevokableV4 = ? @@ -225,7 +224,7 @@ export const isObfuscated = ( document: | WrappedDocumentV2 | WrappedDocumentV3 - | WrappedDocumentV4 + | V4WrappedDocument ): boolean => { if (isWrappedV2Document(document)) { return !!document.privacy?.obfuscatedData?.length; @@ -244,7 +243,7 @@ export const getObfuscatedData = ( document: | WrappedDocumentV2 | WrappedDocumentV3 - | WrappedDocumentV4 + | V4WrappedDocument ): string[] => { if (isWrappedV2Document(document)) { return document.privacy?.obfuscatedData || []; @@ -261,3 +260,22 @@ export const getObfuscatedData = ( export const isStringArray = (input: unknown): input is string[] => Array.isArray(input) && input.every((i) => typeof i === "string"); + +export const getVersion = (document: unknown): Version => { + if (typeof document === "object" && document !== null) { + if ("version" in document && typeof document.version === "string") { + switch (document.version) { + case SchemaId.v2: + return "2.0"; + case SchemaId.v3: + return "3.0"; + } + } else if ("@context" in document && Array.isArray(document["@context"])) { + if (document["@context"].includes(ContextUrl.v4_alpha)) { + return "4.0"; + } + } + } + + throw new Error("Unknown document version: Can only determine between OpenAttestation v2, v3 & v4 documents."); +};