diff --git a/package-lock.json b/package-lock.json index 9e32bdfc..1f9d387b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,13 @@ "license": "MIT", "dependencies": { "@inrupt/solid-client": "^1.25.2", - "@inrupt/universal-fetch": "^1.0.1" + "@inrupt/universal-fetch": "^1.0.1", + "content-type": "^1.0.5", + "event-emitter-promisify": "^1.1.0", + "jsonld-context-parser": "^2.3.3", + "jsonld-streaming-parser": "^3.2.1", + "jsonld-streaming-serializer": "^2.1.0", + "n3": "^1.17.0" }, "devDependencies": { "@inrupt/eslint-config-lib": "^2.0.0", @@ -21,8 +27,10 @@ "@rdfjs/dataset": "2.0.1", "@rdfjs/types": "^1.1.0", "@rushstack/eslint-patch": "^1.1.4", + "@types/content-type": "^1.1.5", "@types/dotenv-flow": "^3.1.1", "@types/jest": "^29.2.2", + "@types/n3": "^1.16.0", "@types/node": "^20.1.2", "@types/rdfjs__dataset": "2.0.5", "dotenv-flow": "^3.2.0", @@ -30,6 +38,7 @@ "jest": "^29.3.0", "jest-environment-jsdom": "^29.3.0", "prettier": "^3.0.2", + "rdf-isomorphic": "^1.3.1", "rollup": "^3.1.0", "rollup-plugin-typescript2": "^0.35.0", "ts-jest": "^29.0.3", @@ -1828,6 +1837,12 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/content-type": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/content-type/-/content-type-1.1.5.tgz", + "integrity": "sha512-dgMN+syt1xb7Hk8LU6AODOfPlvz5z1CbXpPuJE5ZrX9STfBOIXF09pEB8N7a97WT9dbngt3ksDCm6GW6yMrxfQ==", + "dev": true + }, "node_modules/@types/dotenv-flow": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/@types/dotenv-flow/-/dotenv-flow-3.3.1.tgz", @@ -1908,6 +1923,16 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/n3": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@types/n3/-/n3-1.16.0.tgz", + "integrity": "sha512-g/67NVSihmIoIZT3/J462NhJrmpCw+5WUkkKqpCE9YxNEWzBwKavGPP+RUmG6DIm5GrW4GPunuxLJ0Yn/GgNjQ==", + "dev": true, + "dependencies": { + "@rdfjs/types": "^1.1.0", + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "20.6.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.6.2.tgz", @@ -3098,7 +3123,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -4385,6 +4409,11 @@ "node": ">= 0.6" } }, + "node_modules/event-emitter-promisify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/event-emitter-promisify/-/event-emitter-promisify-1.1.0.tgz", + "integrity": "sha512-uyHG8gjwYGDlKoo0Txtx/u1HI1ubj0FK0rVqI4O0s1EymQm4iAEMbrS5B+XFlSaS8SZ3xzoKX+YHRZk8Nk/bXg==" + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -5165,6 +5194,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -6548,9 +6587,9 @@ } }, "node_modules/jsonld-context-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/jsonld-context-parser/-/jsonld-context-parser-2.3.0.tgz", - "integrity": "sha512-c6w2GE57O26eWFjcPX6k6G86ootsIfpuVwhZKjCll0bVoDGBxr1P4OuU+yvgfnh1GJhAGErolfC7W1BklLjWMg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/jsonld-context-parser/-/jsonld-context-parser-2.3.3.tgz", + "integrity": "sha512-H+REInOx7XI2ciF8wJV31D20Bh+ofBmEjN2Tkds51vypqDJIiD341E5g+hYyrEInIKRnbW58TN/Ehz+ACT0l0w==", "dependencies": { "@types/http-link-header": "^1.0.1", "@types/node": "^18.0.0", @@ -6569,9 +6608,9 @@ "integrity": "sha512-xNbS75FxH6P4UXTPUJp/zNPq6/xsfdJKussCWNOnz4aULWIRwMgP1LgaB5RiBnMX1DPCYenuqGZfnIAx5mbFLA==" }, "node_modules/jsonld-streaming-parser": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsonld-streaming-parser/-/jsonld-streaming-parser-3.2.0.tgz", - "integrity": "sha512-lJR1SCT364PGpFrOQaY+ZQ7qDWqqiT3IMK+AvZ83fo0LvltFn8/UyXvIFc3RO7YcaEjLahAF0otCi8vOq21NtQ==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jsonld-streaming-parser/-/jsonld-streaming-parser-3.2.1.tgz", + "integrity": "sha512-MZCUrQe3pBO2pk2i3BpyW9Yn2oZoe2RCRpHZAJa88S6tRyxbe7XcjWfTKAZv35obDJDIREgot4723VhbClJELw==", "dependencies": { "@bergos/jsonparse": "^1.4.0", "@rdfjs/types": "*", @@ -6585,6 +6624,18 @@ "readable-stream": "^4.0.0" } }, + "node_modules/jsonld-streaming-serializer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/jsonld-streaming-serializer/-/jsonld-streaming-serializer-2.1.0.tgz", + "integrity": "sha512-COHdLoeMTnrqHMoFhN3PoAwqnrKrpPC7/ACb0WbELYvt+HSOIFN3v4IJP7fOtLNQ4GeaeYkvbeWJ7Jo4EjxMDw==", + "dependencies": { + "@rdfjs/types": "*", + "@types/readable-stream": "^2.3.13", + "buffer": "^6.0.3", + "jsonld-context-parser": "^2.0.0", + "readable-stream": "^4.0.0" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -6800,6 +6851,12 @@ "node": ">=6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -7898,6 +7955,18 @@ "@rdfjs/types": "*" } }, + "node_modules/rdf-isomorphic": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/rdf-isomorphic/-/rdf-isomorphic-1.3.1.tgz", + "integrity": "sha512-6uIhsXTVp2AtO6f41PdnRV5xZsa0zVZQDTBdn0br+DZuFf5M/YD+T6m8hKDUnALI6nFL/IujTMLgEs20MlNidQ==", + "dev": true, + "dependencies": { + "@rdfjs/types": "*", + "hash.js": "^1.1.7", + "rdf-string": "^1.6.0", + "rdf-terms": "^1.7.0" + } + }, "node_modules/rdf-js": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/rdf-js/-/rdf-js-4.0.2.tgz", @@ -7906,6 +7975,27 @@ "@rdfjs/types": "*" } }, + "node_modules/rdf-string": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/rdf-string/-/rdf-string-1.6.3.tgz", + "integrity": "sha512-HIVwQ2gOqf+ObsCLSUAGFZMIl3rh9uGcRf1KbM85UDhKqP+hy6qj7Vz8FKt3GA54RiThqK3mNcr66dm1LP0+6g==", + "dev": true, + "dependencies": { + "@rdfjs/types": "*", + "rdf-data-factory": "^1.1.0" + } + }, + "node_modules/rdf-terms": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/rdf-terms/-/rdf-terms-1.11.0.tgz", + "integrity": "sha512-iKlVgnMopRKl9pHVNrQrax7PtZKRCT/uJIgYqvuw1VVQb88zDvurtDr1xp0rt7N9JtKtFwUXoIQoEsjyRo20qQ==", + "dev": true, + "dependencies": { + "@rdfjs/types": "*", + "rdf-data-factory": "^1.1.0", + "rdf-string": "^1.6.0" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", diff --git a/package.json b/package.json index 4c596ae4..7335ef1a 100644 --- a/package.json +++ b/package.json @@ -62,8 +62,10 @@ "@rdfjs/dataset": "2.0.1", "@rdfjs/types": "^1.1.0", "@rushstack/eslint-patch": "^1.1.4", + "@types/content-type": "^1.1.5", "@types/dotenv-flow": "^3.1.1", "@types/jest": "^29.2.2", + "@types/n3": "^1.16.0", "@types/node": "^20.1.2", "@types/rdfjs__dataset": "2.0.5", "dotenv-flow": "^3.2.0", @@ -71,6 +73,7 @@ "jest": "^29.3.0", "jest-environment-jsdom": "^29.3.0", "prettier": "^3.0.2", + "rdf-isomorphic": "^1.3.1", "rollup": "^3.1.0", "rollup-plugin-typescript2": "^0.35.0", "ts-jest": "^29.0.3", @@ -84,7 +87,13 @@ }, "dependencies": { "@inrupt/solid-client": "^1.25.2", - "@inrupt/universal-fetch": "^1.0.1" + "@inrupt/universal-fetch": "^1.0.1", + "content-type": "^1.0.5", + "event-emitter-promisify": "^1.1.0", + "jsonld-context-parser": "^2.3.3", + "jsonld-streaming-parser": "^3.2.1", + "jsonld-streaming-serializer": "^2.1.0", + "n3": "^1.17.0" }, "publishConfig": { "access": "public" diff --git a/src/common/common.mock.ts b/src/common/common.mock.ts index 4befe6f9..0bac7662 100644 --- a/src/common/common.mock.ts +++ b/src/common/common.mock.ts @@ -45,9 +45,15 @@ export type CredentialClaims = VerifiableClaims & { }; export const defaultVerifiableClaims: VerifiableClaims = { - "@context": { ex: "https://example.org/ns/" }, - id: "ex:someCredentialInstance", - type: [...defaultCredentialTypes, "ex:spaceDogCertificate"], + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://schema.inrupt.com/credentials/v1.jsonld", + ], + id: "https://example.org/ns/someCredentialInstance", + type: [ + ...defaultCredentialTypes, + "https://example.org/ns/spaceDogCertificate", + ], proofType: "Ed25519Signature2018", proofCreated: "2021-08-19T16:08:31Z", proofVerificationMethod: @@ -63,16 +69,19 @@ export const defaultCredentialClaims: CredentialClaims = { issuanceDate: "1960-08-19T16:08:31Z", subjectId: "https://some.webid.provider/strelka", subjectClaims: { - "ex:status": "https://example.org/ns/GoodDog", - "ex:passengerOf": "https://example.org/ns/Korabl-Sputnik2", + "https://example.org/ns/status": "https://example.org/ns/GoodDog", + "https://example.org/ns/passengerOf": + "https://example.org/ns/Korabl-Sputnik2", }, }; export const mockPartialCredential = ( claims?: Partial, + id?: string, ): Record => { return { - id: claims?.id, + "@context": claims?.["@context"], + id: id ?? claims?.id, type: claims?.type, issuer: claims?.issuer, issuanceDate: claims?.issuanceDate, @@ -90,14 +99,39 @@ export const mockPartialCredential = ( }; }; +export const mockPartialCredential2Proofs = ( + claims?: Partial, + id?: string, +): Record => { + return { + ...mockPartialCredential(claims, id), + proof: [ + mockPartialCredential(claims, id).proof, + mockPartialCredential(claims, id).proof, + ], + }; +}; + export const mockCredential = ( claims: CredentialClaims, ): VerifiableCredential => { return mockPartialCredential(claims) as VerifiableCredential; }; -export const mockDefaultCredential = (): VerifiableCredential => { - return mockPartialCredential(defaultCredentialClaims) as VerifiableCredential; +export const mockDefaultCredential = (id?: string): VerifiableCredential => { + return mockPartialCredential( + defaultCredentialClaims, + id, + ) as VerifiableCredential; +}; + +export const mockDefaultCredential2Proofs = ( + id?: string, +): VerifiableCredential => { + return mockPartialCredential2Proofs( + defaultCredentialClaims, + id, + ) as VerifiableCredential; }; export const mockPartialPresentation = ( diff --git a/src/common/common.test.ts b/src/common/common.test.ts index c1f79147..b5f78411 100644 --- a/src/common/common.test.ts +++ b/src/common/common.test.ts @@ -18,16 +18,20 @@ // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // - import { jest, describe, it, expect } from "@jest/globals"; import { Response } from "@inrupt/universal-fetch"; import type * as UniversalFetch from "@inrupt/universal-fetch"; +import { isomorphic } from "rdf-isomorphic"; +import { DataFactory, Store } from "n3"; import type { VerifiableCredential } from "./common"; import { concatenateContexts, getVerifiableCredential, + getVerifiableCredentialFromResponse, + getVerifiableCredentialFromStore, isVerifiableCredential, isVerifiablePresentation, + normalizeVc, } from "./common"; import { defaultCredentialClaims, @@ -36,7 +40,9 @@ import { mockDefaultPresentation, mockPartialPresentation, defaultVerifiableClaims, + mockDefaultCredential2Proofs, } from "./common.mock"; +import { jsonLdStringToStore, jsonLdToStore } from "../parser/jsonld"; jest.mock("@inrupt/universal-fetch", () => { const fetchModule = jest.requireActual( @@ -44,10 +50,19 @@ jest.mock("@inrupt/universal-fetch", () => { ) as typeof UniversalFetch; return { ...fetchModule, - fetch: jest.fn<(typeof UniversalFetch)["fetch"]>(), + fetch: jest.fn<(typeof UniversalFetch)["fetch"]>(() => { + throw new Error("Fetch should not be called"); + }), }; }); +describe("normalizeVc", () => { + it("returns the same object", () => { + const obj = {}; + expect(normalizeVc(obj)).toEqual(obj); + }); +}); + describe("isVerifiableCredential", () => { it("returns true if all the expected fields are present in the credential", () => { expect(isVerifiableCredential(mockDefaultCredential())).toBe(true); @@ -265,7 +280,9 @@ describe("getVerifiableCredential", () => { "@inrupt/universal-fetch", ) as jest.Mocked; mockedFetchModule.fetch.mockResolvedValueOnce( - new Response(JSON.stringify(mockDefaultCredential())), + new Response(JSON.stringify(mockDefaultCredential()), { + headers: new Headers([["content-type", "application/json"]]), + }), ); const redirectUrl = new URL("https://redirect.url"); @@ -286,7 +303,9 @@ describe("getVerifiableCredential", () => { const mockedFetch = jest .fn<(typeof UniversalFetch)["fetch"]>() .mockResolvedValueOnce( - new Response(JSON.stringify(mockDefaultCredential())), + new Response(JSON.stringify(mockDefaultCredential()), { + headers: new Headers([["content-type", "application/json"]]), + }), ); await getVerifiableCredential("https://some.vc", { @@ -335,16 +354,901 @@ describe("getVerifiableCredential", () => { ).rejects.toThrow(/https:\/\/some.vc.*Verifiable Credential/); }); - it("returns the fetched VC and the redirect URL", async () => { + it("throws if the dereferenced data has an unsupported content type", async () => { const mockedFetch = jest .fn<(typeof UniversalFetch)["fetch"]>() .mockResolvedValueOnce( new Response(JSON.stringify(mockDefaultCredential())), ); + await expect( + getVerifiableCredential("https://some.vc", { + fetch: mockedFetch, + }), + ).rejects.toThrow(/unsupported Content-Type/); + }); + + it("throws if the dereferenced data is empty", async () => { + const mockedFetch = jest + .fn<(typeof UniversalFetch)["fetch"]>() + .mockResolvedValueOnce( + new Response(JSON.stringify({}), { + headers: new Headers([["content-type", "application/json"]]), + }), + ); + + await expect( + getVerifiableCredential("https://some.vc", { + fetch: mockedFetch, + }), + ).rejects.toThrow( + /Expected exactly one Verifiable Credential.* received: 0/, + ); + }); + + it("throws if the vc is a blank node", async () => { + const mockedFetch = jest + .fn<(typeof UniversalFetch)["fetch"]>() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + "@type": "https://www.w3.org/2018/credentials#VerifiableCredential", + }), + { + headers: new Headers([["content-type", "application/json"]]), + }, + ), + ); + + await expect( + getVerifiableCredential("https://some.vc", { + fetch: mockedFetch, + }), + ).rejects.toThrow( + "Expected the Verifiable Credential in [https://some.vc] to be a Named Node, received: BlankNode", + ); + }); + + it("throws if the vc has a type that is a literal", async () => { + const mockedFetch = jest + .fn<(typeof UniversalFetch)["fetch"]>() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + "@context": "https://www.w3.org/2018/credentials/v1", + "@id": "http://example.org/my/vc", + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type": [ + { + "@id": + "https://www.w3.org/2018/credentials#VerifiableCredential", + }, + "str", + ], + }), + { + headers: new Headers([["content-type", "application/json"]]), + }, + ), + ); + + await expect( + getVerifiableCredential("https://some.vc", { + fetch: mockedFetch, + }), + ).rejects.toThrow( + "Expected all VC types to be Named Nodes but received [str] of termType [Literal]", + ); + }); + + it("throws if the dereferenced data has 2 vcs", async () => { + const mockedFetch = jest + .fn<(typeof UniversalFetch)["fetch"]>() + .mockResolvedValueOnce( + new Response( + JSON.stringify([ + mockDefaultCredential(), + mockDefaultCredential("http://example.org/mockVC2"), + ]), + { + headers: new Headers([["content-type", "application/json"]]), + }, + ), + ); + + await expect( + getVerifiableCredential("https://some.vc", { + fetch: mockedFetch, + }), + ).rejects.toThrow( + /Expected exactly one Verifiable Credential.* received: 2/, + ); + }); + + it("throws if the dereferenced data has 2 proofs", async () => { + const mockedFetch = jest + .fn<(typeof UniversalFetch)["fetch"]>() + .mockResolvedValueOnce( + new Response(JSON.stringify(mockDefaultCredential2Proofs()), { + headers: new Headers([["content-type", "application/json"]]), + }), + ); + + await expect( + getVerifiableCredential("https://some.vc", { + fetch: mockedFetch, + }), + ).rejects.toThrow( + /Expected exactly one \[https:\/\/w3id.org\/security#proof\].* received: 2/, + ); + }); + + it("throws if the date field is not a valid xsd:dateTime", async () => { + const mocked = mockDefaultCredential(); + mocked.issuanceDate = "http://example.org/not/a/date"; + + const mockedFetch = jest + .fn<(typeof UniversalFetch)["fetch"]>() + .mockResolvedValueOnce( + new Response(JSON.stringify(mocked), { + headers: new Headers([["content-type", "application/json"]]), + }), + ); + + await expect( + getVerifiableCredential("https://some.vc", { + fetch: mockedFetch, + }), + ).rejects.toThrow( + /Invalid dateTime in VC \[http:\/\/example.org\/not\/a\/date\]/, + ); + }); + + it("throws if the date field is a string", async () => { + const mocked = mockDefaultCredential(); + // @ts-expect-error issuanceDate is required on the VC type + delete mocked.issuanceDate; + mocked["https://www.w3.org/2018/credentials#issuanceDate"] = + "http://example.org/not/a/date"; + + const mockedFetch = jest + .fn<(typeof UniversalFetch)["fetch"]>() + .mockResolvedValueOnce( + new Response(JSON.stringify(mocked), { + headers: new Headers([["content-type", "application/json"]]), + }), + ); + + await expect( + getVerifiableCredential("https://some.vc", { + fetch: mockedFetch, + }), + ).rejects.toThrow( + /Expected issuanceDate to have dataType \[http:\/\/www.w3.org\/2001\/XMLSchema#dateTime\], received: \[http:\/\/www.w3.org\/2001\/XMLSchema#string\]/, + ); + }); + + it("throws if the date field is an IRI", async () => { + const mocked = mockDefaultCredential(); + // @ts-expect-error issuanceDate is required on the VC type + delete mocked.issuanceDate; + mocked["https://www.w3.org/2018/credentials#issuanceDate"] = { + "@id": "http://example.org/not/a/date", + }; + + const mockedFetch = jest + .fn<(typeof UniversalFetch)["fetch"]>() + .mockResolvedValueOnce( + new Response(JSON.stringify(mocked), { + headers: new Headers([["content-type", "application/json"]]), + }), + ); + + await expect( + getVerifiableCredential("https://some.vc", { + fetch: mockedFetch, + }), + ).rejects.toThrow( + /Expected issuanceDate to be a Literal, received: NamedNode/, + ); + }); + + it("throws if the issuer is a string", async () => { + const mocked = mockDefaultCredential(); + // @ts-expect-error issuer is of type string on the VC type + mocked.issuer = { "@value": "my string" }; + + const mockedFetch = jest + .fn<(typeof UniversalFetch)["fetch"]>() + .mockResolvedValueOnce( + new Response(JSON.stringify(mocked), { + headers: new Headers([["content-type", "application/json"]]), + }), + ); + + await expect( + getVerifiableCredential("https://some.vc", { + fetch: mockedFetch, + }), + ).rejects.toThrow( + "Expected property [https://www.w3.org/2018/credentials#issuer] of the Verifiable Credential [https://example.org/ns/someCredentialInstance] to be a NamedNode, received: Literal", + ); + }); + + it("should error when there is a graph in the credential subject", async () => { + const mocked = mockDefaultCredential(); + mocked.credentialSubject = { + ...mocked.credentialSubject, + "https://example.org/ns/passengerOf": [ + { "@id": "http://example.org/v1" }, + { "@id": "http://example.org/v2" }, + { + "https://example.org/ns/predicate": "str", + }, + ], + }; + + const store = await jsonLdToStore(mocked); + store.add( + DataFactory.quad( + DataFactory.namedNode("https://some.webid.provider/strelka"), + DataFactory.namedNode("https://example.org/predicate"), + // @ts-expect-error DefaultGraph is not allowed as an object + DataFactory.defaultGraph(), + ), + ); + + await expect( + getVerifiableCredentialFromStore(store, "https://some.vc"), + ).rejects.toThrow("Unexpected term type: DefaultGraph"); + }); + + it("should error when there is a non-NamedNode predicate in the credential subject", async () => { + const mocked = mockDefaultCredential(); + mocked.credentialSubject = { + ...mocked.credentialSubject, + "https://example.org/ns/passengerOf": [ + { "@id": "http://example.org/v1" }, + { "@id": "http://example.org/v2" }, + { + "https://example.org/ns/predicate": "str", + }, + ], + }; + + const store = await jsonLdToStore(mocked); + store.add( + DataFactory.quad( + DataFactory.namedNode("https://some.webid.provider/strelka"), + // @ts-expect-error Literal is not allowed as a subject + DataFactory.literal("https://example.org/predicate"), + DataFactory.namedNode("https://example.org/ns/object"), + ), + ); + + await expect( + getVerifiableCredentialFromStore(store, "https://some.vc"), + ).rejects.toThrow("Predicate must be a namedNode"); + }); + + it("should handle credential subjects with multiple objects", async () => { + const mocked = mockDefaultCredential(); + mocked.credentialSubject = { + ...mocked.credentialSubject, + "https://example.org/ns/passengerOf": [ + { "@id": "http://example.org/v1" }, + { "@id": "http://example.org/v2" }, + ], + "https://example.org/my/predicate/i": { + "https://example.org/my/predicate": "object", + }, + }; + + const mockedFetch = jest + .fn<(typeof UniversalFetch)["fetch"]>() + .mockResolvedValueOnce( + new Response(JSON.stringify(mocked), { + headers: new Headers([["content-type", "application/json"]]), + }), + ); + + const vc = await getVerifiableCredential("https://some.vc", { + fetch: mockedFetch, + }); + + const result = { + ...mocked, + credentialSubject: { + ...mocked.credentialSubject, + // All objects are fully expanded to use @value + "https://example.org/ns/status": { + "@value": "https://example.org/ns/GoodDog", + }, + // This is how blank nodes are represented + "https://example.org/my/predicate/i": { + "@id": "_:b1", + "https://example.org/my/predicate": { + "@value": "object", + }, + }, + }, + type: [ + // Unknown types like http://example.org/spaceDog are excluded + "VerifiableCredential", + ], + }; + + // Since we have dataset properties in vc it should match the result + // but won't equal + expect(vc).toMatchObject(result); + // However we DO NOT want these properties showing up when we stringify + // the VC + expect(JSON.parse(JSON.stringify(vc))).toEqual(result); + }); + + it("should error if more than 2 subjects in proof graph", async () => { + const store = new Store([ + DataFactory.quad( + DataFactory.namedNode("http://example.org/vc"), + DataFactory.namedNode("https://w3id.org/security#proof"), + DataFactory.namedNode("http://example.org/proofGraph"), + ), + DataFactory.quad( + DataFactory.namedNode("http://example.org/s"), + DataFactory.namedNode("http://example.org/p"), + DataFactory.namedNode("http://example.org/o"), + DataFactory.namedNode("http://example.org/proofGraph"), + ), + DataFactory.quad( + DataFactory.namedNode("http://example.org/s2"), + DataFactory.namedNode("http://example.org/p2"), + DataFactory.namedNode("http://example.org/o2"), + DataFactory.namedNode("http://example.org/proofGraph"), + ), + DataFactory.quad( + DataFactory.namedNode("http://example.org/vc"), + DataFactory.namedNode( + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + ), + DataFactory.namedNode( + "https://www.w3.org/2018/credentials#VerifiableCredential", + ), + ), + ]); + + await expect( + getVerifiableCredentialFromStore(store, "http://example.org/vc"), + ).rejects.toThrow( + "Expected exactly one proof to live in the proofs graph, received 2", + ); + }); + + it("should error if proof graph made about different subject", async () => { + const store = new Store([ + DataFactory.quad( + DataFactory.namedNode("http://example.org/vc2"), + DataFactory.namedNode("https://w3id.org/security#proof"), + DataFactory.namedNode("http://example.org/proofGraph"), + ), + DataFactory.quad( + DataFactory.namedNode("http://example.org/s"), + DataFactory.namedNode("http://example.org/p"), + DataFactory.namedNode("http://example.org/o"), + DataFactory.namedNode("http://example.org/proofGraph"), + ), + DataFactory.quad( + DataFactory.namedNode("http://example.org/s2"), + DataFactory.namedNode("http://example.org/p2"), + DataFactory.namedNode("http://example.org/o2"), + DataFactory.namedNode("http://example.org/proofGraph"), + ), + DataFactory.quad( + DataFactory.namedNode("http://example.org/vc"), + DataFactory.namedNode( + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + ), + DataFactory.namedNode( + "https://www.w3.org/2018/credentials#VerifiableCredential", + ), + ), + ]); + + await expect( + getVerifiableCredentialFromStore(store, "http://example.org/vc"), + ).rejects.toThrow( + "Expected exactly one [https://w3id.org/security#proof] for the Verifiable Credential http://example.org/vc, received: 0", + ); + }); + + it("throws if there are 2 proof values", async () => { + const mocked = mockDefaultCredential(); + // @ts-expect-error proofValue is a string not string[] in VC type + mocked.proof.proofValue = [mocked.proof.proofValue, "abc"]; + + const mockedFetch = jest + .fn<(typeof UniversalFetch)["fetch"]>() + .mockResolvedValueOnce( + new Response(JSON.stringify(mocked), { + headers: new Headers([["content-type", "application/json"]]), + }), + ); + + await expect( + getVerifiableCredential("https://some.vc", { + fetch: mockedFetch, + }), + ).rejects.toThrow( + /Expected exactly one \[https:\/\/w3id.org\/security#proofValue\] for the Verifiable Credential https:\/\/example.org\/ns\/someCredentialInstance, received: 2/, + ); + }); + + it("returns the fetched VC and the redirect URL", async () => { + const mockedFetch = jest + .fn<(typeof UniversalFetch)["fetch"]>() + .mockResolvedValueOnce( + new Response(JSON.stringify(mockDefaultCredential()), { + headers: new Headers([["content-type", "application/json"]]), + }), + ); + const vc = await getVerifiableCredential("https://some.vc", { fetch: mockedFetch, }); - expect(vc).toStrictEqual(mockDefaultCredential()); + + const res = await jsonLdStringToStore( + JSON.stringify(mockDefaultCredential()), + ); + expect(vc).toMatchObject( + Object.assign(mockDefaultCredential(), { + size: 13, + // We always re-frame w.r.t to this context + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://schema.inrupt.com/credentials/v1.jsonld", + ], + // The credentials subject is re-framed to make the fact that the + // objects are literals explicit + credentialSubject: { + "https://example.org/ns/passengerOf": { + "@value": "https://example.org/ns/Korabl-Sputnik2", + }, + "https://example.org/ns/status": { + "@value": "https://example.org/ns/GoodDog", + }, + id: "https://some.webid.provider/strelka", + }, + // Any types outside of those in our VC and Inrupt context are removed + type: ["VerifiableCredential"], + }), + ); + + const meaninglessQuad = DataFactory.quad( + DataFactory.namedNode("http://example.org/a"), + DataFactory.namedNode("http://example.org/b"), + DataFactory.namedNode("http://example.org/c"), + ); + + const issuerQuad = DataFactory.quad( + DataFactory.namedNode("https://example.org/ns/someCredentialInstance"), + DataFactory.namedNode("https://www.w3.org/2018/credentials#issuer"), + DataFactory.namedNode("https://some.vc.issuer/in-ussr"), + ); + + expect(isomorphic([...vc], [...res])).toBe(true); + expect(isomorphic([...vc.match()], [...res])).toBe(true); + expect(() => vc.add(meaninglessQuad)).toThrow("Cannot mutate this dataset"); + expect(() => vc.delete(meaninglessQuad)).toThrow( + "Cannot mutate this dataset", + ); + expect(vc.has(meaninglessQuad)).toBe(false); + expect(vc.has(issuerQuad)).toBe(true); + expect(vc.size).toBe(13); + expect( + vc.match( + DataFactory.namedNode("https://example.org/ns/someCredentialInstance"), + ).size, + ).toBe(6); + }); + + it("should handle non standard proof type", async () => { + const store = await jsonLdToStore(mockDefaultCredential()); + + for (const quad of store.match( + null, + DataFactory.namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + DataFactory.namedNode("https://w3id.org/security#Ed25519Signature2018"), + )) { + store.delete(quad); + store.add( + DataFactory.quad( + quad.subject, + quad.predicate, + DataFactory.namedNode("https://w3id.org/security#notARealSignature"), + quad.graph, + ), + ); + } + + expect( + await getVerifiableCredentialFromStore(store, "https://some.vc"), + ).toMatchObject( + Object.assign(mockDefaultCredential(), { + size: 13, + // We always re-frame w.r.t to this context + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://schema.inrupt.com/credentials/v1.jsonld", + ], + // The credentials subject is re-framed to make the fact that the + // objects are literals explicit + credentialSubject: { + "https://example.org/ns/passengerOf": { + "@value": "https://example.org/ns/Korabl-Sputnik2", + }, + "https://example.org/ns/status": { + "@value": "https://example.org/ns/GoodDog", + }, + id: "https://some.webid.provider/strelka", + }, + // Any types outside of those in our VC and Inrupt context are removed + type: ["VerifiableCredential"], + proof: { + ...mockDefaultCredential().proof, + // Proof purpose has full URI as compacting this relies on the + // context of the "type" + proofPurpose: "sec:assertionMethod", + type: "sec:notARealSignature", + }, + }), + ); + }); + + it("should apply the correct context for a given proof", async () => { + const store = await jsonLdToStore(mockDefaultCredential()); + + for (const quad of store.match( + null, + DataFactory.namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + DataFactory.namedNode("https://w3id.org/security#Ed25519Signature2018"), + )) { + store.removeQuad(quad); + store.add( + DataFactory.quad( + quad.subject, + quad.predicate, + DataFactory.namedNode( + "https://www.w3.org/2018/credentials#VerifiableCredential", + ), + quad.graph, + ), + ); + } + + expect( + await getVerifiableCredentialFromStore(store, "https://some.vc"), + ).toMatchObject( + Object.assign(mockDefaultCredential(), { + size: 13, + // We always re-frame w.r.t to this context + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://schema.inrupt.com/credentials/v1.jsonld", + ], + // The credentials subject is re-framed to make the fact that the + // objects are literals explicit + credentialSubject: { + "https://example.org/ns/passengerOf": { + "@value": "https://example.org/ns/Korabl-Sputnik2", + }, + "https://example.org/ns/status": { + "@value": "https://example.org/ns/GoodDog", + }, + id: "https://some.webid.provider/strelka", + }, + // Any types outside of those in our VC and Inrupt context are removed + type: ["VerifiableCredential"], + proof: { + ...mockDefaultCredential().proof, + // Proof purpose has a prefix because assertionMethod is not + // defined as a term in the VerifiableCredential subContext + proofPurpose: "sec:assertionMethod", + type: "VerifiableCredential", + }, + }), + ); + }); + + it("should handle multiple known types", async () => { + const store = await jsonLdToStore(mockDefaultCredential()); + + for (const quad of store.match( + null, + DataFactory.namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + DataFactory.namedNode( + "https://www.w3.org/2018/credentials#VerifiableCredential", + ), + )) { + store.add( + DataFactory.quad( + quad.subject, + quad.predicate, + DataFactory.namedNode( + "https://www.w3.org/2018/credentials#VerifiablePresentation", + ), + quad.graph, + ), + ); + } + + expect( + await getVerifiableCredentialFromStore(store, "https://some.vc"), + ).toMatchObject( + Object.assign(mockDefaultCredential(), { + size: 14, + // We always re-frame w.r.t to this context + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://schema.inrupt.com/credentials/v1.jsonld", + ], + // The credentials subject is re-framed to make the fact that the + // objects are literals explicit + credentialSubject: { + "https://example.org/ns/passengerOf": { + "@value": "https://example.org/ns/Korabl-Sputnik2", + }, + "https://example.org/ns/status": { + "@value": "https://example.org/ns/GoodDog", + }, + id: "https://some.webid.provider/strelka", + }, + // Any types outside of those in our VC and Inrupt context are removed + type: ["VerifiableCredential", "VerifiablePresentation"], + }), + ); + }); + + it("should handle credential subject with a blank node and a boolean", async () => { + const store = await jsonLdToStore(mockDefaultCredential()); + + store.add( + DataFactory.quad( + DataFactory.namedNode("https://some.webid.provider/strelka"), + DataFactory.namedNode("https://example.org/predicateBnode"), + DataFactory.blankNode("b2"), + ), + ); + + store.add( + DataFactory.quad( + DataFactory.namedNode("https://some.webid.provider/strelka"), + DataFactory.namedNode("https://example.org/predicateTrue"), + DataFactory.literal( + "true", + DataFactory.namedNode("http://www.w3.org/2001/XMLSchema#boolean"), + ), + ), + ); + + store.add( + DataFactory.quad( + DataFactory.namedNode("https://some.webid.provider/strelka"), + DataFactory.namedNode("https://example.org/predicateFalse"), + DataFactory.literal( + "false", + DataFactory.namedNode("http://www.w3.org/2001/XMLSchema#boolean"), + ), + ), + ); + + store.add( + DataFactory.quad( + DataFactory.namedNode("https://some.webid.provider/strelka"), + DataFactory.namedNode("https://example.org/predicateFalse"), + DataFactory.literal( + "false", + DataFactory.namedNode("http://www.w3.org/2001/XMLSchema#boolean"), + ), + ), + ); + + store.add( + DataFactory.quad( + DataFactory.namedNode("https://some.webid.provider/strelka"), + DataFactory.namedNode("https://w3id.org/GConsent#forPurpose"), + DataFactory.namedNode( + "http://example.org/known/to/be/iri/from/predicate", + ), + ), + ); + + store.add( + DataFactory.quad( + DataFactory.namedNode("https://some.webid.provider/strelka"), + DataFactory.namedNode("https://example.org/predicate"), + DataFactory.namedNode("https://example.org/object"), + ), + ); + + expect( + await getVerifiableCredentialFromStore(store, "https://some.vc"), + ).toMatchObject( + Object.assign(mockDefaultCredential(), { + size: 18, + // We always re-frame w.r.t to this context + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://schema.inrupt.com/credentials/v1.jsonld", + ], + // The credentials subject is re-framed to make the fact that the + // objects are literals explicit + credentialSubject: { + "https://example.org/ns/passengerOf": { + "@value": "https://example.org/ns/Korabl-Sputnik2", + }, + "https://example.org/ns/status": { + "@value": "https://example.org/ns/GoodDog", + }, + id: "https://some.webid.provider/strelka", + "https://example.org/predicate": { + "@id": "https://example.org/object", + }, + "https://example.org/predicateBnode": {}, + "https://example.org/predicateFalse": false, + "https://example.org/predicateTrue": true, + forPurpose: "http://example.org/known/to/be/iri/from/predicate", + }, + // Any types outside of those in our VC and Inrupt context are removed + type: ["VerifiableCredential"], + }), + ); + }); + + it("should handle credential subject with a self-referential blank node", async () => { + const store = await jsonLdToStore(mockDefaultCredential()); + + store.add( + DataFactory.quad( + DataFactory.namedNode("https://some.webid.provider/strelka"), + DataFactory.namedNode("https://example.org/predicateBnode"), + DataFactory.blankNode("b2"), + ), + ); + + store.add( + DataFactory.quad( + DataFactory.namedNode("https://some.webid.provider/strelka"), + DataFactory.namedNode("https://example.org/predicate"), + DataFactory.namedNode("https://example.org/object"), + ), + ); + + store.add( + DataFactory.quad( + DataFactory.blankNode("b2"), + DataFactory.namedNode("https://example.org/predicate"), + DataFactory.blankNode("b2"), + ), + ); + + expect( + await getVerifiableCredentialFromStore(store, "https://some.vc"), + ).toMatchObject( + Object.assign(mockDefaultCredential(), { + size: 16, + // We always re-frame w.r.t to this context + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://schema.inrupt.com/credentials/v1.jsonld", + ], + // The credentials subject is re-framed to make the fact that the + // objects are literals explicit + credentialSubject: { + "https://example.org/ns/passengerOf": { + "@value": "https://example.org/ns/Korabl-Sputnik2", + }, + "https://example.org/ns/status": { + "@value": "https://example.org/ns/GoodDog", + }, + id: "https://some.webid.provider/strelka", + "https://example.org/predicate": { + "@id": "https://example.org/object", + }, + "https://example.org/predicateBnode": { + "@id": "_:b1", + "https://example.org/predicate": { + "@id": "_:b1", + }, + }, + }, + // Any types outside of those in our VC and Inrupt context are removed + type: ["VerifiableCredential"], + }), + ); + }); + + it("should handle credential subject with a self-referential blank node and stand-alone blank node", async () => { + const store = await jsonLdToStore(mockDefaultCredential()); + + store.add( + DataFactory.quad( + DataFactory.namedNode("https://some.webid.provider/strelka"), + DataFactory.namedNode("https://example.org/predicateBnode"), + DataFactory.blankNode("b2"), + ), + ); + + store.add( + DataFactory.quad( + DataFactory.namedNode("https://some.webid.provider/strelka"), + DataFactory.namedNode("https://example.org/predicateBnode"), + DataFactory.blankNode("b3"), + ), + ); + + store.add( + DataFactory.quad( + DataFactory.namedNode("https://some.webid.provider/strelka"), + DataFactory.namedNode("https://example.org/predicate"), + DataFactory.namedNode("https://example.org/object"), + ), + ); + + store.add( + DataFactory.quad( + DataFactory.blankNode("b2"), + DataFactory.namedNode("https://example.org/predicate"), + DataFactory.blankNode("b2"), + ), + ); + + expect( + await getVerifiableCredentialFromStore(store, "https://some.vc"), + ).toMatchObject( + Object.assign(mockDefaultCredential(), { + size: 17, + // We always re-frame w.r.t to this context + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://schema.inrupt.com/credentials/v1.jsonld", + ], + // The credentials subject is re-framed to make the fact that the + // objects are literals explicit + credentialSubject: { + "https://example.org/ns/passengerOf": { + "@value": "https://example.org/ns/Korabl-Sputnik2", + }, + "https://example.org/ns/status": { + "@value": "https://example.org/ns/GoodDog", + }, + id: "https://some.webid.provider/strelka", + "https://example.org/predicate": { + "@id": "https://example.org/object", + }, + "https://example.org/predicateBnode": [ + { + "@id": "_:b1", + "https://example.org/predicate": { + "@id": "_:b1", + }, + }, + { + "@id": "_:b2", + }, + ], + }, + // Any types outside of those in our VC and Inrupt context are removed + type: ["VerifiableCredential"], + }), + ); + }); +}); + +describe("getVerifiableCredentialFromResponse", () => { + it("should error if the response has no content type", () => { + return expect( + getVerifiableCredentialFromResponse( + new Response(), + "https://example.org", + ), + ).rejects.toThrow("Response does not have a Content-Type"); }); }); diff --git a/src/common/common.ts b/src/common/common.ts index c8e49120..768f4d90 100644 --- a/src/common/common.ts +++ b/src/common/common.ts @@ -26,11 +26,21 @@ import type { UrlString } from "@inrupt/solid-client"; import { getIri, + getJsonLdParser, getSolidDataset, getThingAll, - getJsonLdParser, } from "@inrupt/solid-client"; import { fetch as uniFetch } from "@inrupt/universal-fetch"; +import type { DatasetCore } from "@rdfjs/types"; +import contentTypeParser from "content-type"; +import { Util } from "jsonld-streaming-serializer"; +import type { BlankNode, Store, Term } from "n3"; +import { DataFactory as DF } from "n3"; +import type { JsonLdContextNormalized } from "jsonld-context-parser"; +import { context } from "../parser/contexts"; +import VcContext from "../parser/contexts/vc"; +import type { ParseOptions } from "../parser/jsonld"; +import { getVcContext, jsonLdResponseToStore } from "../parser/jsonld"; export type Iri = string; /** @@ -239,7 +249,7 @@ export function concatenateContexts(...contexts: unknown[]): unknown { contexts.forEach((additionalContext) => { // Case when the context is an array of IRIs and/or inline contexts if (Array.isArray(additionalContext)) { - additionalContext.forEach((context) => result.add(context)); + additionalContext.forEach((contextEntry) => result.add(contextEntry)); } else if (additionalContext !== null && additionalContext !== undefined) { // Case when the context is a single remote URI or a single inline context result.add(additionalContext); @@ -396,6 +406,462 @@ export async function getVerifiableCredentialApiConfiguration( }; } +/** + * @param response Takes a response from a VC service and checks that it has the correct status and content type + * @param vcUrl The URL of the VC + * @returns The input response + */ +function validateVcResponse(response: Response, vcUrl: string): Response { + if (!response.ok) { + throw new Error( + `Fetching the Verifiable Credential [${vcUrl}] failed: ${response.status} ${response.statusText}`, + ); + } + + const contentType = response.headers.get("Content-Type"); + if (!contentType) { + throw new Error( + `Fetching the Verifiable Credential [${vcUrl}] failed: Response does not have a Content-Type header; expected application/ld+json`, + ); + } + + const parsedContentType = contentTypeParser.parse(contentType); + const [mediaType, subtypesString] = parsedContentType.type.split("/"); + const subtypes = subtypesString.split("+"); + + if (mediaType !== "application" || !subtypes.includes("json")) { + throw new Error( + `Fetching the Verifiable Credential [${vcUrl}] failed: Response has an unsupported Content-Type [${contentType}]; expected application/ld+json`, + ); + } + + return response; +} + +async function responseToVcStore( + response: Response, + vcUrl: UrlString, + options?: ParseOptions, +): Promise { + try { + return await jsonLdResponseToStore(validateVcResponse(response, vcUrl), { + ...options, + baseIRI: vcUrl, + }); + } catch (e) { + throw new Error( + `Parsing the Verifiable Credential [${vcUrl}] as JSON-LD failed: ${e}`, + ); + } +} + +const VC = "https://www.w3.org/2018/credentials#"; +const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +const XSD = "http://www.w3.org/2001/XMLSchema#"; +const SEC = "https://w3id.org/security#"; +const CRED = "https://www.w3.org/2018/credentials#"; +const RDF_TYPE = `${RDF}type`; +const VERIFIABLE_CREDENTIAL = `${VC}VerifiableCredential`; +const DATE_TIME = `${XSD}dateTime`; +const CREDENTIAL_SUBJECT = `${CRED}credentialSubject`; +const ISSUER = `${CRED}issuer`; +const ISSUANCE_DATE = `${CRED}issuanceDate`; +const PROOF = `${SEC}proof`; +const PROOF_PURPOSE = `${SEC}proofPurpose`; +const VERIFICATION_METHOD = `${SEC}verificationMethod`; +const PROOF_VALUE = `${SEC}proofValue`; + +/** + * @hidden + */ +function getSingleObject( + fullProperty: string, + vcStore: Store, + vc: Term, + subject?: Term, + graph: Term = DF.defaultGraph(), +): Term { + const objects = vcStore.getObjects(subject ?? vc, fullProperty, graph); + + if (objects.length !== 1) { + throw new Error( + `Expected exactly one [${fullProperty}] for the Verifiable Credential ${vc.value}, received: ${objects.length}`, + ); + } + + return objects[0]; +} + +/** + * @hidden + */ +function getSingleObjectOfTermType( + fullProperty: string, + vcStore: Store, + vc: Term, + subject?: Term, + graph?: Term, + termType: Term["termType"] = "NamedNode", +) { + const object = getSingleObject(fullProperty, vcStore, vc, subject, graph); + + if (object.termType !== termType) { + throw new Error( + `Expected property [${fullProperty}] of the Verifiable Credential [${vc.value}] to be a ${termType}, received: ${object.termType}`, + ); + } + + return object.value; +} + +/** + * @hidden + */ +function writeObject( + object: Term, + writtenTerms: string[], + predicate: string, + vcStore: Store, + customContext: JsonLdContextNormalized, + createBnodeId: (id: BlankNode) => string, +) { + switch (object.termType) { + case "BlankNode": { + const obj = writtenTerms.includes(object.value) + ? {} + : getProperties(object, vcStore, customContext, createBnodeId, [ + ...writtenTerms, + object.value, + ]); + + // eslint-disable-next-line no-multi-assign + // obj["@id"] = createBnodeId(object); + return obj; + } + // eslint-disable-next-line no-fallthrough + case "NamedNode": + case "Literal": { + const compact = customContext.compactIri(predicate, true); + const termContext = customContext.getContextRaw()[compact]; + const term = Util.termToValue(object, customContext, { + // If an `@type` is defined in the context, then the + // parser can determine that it is an IRI immediately + // and so we don't need to wrap it in an object with an + // `@id` entry. + compactIds: termContext && "@type" in termContext, + vocab: true, + useNativeTypes: true, + }); + + // Special case: Booleans (any any other native literals for + // that matter) don't need to be wrapped in an object with an + // `@value` key. + if (term["@value"] === true || term["@value"] === false) { + return term["@value"]; + } + return term; + } + default: + throw new Error(`Unexpected term type: ${object.termType}`); + } +} + +/** + * @hidden + */ +function getProperties( + subject: Term, + vcStore: Store, + vcContext: JsonLdContextNormalized, + createBnodeId: (id: BlankNode) => string, + writtenTerms: string[] = [], +) { + const object: Record = {}; + + for (const predicate of vcStore.getPredicates( + subject, + null, + DF.defaultGraph(), + )) { + if (predicate.termType !== "NamedNode") { + throw new Error("Predicate must be a namedNode"); + } + + const compact = vcContext.compactIri(predicate.value, true); + const objects = vcStore + .getObjects(subject, predicate, DF.defaultGraph()) + // writeObject and getProperties depend on each other circularly + // eslint-disable-next-line @typescript-eslint/no-use-before-define + .map((obj) => + writeObject( + obj, + writtenTerms, + predicate.value, + vcStore, + vcContext, + createBnodeId, + ), + ) + .filter((obj) => typeof obj !== "object" || Object.keys(obj).length >= 1); + + if (objects.length === 1) { + [object[compact]] = objects; + } else if (objects.length > 1) { + object[compact] = objects; + } + } + + return object; +} + +/** + * @hidden + */ +function createBnodeIdFactory() { + let i = 0; + const data: Record = {}; + // eslint-disable-next-line no-return-assign, no-multi-assign + return (term: BlankNode) => `_:b${(data[term.value] ??= i += 1)}`; +} + +/** + * @hidden + */ +function getSingleDateTime( + fullProperty: string, + vcStore: Store, + vc: Term, + subject?: Term, + graph?: Term, +) { + const object = getSingleObject(fullProperty, vcStore, vc, subject, graph); + + if (object.termType !== "Literal") { + throw new Error( + `Expected issuanceDate to be a Literal, received: ${object.termType}`, + ); + } + if (!object.datatype.equals(DF.namedNode(DATE_TIME))) { + throw new Error( + `Expected issuanceDate to have dataType [${DATE_TIME}], received: [${object.datatype.value}]`, + ); + } + + if (Number.isNaN(Date.parse(object.value))) { + throw new Error(`Invalid dateTime in VC [${object.value}]`); + } + + return object.value; +} + +/** + * @hidden + */ +export async function getVerifiableCredentialFromStore( + vcStore: Store, + vcUrl: UrlString, +): Promise { + const createBnodeId = createBnodeIdFactory(); + let vcContext = await getVcContext(); + + const vcs = vcStore.getSubjects( + RDF_TYPE, + VERIFIABLE_CREDENTIAL, + DF.defaultGraph(), + ); + if (vcs.length !== 1) { + throw new Error( + `Expected exactly one Verifiable Credential in [${vcUrl}], received: ${vcs.length}`, + ); + } + + const [vc] = vcs; + if (vc.termType !== "NamedNode") { + throw new Error( + `Expected the Verifiable Credential in [${vcUrl}] to be a Named Node, received: ${vc.termType}`, + ); + } + + const type: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const typeContexts: any[] = []; + + for (const t of vcStore.getObjects(vc, RDF_TYPE, DF.defaultGraph())) { + if (t.termType !== "NamedNode") { + throw new Error( + `Expected all VC types to be Named Nodes but received [${t.value}] of termType [${t.termType}]`, + ); + } + + // Note that compact IRI by definition is of the form prefx:suffix so that might be the reason + // that some of the very short forms of the IRIs are not showing up here + const compact = vcContext.compactIri(t.value, true); + + if (compact in VcContext["@context"]) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + typeContexts.push((VcContext as any)["@context"][compact]["@context"]); + } + + if (/^[a-z]+$/i.test(compact)) type.push(compact); + } + + // This allows any context specific to the type of the things that we are re-framing to be applied + vcContext = await getVcContext(...typeContexts); + + // The proof lives within a named graph + const proofGraph = getSingleObject(PROOF, vcStore, vc); + const proofs = vcStore.getSubjects(null, null, proofGraph); + + if (proofs.length !== 1) { + throw new Error( + `Expected exactly one proof to live in the proofs graph, received ${proofs.length}`, + ); + } + + const [proof] = proofs; + const proofType = getSingleObjectOfTermType( + RDF_TYPE, + vcStore, + vc, + proof, + proofGraph, + ); + + const proposedContextTemp = + VcContext["@context"][ + vcContext.compactIri( + proofType, + true, + ) as keyof (typeof VcContext)["@context"] + ]; + const proposedContext = + typeof proposedContextTemp === "object" && + "@context" in proposedContextTemp && + proposedContextTemp["@context"]; + + let proofContext = vcContext; + let proofPurposeContext = vcContext; + + if (typeof proposedContext === "object") { + proofContext = await getVcContext(proposedContext, ...typeContexts); + if ( + "proofPurpose" in proposedContext && + typeof proposedContext.proofPurpose["@context"] === "object" + ) { + proofPurposeContext = await getVcContext( + proposedContext, + proposedContext.proofPurpose["@context"], + ...typeContexts, + ); + } else { + proofPurposeContext = proofContext; + } + } + + const credentialSubjectTerm = getSingleObjectOfTermType( + CREDENTIAL_SUBJECT, + vcStore, + vc, + ); + + const proofArgs = [vcStore, vc, proof, proofGraph] as const; + const res: VerifiableCredential & DatasetCore = { + "@context": context, + id: vc.value, + // It is possible to have multiple claims in a credential subject + // we do not support this + // https://www.w3.org/TR/vc-data-model/#example-specifying-multiple-subjects-in-a-verifiable-credential + credentialSubject: { + ...getProperties( + DF.namedNode(credentialSubjectTerm), + vcStore, + vcContext, + createBnodeId, + ), + id: credentialSubjectTerm, + }, + issuer: getSingleObjectOfTermType(ISSUER, vcStore, vc), + issuanceDate: getSingleDateTime(ISSUANCE_DATE, vcStore, vc), + type, + proof: { + created: getSingleDateTime( + "http://purl.org/dc/terms/created", + ...proofArgs, + ), + proofPurpose: proofPurposeContext.compactIri( + getSingleObjectOfTermType(PROOF_PURPOSE, ...proofArgs), + true, + ), + type: proofContext.compactIri(proofType, true), + verificationMethod: proofContext.compactIri( + getSingleObjectOfTermType(VERIFICATION_METHOD, ...proofArgs), + true, + ), + proofValue: getSingleObjectOfTermType( + PROOF_VALUE, + ...proofArgs, + "Literal", + ), + }, + + // Make this a DatasetCore without polluting the object with + // all of the properties present in the N3.Store + [Symbol.iterator]() { + return vcStore[Symbol.iterator](); + }, + has(quad) { + return vcStore.has(quad); + }, + match(subject, predicate, object, graph) { + // We need to cast to DatasetCore because the N3.Store + // type uses an internal type for Term rather than the @rdfjs/types Term + return (vcStore as DatasetCore).match(subject, predicate, object, graph); + }, + add() { + throw new Error("Cannot mutate this dataset"); + }, + delete() { + throw new Error("Cannot mutate this dataset"); + }, + get size() { + return vcStore.size; + }, + // For backwards compatibility the dataset properties + // SHOULD NOT be included when we JSON.stringify the object + toJSON() { + return { + "@context": this["@context"], + id: this.id, + credentialSubject: this.credentialSubject, + issuer: this.issuer, + issuanceDate: this.issuanceDate, + type: this.type, + proof: this.proof, + }; + }, + }; + + return res; +} + +/** + * Dereference a VC URL, and verify that the resulting content is valid. + * + * @param vcUrl The URL of the VC. + * @param options Options to customize the function behavior. + * - options.fetch: Specify a WHATWG-compatible authenticated fetch. + * @returns The dereferenced VC if valid. Throws otherwise. + * @since 0.4.0 + */ +export async function getVerifiableCredentialFromResponse( + response: Response, + vcUrl: UrlString, + options?: ParseOptions, +): Promise { + const vcStore = await responseToVcStore(response, vcUrl, options); + return getVerifiableCredentialFromStore(vcStore, vcUrl); +} + /** * Dereference a VC URL, and verify that the resulting content is valid. * @@ -407,32 +873,9 @@ export async function getVerifiableCredentialApiConfiguration( */ export async function getVerifiableCredential( vcUrl: UrlString, - options?: Partial<{ - fetch: typeof fetch; - }>, -): Promise { + options?: ParseOptions, +): Promise { const authFetch = options?.fetch ?? uniFetch; - return authFetch(vcUrl as string) - .then(async (response) => { - if (!response.ok) { - throw new Error( - `Fetching the Verifiable Credential [${vcUrl}] failed: ${response.status} ${response.statusText}`, - ); - } - try { - return normalizeVc(await response.json()); - } catch (e) { - throw new Error( - `Parsing the Verifiable Credential [${vcUrl}] as JSON failed: ${e}`, - ); - } - }) - .then((vc) => { - if (!isVerifiableCredential(vc)) { - throw new Error( - `The value received from [${vcUrl}] is not a Verifiable Credential`, - ); - } - return vc; - }); + const response = await authFetch(vcUrl); + return getVerifiableCredentialFromResponse(response, vcUrl, options); } diff --git a/src/index.test.ts b/src/index.test.ts index d7732fa0..cce31320 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -26,6 +26,7 @@ import { isVerifiablePresentation, getVerifiableCredential, getVerifiableCredentialApiConfiguration, + getVerifiableCredentialFromResponse, } from "./common/common"; import getVerifiableCredentialAllFromShape from "./lookup/derive"; import revokeVerifiableCredential from "./revoke/revoke"; @@ -36,6 +37,7 @@ describe("exports", () => { it("includes all of the expected functions", () => { expect(Object.keys(packageExports)).toEqual([ "issueVerifiableCredential", + "getVerifiableCredentialFromResponse", "isVerifiableCredential", "isVerifiablePresentation", "getVerifiableCredential", @@ -49,6 +51,9 @@ describe("exports", () => { expect(packageExports.issueVerifiableCredential).toBe( issueVerifiableCredential, ); + expect(packageExports.getVerifiableCredentialFromResponse).toBe( + getVerifiableCredentialFromResponse, + ); expect(packageExports.isVerifiableCredential).toBe(isVerifiableCredential); expect(packageExports.isVerifiablePresentation).toBe( isVerifiablePresentation, diff --git a/src/index.ts b/src/index.ts index e98d64f9..e5ba2ed3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ export { default as issueVerifiableCredential } from "./issue/issue"; export type { Iri, JsonLd, VerifiableCredential } from "./common/common"; +export { getVerifiableCredentialFromResponse } from "./common/common"; export { isVerifiableCredential, isVerifiablePresentation, diff --git a/src/issue/issue.test.ts b/src/issue/issue.test.ts index 6229701b..51082b76 100644 --- a/src/issue/issue.test.ts +++ b/src/issue/issue.test.ts @@ -113,7 +113,10 @@ describe("issueVerifiableCredential", () => { const mockedFetch = jest .fn<(typeof UniversalFetch)["fetch"]>() .mockResolvedValueOnce( - new Response(JSON.stringify(mockDefaultCredential()), { status: 201 }), + new Response(JSON.stringify(mockDefaultCredential()), { + status: 201, + headers: new Headers([["content-type", "application/ld+json"]]), + }), ); await expect( issueVerifiableCredential( @@ -122,7 +125,19 @@ describe("issueVerifiableCredential", () => { { "@context": ["https://some.context"] }, { fetch: mockedFetch }, ), - ).resolves.toEqual(mockDefaultCredential()); + ).resolves.toMatchObject({ + ...mockDefaultCredential(), + credentialSubject: { + ...mockDefaultCredential().credentialSubject, + "https://example.org/ns/passengerOf": { + "@value": "https://example.org/ns/Korabl-Sputnik2", + }, + "https://example.org/ns/status": { + "@value": "https://example.org/ns/GoodDog", + }, + }, + type: ["VerifiableCredential"], + }); }); it("sends a request to the specified issuer", async () => { @@ -396,6 +411,7 @@ describe("issueVerifiableCredential", () => { const mockedFetch = jest.fn().mockResolvedValueOnce( new Response(JSON.stringify(mockedVc), { status: 201, + headers: new Headers([["content-type", "application/json"]]), }), ); const resultVc = await issueVerifiableCredential( diff --git a/src/issue/issue.ts b/src/issue/issue.ts index c8a1fd13..9a21c16d 100644 --- a/src/issue/issue.ts +++ b/src/issue/issue.ts @@ -25,13 +25,13 @@ import { fetch as fallbackFetch } from "@inrupt/universal-fetch"; +import type { DatasetCore } from "@rdfjs/types"; import type { Iri, JsonLd, VerifiableCredential } from "../common/common"; import { - isVerifiableCredential, concatenateContexts, defaultContext, defaultCredentialTypes, - normalizeVc, + getVerifiableCredentialFromResponse, } from "../common/common"; type OptionsType = { @@ -47,7 +47,7 @@ async function internal_issueVerifiableCredential( subjectClaims: JsonLd, credentialClaims?: JsonLd, options?: OptionsType, -): Promise { +): Promise { const internalOptions = { ...options }; if (internalOptions.fetch === undefined) { internalOptions.fetch = fallbackFetch; @@ -101,17 +101,15 @@ async function internal_issueVerifiableCredential( `The VC issuing endpoint [${issuerEndpoint}] could not successfully issue a VC: ${response.status} ${response.statusText}`, ); } - const jsonData = normalizeVc(await response.json()); - if (isVerifiableCredential(jsonData)) { - return jsonData; + + try { + // FIXME: Check response.url as the second arg + return await getVerifiableCredentialFromResponse(response, response.url); + } catch (e) { + throw new Error( + `The VC issuing endpoint [${issuerEndpoint}] returned an unexpected object: ${e}`, + ); } - throw new Error( - `The VC issuing endpoint [${issuerEndpoint}] returned an unexpected object: ${JSON.stringify( - jsonData, - null, - " ", - )}`, - ); } /** @@ -134,7 +132,7 @@ export async function issueVerifiableCredential( subjectClaims: JsonLd, credentialClaims?: JsonLd, options?: OptionsType, -): Promise; +): Promise; /** * @deprecated Please remove the `subjectId` parameter */ @@ -144,7 +142,7 @@ export async function issueVerifiableCredential( subjectClaims: JsonLd, credentialClaims?: JsonLd, options?: OptionsType, -): Promise; +): Promise; // The signature of the implementation here is a bit confusing, but it avoid // breaking changes until we remove the `subjectId` completely from the API export async function issueVerifiableCredential( @@ -153,7 +151,7 @@ export async function issueVerifiableCredential( subjectOrCredentialClaims: JsonLd | undefined, credentialClaimsOrOptions?: JsonLd | OptionsType, options?: OptionsType, -): Promise { +): Promise { if (typeof subjectIdOrClaims === "string") { // The function has been called with the deprecated signature, and the // subjectOrCredentialClaims parameter should be ignored. diff --git a/src/lookup/query.ts b/src/lookup/query.ts index 7d225be1..0d7ace55 100644 --- a/src/lookup/query.ts +++ b/src/lookup/query.ts @@ -119,6 +119,7 @@ export async function query( let data; try { + // FIXME: Perform parsing here data = normalizeVp(await response.json()); } catch (e) { throw new Error( diff --git a/src/parser/contexts/data-integrity.ts b/src/parser/contexts/data-integrity.ts new file mode 100644 index 00000000..c9898768 --- /dev/null +++ b/src/parser/contexts/data-integrity.ts @@ -0,0 +1,94 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +export default { + "@context": { + id: "@id", + type: "@type", + "@protected": true, + proof: { + "@id": "https://w3id.org/security#proof", + "@type": "@id", + "@container": "@graph", + }, + DataIntegrityProof: { + "@id": "https://w3id.org/security#DataIntegrityProof", + "@context": { + "@protected": true, + id: "@id", + type: "@type", + challenge: "https://w3id.org/security#challenge", + created: { + "@id": "http://purl.org/dc/terms/created", + "@type": "http://www.w3.org/2001/XMLSchema#dateTime", + }, + domain: "https://w3id.org/security#domain", + expires: { + "@id": "https://w3id.org/security#expiration", + "@type": "http://www.w3.org/2001/XMLSchema#dateTime", + }, + nonce: "https://w3id.org/security#nonce", + proofPurpose: { + "@id": "https://w3id.org/security#proofPurpose", + "@type": "@vocab", + "@context": { + "@protected": true, + id: "@id", + type: "@type", + assertionMethod: { + "@id": "https://w3id.org/security#assertionMethod", + "@type": "@id", + "@container": "@set", + }, + authentication: { + "@id": "https://w3id.org/security#authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + capabilityInvocation: { + "@id": "https://w3id.org/security#capabilityInvocationMethod", + "@type": "@id", + "@container": "@set", + }, + capabilityDelegation: { + "@id": "https://w3id.org/security#capabilityDelegationMethod", + "@type": "@id", + "@container": "@set", + }, + keyAgreement: { + "@id": "https://w3id.org/security#keyAgreementMethod", + "@type": "@id", + "@container": "@set", + }, + }, + }, + cryptosuite: "https://w3id.org/security#cryptosuite", + proofValue: { + "@id": "https://w3id.org/security#proofValue", + "@type": "https://w3id.org/security#multibase", + }, + verificationMethod: { + "@id": "https://w3id.org/security#verificationMethod", + "@type": "@id", + }, + }, + }, + }, +}; diff --git a/src/parser/contexts/ed25519-2020.ts b/src/parser/contexts/ed25519-2020.ts new file mode 100644 index 00000000..b65eb596 --- /dev/null +++ b/src/parser/contexts/ed25519-2020.ts @@ -0,0 +1,113 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +export default { + "@context": { + id: "@id", + type: "@type", + "@protected": true, + proof: { + "@id": "https://w3id.org/security#proof", + "@type": "@id", + "@container": "@graph", + }, + Ed25519VerificationKey2020: { + "@id": "https://w3id.org/security#Ed25519VerificationKey2020", + "@context": { + "@protected": true, + id: "@id", + type: "@type", + controller: { + "@id": "https://w3id.org/security#controller", + "@type": "@id", + }, + revoked: { + "@id": "https://w3id.org/security#revoked", + "@type": "http://www.w3.org/2001/XMLSchema#dateTime", + }, + publicKeyMultibase: { + "@id": "https://w3id.org/security#publicKeyMultibase", + "@type": "https://w3id.org/security#multibase", + }, + }, + }, + Ed25519Signature2020: { + "@id": "https://w3id.org/security#Ed25519Signature2020", + "@context": { + "@protected": true, + id: "@id", + type: "@type", + challenge: "https://w3id.org/security#challenge", + created: { + "@id": "http://purl.org/dc/terms/created", + "@type": "http://www.w3.org/2001/XMLSchema#dateTime", + }, + domain: "https://w3id.org/security#domain", + expires: { + "@id": "https://w3id.org/security#expiration", + "@type": "http://www.w3.org/2001/XMLSchema#dateTime", + }, + nonce: "https://w3id.org/security#nonce", + proofPurpose: { + "@id": "https://w3id.org/security#proofPurpose", + "@type": "@vocab", + "@context": { + "@protected": true, + id: "@id", + type: "@type", + assertionMethod: { + "@id": "https://w3id.org/security#assertionMethod", + "@type": "@id", + "@container": "@set", + }, + authentication: { + "@id": "https://w3id.org/security#authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + capabilityInvocation: { + "@id": "https://w3id.org/security#capabilityInvocationMethod", + "@type": "@id", + "@container": "@set", + }, + capabilityDelegation: { + "@id": "https://w3id.org/security#capabilityDelegationMethod", + "@type": "@id", + "@container": "@set", + }, + keyAgreement: { + "@id": "https://w3id.org/security#keyAgreementMethod", + "@type": "@id", + "@container": "@set", + }, + }, + }, + proofValue: { + "@id": "https://w3id.org/security#proofValue", + "@type": "https://w3id.org/security#multibase", + }, + verificationMethod: { + "@id": "https://w3id.org/security#verificationMethod", + "@type": "@id", + }, + }, + }, + }, +}; diff --git a/src/parser/contexts/index.ts b/src/parser/contexts/index.ts new file mode 100644 index 00000000..384f8ded --- /dev/null +++ b/src/parser/contexts/index.ts @@ -0,0 +1,43 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +import VC from "./vc"; +import Inrupt from "./inrupt"; +import vc from "./inrupt-vc"; +import integrity from "./data-integrity"; +import ed25519 from "./ed25519-2020"; +import revocation from "./revocation-list"; +import statusList from "./status-list"; + +const contextDefinitions = { + "https://www.w3.org/2018/credentials/v1": VC, + "https://schema.inrupt.com/credentials/v1.jsonld": Inrupt, +} as const; + +export const cachedContexts = { + "https://vc.inrupt.com/credentials/v1": vc, + "https://w3id.org/security/data-integrity/v1": integrity, + "https://w3id.org/vc-revocation-list-2020/v1": revocation, + "https://w3id.org/vc/status-list/2021/v1": statusList, + "https://w3id.org/security/suites/ed25519-2020/v1": ed25519, +}; + +export const context = Object.keys(contextDefinitions); +export default contextDefinitions; diff --git a/src/parser/contexts/inrupt-vc.ts b/src/parser/contexts/inrupt-vc.ts new file mode 100644 index 00000000..56ed156c --- /dev/null +++ b/src/parser/contexts/inrupt-vc.ts @@ -0,0 +1,84 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +export default { + "@context": { + "@version": 1.1, + "@protected": true, + ldp: "http://www.w3.org/ns/ldp#", + acl: "http://www.w3.org/ns/auth/acl#", + gc: "https://w3id.org/GConsent#", + vc: "http://www.w3.org/ns/solid/vc#", + xsd: "http://www.w3.org/2001/XMLSchema#", + issuerService: { "@id": "vc:issuerService", "@type": "@id" }, + statusService: { "@id": "vc:statusService", "@type": "@id" }, + verifierService: { "@id": "vc:verifierService", "@type": "@id" }, + derivationService: { "@id": "vc:derivationService", "@type": "@id" }, + proofService: { "@id": "vc:proofService", "@type": "@id" }, + availabilityService: { "@id": "vc:availabilityService", "@type": "@id" }, + submissionService: { "@id": "vc:submissionService", "@type": "@id" }, + supportedSignatureTypes: { + "@id": "vc:supportedSignatureTypes", + "@type": "@id", + }, + include: { "@id": "vc:include", "@type": "@id" }, + SolidAccessGrant: "vc:SolidAccessGrant", + SolidAccessRequest: "vc:SolidAccessRequest", + ExpiredVerifiableCredential: "vc:ExpiredVerifiableCredential", + inbox: { "@id": "ldp:inbox", "@type": "@id" }, + Read: "acl:Read", + Write: "acl:Write", + Append: "acl:Append", + mode: { "@id": "acl:mode", "@type": "@vocab" }, + Consent: "gc:Consent", + ConsentStatusExpired: "gc:ConsentStatusExpired", + ConsentStatusExplicitlyGiven: "gc:ConsentStatusExplicitlyGiven", + ConsentStatusGivenByDelegation: "gc:ConsentStatusGivenByDelegation", + ConsentStatusImplicitlyGiven: "gc:ConsentStatusImplicitlyGiven", + ConsentStatusInvalidated: "gc:ConsentStatusInvalidated", + ConsentStatusNotGiven: "gc:ConsentStatusNotGiven", + ConsentStatusRefused: "gc:ConsentStatusRefused", + ConsentStatusRequested: "gc:ConsentStatusRequested", + ConsentStatusUnknown: "gc:ConsentStatusUnknown", + ConsentStatusWithdrawn: "gc:ConsentStatusWithdrawn", + forPersonalData: { "@id": "gc:forPersonalData", "@type": "@id" }, + forProcessing: { "@id": "gc:forProcessing", "@type": "@id" }, + forPurpose: { "@id": "gc:forPurpose", "@type": "@id" }, + hasConsent: { "@id": "gc:hasConsent", "@type": "@id" }, + hasContext: { "@id": "gc:hasContext", "@type": "@id" }, + hasStatus: { "@id": "gc:hasStatus", "@type": "@vocab" }, + inMedium: { "@id": "gc:inMedium", "@type": "@id" }, + isConsentForDataSubject: { + "@id": "gc:isConsentForDataSubject", + "@type": "@id", + }, + isProvidedTo: { "@id": "gc:isProvidedTo", "@type": "@id" }, + isProvidedToPerson: { "@id": "gc:isProvidedToPerson", "@type": "@id" }, + isProvidedToController: { + "@id": "gc:isProvidedToController", + "@type": "@id", + }, + providedConsent: { "@id": "gc:providedConsent", "@type": "@id" }, + inherit: { + "@id": "urn:uuid:71ab2f68-a68b-4452-b968-dd23e0570227", + "@type": "xsd:boolean", + }, + }, +}; diff --git a/src/parser/contexts/inrupt.ts b/src/parser/contexts/inrupt.ts new file mode 100644 index 00000000..51e2841f --- /dev/null +++ b/src/parser/contexts/inrupt.ts @@ -0,0 +1,149 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +/** + * The custom Inrupt context for verifiable credentials + * @see https://schema.inrupt.com/credentials/v1.jsonld + */ +export default { + "@context": { + "@version": 1.1, + "@protected": true, + ldp: "http://www.w3.org/ns/ldp#", + acl: "http://www.w3.org/ns/auth/acl#", + gc: "https://w3id.org/GConsent#", + vc: "http://www.w3.org/ns/solid/vc#", + xsd: "http://www.w3.org/2001/XMLSchema#", + issuerService: { + "@id": "vc:issuerService", + "@type": "@id", + }, + statusService: { + "@id": "vc:statusService", + "@type": "@id", + }, + verifierService: { + "@id": "vc:verifierService", + "@type": "@id", + }, + derivationService: { + "@id": "vc:derivationService", + "@type": "@id", + }, + proofService: { + "@id": "vc:proofService", + "@type": "@id", + }, + availabilityService: { + "@id": "vc:availabilityService", + "@type": "@id", + }, + submissionService: { + "@id": "vc:submissionService", + "@type": "@id", + }, + supportedSignatureTypes: { + "@id": "vc:supportedSignatureTypes", + "@type": "@id", + }, + include: { + "@id": "vc:include", + "@type": "@id", + }, + SolidAccessGrant: "vc:SolidAccessGrant", + SolidAccessRequest: "vc:SolidAccessRequest", + ExpiredVerifiableCredential: "vc:ExpiredVerifiableCredential", + inbox: { + "@id": "ldp:inbox", + "@type": "@id", + }, + Read: "acl:Read", + Write: "acl:Write", + Append: "acl:Append", + mode: { + "@id": "acl:mode", + "@type": "@vocab", + }, + Consent: "gc:Consent", + ConsentStatusExpired: "gc:ConsentStatusExpired", + ConsentStatusExplicitlyGiven: "gc:ConsentStatusExplicitlyGiven", + ConsentStatusGivenByDelegation: "gc:ConsentStatusGivenByDelegation", + ConsentStatusImplicitlyGiven: "gc:ConsentStatusImplicitlyGiven", + ConsentStatusInvalidated: "gc:ConsentStatusInvalidated", + ConsentStatusNotGiven: "gc:ConsentStatusNotGiven", + ConsentStatusRefused: "gc:ConsentStatusRefused", + ConsentStatusRequested: "gc:ConsentStatusRequested", + ConsentStatusUnknown: "gc:ConsentStatusUnknown", + ConsentStatusWithdrawn: "gc:ConsentStatusWithdrawn", + forPersonalData: { + "@id": "gc:forPersonalData", + "@type": "@id", + }, + forProcessing: { + "@id": "gc:forProcessing", + "@type": "@id", + }, + forPurpose: { + "@id": "gc:forPurpose", + "@type": "@id", + }, + hasConsent: { + "@id": "gc:hasConsent", + "@type": "@id", + }, + hasContext: { + "@id": "gc:hasContext", + "@type": "@id", + }, + hasStatus: { + "@id": "gc:hasStatus", + "@type": "@vocab", + }, + inMedium: { + "@id": "gc:inMedium", + "@type": "@id", + }, + isConsentForDataSubject: { + "@id": "gc:isConsentForDataSubject", + "@type": "@id", + }, + isProvidedTo: { + "@id": "gc:isProvidedTo", + "@type": "@id", + }, + isProvidedToPerson: { + "@id": "gc:isProvidedToPerson", + "@type": "@id", + }, + isProvidedToController: { + "@id": "gc:isProvidedToController", + "@type": "@id", + }, + providedConsent: { + "@id": "gc:providedConsent", + "@type": "@id", + }, + inherit: { + "@id": "urn:uuid:71ab2f68-a68b-4452-b968-dd23e0570227", + "@type": "xsd:boolean", + }, + }, +} as const; diff --git a/src/parser/contexts/revocation-list.ts b/src/parser/contexts/revocation-list.ts new file mode 100644 index 00000000..08a7861b --- /dev/null +++ b/src/parser/contexts/revocation-list.ts @@ -0,0 +1,68 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +export default { + "@context": { + "@protected": true, + RevocationList2020Credential: { + "@id": + "https://w3id.org/vc-revocation-list-2020#RevocationList2020Credential", + "@context": { + "@protected": true, + + id: "@id", + type: "@type", + + description: "http://schema.org/description", + name: "http://schema.org/name", + }, + }, + RevocationList2020: { + "@id": "https://w3id.org/vc-revocation-list-2020#RevocationList2020", + "@context": { + "@protected": true, + + id: "@id", + type: "@type", + + encodedList: "https://w3id.org/vc-revocation-list-2020#encodedList", + }, + }, + + RevocationList2020Status: { + "@id": + "https://w3id.org/vc-revocation-list-2020#RevocationList2020Status", + "@context": { + "@protected": true, + + id: "@id", + type: "@type", + + revocationListCredential: { + "@id": + "https://w3id.org/vc-revocation-list-2020#revocationListCredential", + "@type": "@id", + }, + revocationListIndex: + "https://w3id.org/vc-revocation-list-2020#revocationListIndex", + }, + }, + }, +}; diff --git a/src/parser/contexts/status-list.ts b/src/parser/contexts/status-list.ts new file mode 100644 index 00000000..24653e81 --- /dev/null +++ b/src/parser/contexts/status-list.ts @@ -0,0 +1,68 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +export default { + "@context": { + "@protected": true, + + StatusList2021Credential: { + "@id": "https://w3id.org/vc/status-list#StatusList2021Credential", + "@context": { + "@protected": true, + + id: "@id", + type: "@type", + + description: "http://schema.org/description", + name: "http://schema.org/name", + }, + }, + + StatusList2021: { + "@id": "https://w3id.org/vc/status-list#StatusList2021", + "@context": { + "@protected": true, + + id: "@id", + type: "@type", + + statusPurpose: "https://w3id.org/vc/status-list#statusPurpose", + encodedList: "https://w3id.org/vc/status-list#encodedList", + }, + }, + + StatusList2021Entry: { + "@id": "https://w3id.org/vc/status-list#StatusList2021Entry", + "@context": { + "@protected": true, + + id: "@id", + type: "@type", + + statusPurpose: "https://w3id.org/vc/status-list#statusPurpose", + statusListIndex: "https://w3id.org/vc/status-list#statusListIndex", + statusListCredential: { + "@id": "https://w3id.org/vc/status-list#statusListCredential", + "@type": "@id", + }, + }, + }, + }, +}; diff --git a/src/parser/contexts/vc.ts b/src/parser/contexts/vc.ts new file mode 100644 index 00000000..36f6b852 --- /dev/null +++ b/src/parser/contexts/vc.ts @@ -0,0 +1,275 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +/** + * The Verifiable Credentials context. + * @see https://www.w3.org/2018/credentials/v1 + */ +export default { + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + VerifiableCredential: { + "@id": "https://www.w3.org/2018/credentials#VerifiableCredential", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + cred: "https://www.w3.org/2018/credentials#", + sec: "https://w3id.org/security#", + xsd: "http://www.w3.org/2001/XMLSchema#", + credentialSchema: { + "@id": "cred:credentialSchema", + "@type": "@id", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + cred: "https://www.w3.org/2018/credentials#", + JsonSchemaValidator2018: "cred:JsonSchemaValidator2018", + }, + }, + credentialStatus: { "@id": "cred:credentialStatus", "@type": "@id" }, + credentialSubject: { "@id": "cred:credentialSubject", "@type": "@id" }, + evidence: { "@id": "cred:evidence", "@type": "@id" }, + expirationDate: { + "@id": "cred:expirationDate", + "@type": "xsd:dateTime", + }, + holder: { "@id": "cred:holder", "@type": "@id" }, + issued: { "@id": "cred:issued", "@type": "xsd:dateTime" }, + issuer: { "@id": "cred:issuer", "@type": "@id" }, + issuanceDate: { "@id": "cred:issuanceDate", "@type": "xsd:dateTime" }, + proof: { "@id": "sec:proof", "@type": "@id", "@container": "@graph" }, + refreshService: { + "@id": "cred:refreshService", + "@type": "@id", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + cred: "https://www.w3.org/2018/credentials#", + ManualRefreshService2018: "cred:ManualRefreshService2018", + }, + }, + termsOfUse: { "@id": "cred:termsOfUse", "@type": "@id" }, + validFrom: { "@id": "cred:validFrom", "@type": "xsd:dateTime" }, + validUntil: { "@id": "cred:validUntil", "@type": "xsd:dateTime" }, + }, + }, + VerifiablePresentation: { + "@id": "https://www.w3.org/2018/credentials#VerifiablePresentation", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + cred: "https://www.w3.org/2018/credentials#", + sec: "https://w3id.org/security#", + holder: { "@id": "cred:holder", "@type": "@id" }, + proof: { "@id": "sec:proof", "@type": "@id", "@container": "@graph" }, + verifiableCredential: { + "@id": "cred:verifiableCredential", + "@type": "@id", + "@container": "@graph", + }, + }, + }, + EcdsaSecp256k1Signature2019: { + "@id": "https://w3id.org/security#EcdsaSecp256k1Signature2019", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + sec: "https://w3id.org/security#", + xsd: "http://www.w3.org/2001/XMLSchema#", + challenge: "sec:challenge", + created: { + "@id": "http://purl.org/dc/terms/created", + "@type": "xsd:dateTime", + }, + domain: "sec:domain", + expires: { "@id": "sec:expiration", "@type": "xsd:dateTime" }, + jws: "sec:jws", + nonce: "sec:nonce", + proofPurpose: { + "@id": "sec:proofPurpose", + "@type": "@vocab", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + sec: "https://w3id.org/security#", + assertionMethod: { + "@id": "sec:assertionMethod", + "@type": "@id", + "@container": "@set", + }, + authentication: { + "@id": "sec:authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + }, + }, + proofValue: "sec:proofValue", + verificationMethod: { "@id": "sec:verificationMethod", "@type": "@id" }, + }, + }, + EcdsaSecp256r1Signature2019: { + "@id": "https://w3id.org/security#EcdsaSecp256r1Signature2019", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + sec: "https://w3id.org/security#", + xsd: "http://www.w3.org/2001/XMLSchema#", + challenge: "sec:challenge", + created: { + "@id": "http://purl.org/dc/terms/created", + "@type": "xsd:dateTime", + }, + domain: "sec:domain", + expires: { "@id": "sec:expiration", "@type": "xsd:dateTime" }, + jws: "sec:jws", + nonce: "sec:nonce", + proofPurpose: { + "@id": "sec:proofPurpose", + "@type": "@vocab", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + sec: "https://w3id.org/security#", + assertionMethod: { + "@id": "sec:assertionMethod", + "@type": "@id", + "@container": "@set", + }, + authentication: { + "@id": "sec:authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + }, + }, + proofValue: "sec:proofValue", + verificationMethod: { "@id": "sec:verificationMethod", "@type": "@id" }, + }, + }, + Ed25519Signature2018: { + "@id": "https://w3id.org/security#Ed25519Signature2018", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + sec: "https://w3id.org/security#", + xsd: "http://www.w3.org/2001/XMLSchema#", + challenge: "sec:challenge", + created: { + "@id": "http://purl.org/dc/terms/created", + "@type": "xsd:dateTime", + }, + domain: "sec:domain", + expires: { "@id": "sec:expiration", "@type": "xsd:dateTime" }, + jws: "sec:jws", + nonce: "sec:nonce", + proofPurpose: { + "@id": "sec:proofPurpose", + "@type": "@vocab", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + sec: "https://w3id.org/security#", + assertionMethod: { + "@id": "sec:assertionMethod", + "@type": "@id", + "@container": "@set", + }, + authentication: { + "@id": "sec:authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + }, + }, + proofValue: "sec:proofValue", + verificationMethod: { "@id": "sec:verificationMethod", "@type": "@id" }, + }, + }, + RsaSignature2018: { + "@id": "https://w3id.org/security#RsaSignature2018", + "@context": { + "@version": 1.1, + "@protected": true, + challenge: "sec:challenge", + created: { + "@id": "http://purl.org/dc/terms/created", + "@type": "xsd:dateTime", + }, + domain: "sec:domain", + expires: { "@id": "sec:expiration", "@type": "xsd:dateTime" }, + jws: "sec:jws", + nonce: "sec:nonce", + proofPurpose: { + "@id": "sec:proofPurpose", + "@type": "@vocab", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + sec: "https://w3id.org/security#", + assertionMethod: { + "@id": "sec:assertionMethod", + "@type": "@id", + "@container": "@set", + }, + authentication: { + "@id": "sec:authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + }, + }, + proofValue: "sec:proofValue", + verificationMethod: { "@id": "sec:verificationMethod", "@type": "@id" }, + }, + }, + proof: { + "@id": "https://w3id.org/security#proof", + "@type": "@id", + "@container": "@graph", + }, + }, +} as const; diff --git a/src/parser/jsonld.test.ts b/src/parser/jsonld.test.ts new file mode 100644 index 00000000..9006e35d --- /dev/null +++ b/src/parser/jsonld.test.ts @@ -0,0 +1,207 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import { jest, it, describe, expect, beforeAll } from "@jest/globals"; +import { Response } from "@inrupt/universal-fetch"; +import type * as UniversalFetch from "@inrupt/universal-fetch"; +import { DataFactory as DF } from "n3"; +import { isomorphic } from "rdf-isomorphic"; +import type { JsonLdContextNormalized } from "jsonld-context-parser"; +import { jsonLdResponseToStore, jsonLdToStore, getVcContext } from "./jsonld"; + +const fetcher: (typeof UniversalFetch)["fetch"] = async (url) => { + if (url !== "https://example.com/myContext") { + throw new Error("Unexpected URL"); + } + + return new Response( + JSON.stringify({ + "@context": { + Person: "http://xmlns.com/foaf/0.1/Person", + xsd: "http://www.w3.org/2001/XMLSchema#", + name: "http://xmlns.com/foaf/0.1/name", + nickname: "http://xmlns.com/foaf/0.1/nick", + affiliation: "http://schema.org/affiliation", + }, + }), + { + headers: new Headers([["content-type", "application/ld+json"]]), + }, + ); +}; + +jest.mock("@inrupt/universal-fetch", () => { + const fetchModule = jest.requireActual( + "@inrupt/universal-fetch", + ) as typeof UniversalFetch; + return { + ...fetchModule, + fetch: jest.fn<(typeof UniversalFetch)["fetch"]>(), + }; +}); + +const data = { + "@context": "https://www.w3.org/2018/credentials/v1", + id: "https://some.example#credential", + type: ["VerifiableCredential"], + issuer: "https://some.example", +}; + +const dataExampleContext = { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://example.com/myContext", + ], + id: "https://some.example#credential", + name: "Inrupt", +}; + +const dataWithPrefix = { + "@context": [ + { ex: "https://some.example#" }, + "https://www.w3.org/2018/credentials/v1", + ], + id: "ex:credential", + type: ["VerifiableCredential"], + issuer: "https://some.example", +}; + +const result = [ + DF.quad( + DF.namedNode("https://some.example#credential"), + DF.namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + DF.namedNode("https://www.w3.org/2018/credentials#VerifiableCredential"), + ), + DF.quad( + DF.namedNode("https://some.example#credential"), + DF.namedNode("https://www.w3.org/2018/credentials#issuer"), + DF.namedNode("https://some.example"), + ), +]; + +describe("jsonLdResponseToStore", () => { + it("converting fetch response to a store", async () => { + const response = new Response(JSON.stringify(data)); + expect( + isomorphic([...(await jsonLdResponseToStore(response))], result), + ).toBe(true); + }); + + it("should error if trying to fetch a remote context when allowContextFetching is disabled", async () => { + const response = new Response( + JSON.stringify({ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "http://example.org/my/remote/context", + ], + id: "https://some.example#credential", + type: ["VerifiableCredential"], + issuer: "https://some.example", + }), + ); + await expect( + jsonLdResponseToStore(response, { + allowContextFetching: false, + }), + ).rejects.toThrow( + "Unexpected context requested [http://example.org/my/remote/context]", + ); + }); + + it("converting fetch response with custom prefix definition to a store", async () => { + const response = new Response(JSON.stringify(dataWithPrefix)); + const quads = [...(await jsonLdResponseToStore(response))]; + expect(isomorphic(quads, result)).toBe(true); + }); + + it("rejects on empty fetch response", async () => { + await expect(jsonLdResponseToStore(new Response())).rejects.toThrow( + "Empty response body. Expected JSON-LD.", + ); + }); + + it("rejects on invalid JSON-LD", async () => { + await expect(jsonLdResponseToStore(new Response("{"))).rejects.toThrow( + "Error parsing JSON-LD: [Error: Unclosed document].", + ); + }); + + it("converting fetch response with custom context to a store", async () => { + const response = new Response(JSON.stringify(dataExampleContext)); + expect( + isomorphic( + [...(await jsonLdResponseToStore(response, { fetch: fetcher }))], + [ + DF.quad( + DF.namedNode("https://some.example#credential"), + DF.namedNode("http://xmlns.com/foaf/0.1/name"), + DF.literal("Inrupt"), + ), + ], + ), + ).toBe(true); + }); +}); + +describe("jsonLdToStore", () => { + it("converting valid POJO to store", async () => { + expect(isomorphic([...(await jsonLdToStore(data))], result)).toBe(true); + }); +}); + +describe("getVcContext", () => { + let context: JsonLdContextNormalized; + + beforeAll(async () => { + context = await getVcContext(); + }); + + it("should be able to compact and expand IRIs from the VC context", () => { + expect( + context.compactIri( + "https://www.w3.org/2018/credentials#VerifiableCredential", + true, + ), + ).toBe("VerifiableCredential"); + expect(context.expandTerm("VerifiableCredential", true)).toBe( + "https://www.w3.org/2018/credentials#VerifiableCredential", + ); + }); + + it("should be able to compact and expand IRIs from the Inrupt context", () => { + expect(context.compactIri("https://w3id.org/GConsent#Consent", true)).toBe( + "Consent", + ); + expect(context.expandTerm("Consent", true)).toBe( + "https://w3id.org/GConsent#Consent", + ); + }); + + it("should not compact and expand IRIs not in the VC or Inrupt context", () => { + expect( + context.compactIri( + "https://example.org/credentials#VerifiableCredential", + true, + ), + ).toBe("https://example.org/credentials#VerifiableCredential"); + expect(context.expandTerm("VC", true)).toBe("VC"); + }); +}); diff --git a/src/parser/jsonld.ts b/src/parser/jsonld.ts new file mode 100644 index 00000000..5851aef3 --- /dev/null +++ b/src/parser/jsonld.ts @@ -0,0 +1,169 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +/* eslint-disable max-classes-per-file */ +import defaultFetch from "@inrupt/universal-fetch"; +import { promisifyEventEmitter } from "event-emitter-promisify"; +import type { + IJsonLdContext, + JsonLdContextNormalized, +} from "jsonld-context-parser"; +import { ContextParser, FetchDocumentLoader } from "jsonld-context-parser"; +import { JsonLdParser } from "jsonld-streaming-parser"; +import { Store } from "n3"; +import CONTEXTS, { cachedContexts } from "./contexts"; +import type { JsonLd } from "../common/common"; + +/** + * A JSON-LD document loader with the standard context for VCs pre-loaded + */ +class CachedFetchDocumentLoader extends FetchDocumentLoader { + private contexts: Record; + + constructor( + contexts?: Record, + private readonly allowContextFetching = true, + ...args: ConstructorParameters + ) { + super(...args); + this.contexts = { ...contexts, ...cachedContexts, ...CONTEXTS }; + } + + public async load(url: string): Promise { + if (Object.keys(this.contexts).includes(url)) { + return this.contexts[url as keyof typeof CONTEXTS]; + } + if (!this.allowContextFetching) { + throw new Error(`Unexpected context requested [${url}]`); + } + return super.load(url); + } +} + +/** + * Creates a context for use with the VC library + */ +// FIXME: See if our access grants specific context should be passed +// through as a parameter instead +export function getVcContext( + ...contexts: IJsonLdContext[] +): Promise { + const myParser = new ContextParser({ + documentLoader: new CachedFetchDocumentLoader(), + }); + return myParser.parse([ + ...Object.values(CONTEXTS).map((x) => x["@context"]), + ...contexts, + ]); +} + +export interface ParseOptions { + fetch?: typeof globalThis.fetch; + baseIRI?: string; + contexts?: Record; + allowContextFetching?: boolean; +} + +/** + * Our internal JsonLd Parser with a cached VC context + */ +export class CachedJsonLdParser extends JsonLdParser { + constructor(options?: ParseOptions) { + super({ + documentLoader: new CachedFetchDocumentLoader( + options?.contexts, + options?.allowContextFetching, + options?.fetch ?? defaultFetch, + ), + baseIRI: options?.baseIRI, + }); + } +} + +/** + * Gets an N3 store from a JSON-LD string + * @param response A JSON-LD string + * @param options An optional fetch function for dereferencing remote contexts + * @returns A store containing the Quads in the JSON-LD response + */ +export async function jsonLdStringToStore( + data: string, + options?: ParseOptions, +) { + try { + const parser = new CachedJsonLdParser(options); + const store = new Store(); + const storePromise = promisifyEventEmitter(store.import(parser), store); + parser.write(data); + parser.end(); + return await storePromise; + } catch (e) { + throw new Error(`Error parsing JSON-LD: [${e}].`); + } +} + +/** + * Gets an N3 store from a JSON-LD response to a fetch request + * @param response A JSON-LD response + * @param options An optional fetch function for dereferencing remote contexts + * @returns A store containing the Quads in the JSON-LD response + */ +export async function jsonLdResponseToStore( + response: Response, + options?: ParseOptions, +): Promise { + if (response.body === null) + throw new Error("Empty response body. Expected JSON-LD."); + + return jsonLdStringToStore(await response.text(), options); + + // FIXME: Use this logic once node 16 is deprecated + // This won't work with node-fetch (and hence versions of Node lower than 16.8) because + // node-fetch does not have #getReader implemented. + // You will likely encounter the following error when trying to implement it this way and testing it in Jest: + // TypeError: The "chunk" argument must be of type string or an instance of Buffer or Uint8Array. + // Received an instance of Uint8Array. + // I believe this error is caused by the `fetch` and `TextEncoder` polyfills in our environment + // causing multiple versions of the Buffer or Uint8Array classes existing and hence `instanceof` + // checks not behaving as intended. + + // const reader = response.body.getReader(); + // const parser = new IJsonLdParser({ fetch: options?.fetch, baseIRI: response.url }); + // const store = new Store(); + // const result = promisifyEventEmitter(store.import(parser), store); + // let value = await reader.read(); + // while (!value.done) { + // parser.write(value.value); + // value = await reader.read(); + // } + // parser.end(); + // return result; +} + +/** + * Gets an N3 store from a JSON-LD as an Object + * @param response JSON-LD as an Object + * @param options An optional fetch function for dereferencing remote contexts + * @returns A store containing the Quads in the JSON-LD response + */ +export function jsonLdToStore(data: JsonLd, options?: ParseOptions) { + return jsonLdStringToStore(JSON.stringify(data), options); +} diff --git a/src/verify/verify.ts b/src/verify/verify.ts index 57b87f71..1d3e012e 100644 --- a/src/verify/verify.ts +++ b/src/verify/verify.ts @@ -135,6 +135,7 @@ export async function isValidVc( } try { + // FIXME: Perform parsing here return await response.json(); } catch (e) { throw new Error( @@ -217,6 +218,7 @@ export async function isValidVerifiablePresentation( } try { + // FIXME: Perform parsing here return await response.json(); } catch (e) { throw new Error(