From 3866a9e49982a69d2d7f69d7c67ac6d65f792392 Mon Sep 17 00:00:00 2001 From: Orie Steele Date: Wed, 24 Apr 2024 17:47:22 -0500 Subject: [PATCH] update readme --- README.md | 336 +++++++++++++++++- src/cr1/key/generate.ts | 8 +- src/cr1/types.ts | 5 + src/cr1/validator/index.ts | 7 +- .../jwt-product-passports/integration.test.ts | 117 ++++-- 5 files changed, 441 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 8f77e0f..08fe0ef 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,342 @@ npm i @transmute/verifiable-credentials@latest --save import * as transmute from "@transmute/verifiable-credentials"; ``` +### Generating Keys + +```ts +const privateKey = await transmute.key.generate({ + alg, + type: "application/jwk+json", +}); +// console.log(new TextDecoder().decode(privateKey)) +// { +// "kid": "xSgm4GQOT_ZyYFApew0GnRvPWt70omVJV9XVB5tsmN8", +// "alg": "ES256", +// "kty": "EC", +// "crv": "P-256", +// "x": "XRkZngz2KSCrLdXKGCRNyDzBgsovioZIqMWnF42nmdg", +// "y": "H2t6Xxdg8p8Cqn2-hsuWnXYj0192He4zTZghAxNXllo", +// ... +// } +const publicKey = await transmute.key.publicFromPrivate({ + type: "application/jwk+json", + content: privateKey, +}); +// console.log(new TextDecoder().decode(publicKey)) +// { +// "kid": "xSgm4GQOT_ZyYFApew0GnRvPWt70omVJV9XVB5tsmN8", +// "alg": "ES256", +// "kty": "EC", +// "crv": "P-256", +// "x": "XRkZngz2KSCrLdXKGCRNyDzBgsovioZIqMWnF42nmdg", +// "y": "H2t6Xxdg8p8Cqn2-hsuWnXYj0192He4zTZghAxNXllo", +// } +``` + +### Issuing Credentials + +```ts +const alg = `ES256`; +const statusListSize = 131072; +const revocationIndex = 94567; +const suspensionIndex = 23452; + +const issuer = `did:example:123`; +const baseURL = `https://vendor.example/api`; +const issued = await transmute + .issuer({ + alg, + type: "application/vc+ld+json+jwt", + signer: { + sign: async (bytes: Uint8Array) => { + const jws = await new jose.CompactSign(bytes) + .setProtectedHeader({ kid: `${issuer}#key-42`, alg }) + .sign( + await transmute.key.importKeyLike({ + type: "application/jwk+json", + content: privateKey, + }) + ); + return transmute.text.encoder.encode(jws); + }, + }, + }) + .issue({ + claimset: transmute.text.encoder.encode(` +"@context": + - https://www.w3.org/ns/credentials/v2 + - https://www.w3.org/ns/credentials/examples/v2 + +id: ${baseURL}/credentials/3732 +type: + - VerifiableCredential + - ExampleDegreeCredential +issuer: + id: ${issuer} + name: "Example University" +validFrom: ${moment().toISOString()} +credentialSchema: + id: ${baseURL}/schemas/product-passport + type: JsonSchema +credentialStatus: + - id: ${baseURL}/credentials/status/3#${revocationIndex} + type: BitstringStatusListEntry + statusPurpose: revocation + statusListIndex: "${revocationIndex}" + statusListCredential: "${baseURL}/credentials/status/3" + - id: ${baseURL}/credentials/status/4#${suspensionIndex} + type: BitstringStatusListEntry + statusPurpose: suspension + statusListIndex: "${suspensionIndex}" + statusListCredential: "${baseURL}/credentials/status/4" +credentialSubject: + id: did:example:ebfeb1f712ebc6f1c276e12ec21 + degree: + type: ExampleBachelorDegree + subtype: Bachelor of Science and Arts +`), + }); +// console.log(new TextDecoder().decode(issued)) +// eyJraWQiOiJkaWQ6ZXhhbXBsZToxMjMja2V5LTQyIiwiYWxnIjoiRVMyNTYifQ.eyJAY29udGV4dCI6WyJodHRwczovL3d3dy53My5vcmcvbnMvY3JlZGVudGlhbHMvdjIiLCJodHRwczovL3ZlbmRvci5leGFtcGxlL2FwaS9jb250ZXh0L3YyIl0sImlkIjoiaHR0cHM6Ly92ZW5kb3IuZXhhbXBsZS9hcGkvY3JlZGVudGlhbHMvMzczMiIsInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJFeGFtcGxlRGVncmVlQ3JlZGVudGlhbCJdLCJpc3N1ZXIiOnsiaWQiOiJkaWQ6ZXhhbXBsZToxMjMiLCJuYW1lIjoiRXhhbXBsZSBVbml2ZXJzaXR5In0sInZhbGlkRnJvbSI6IjIwMjQtMDQtMjRUMjI6MjM6MDIuODU2WiIsImNyZWRlbnRpYWxTY2hlbWEiOnsiaWQiOiJodHRwczovL3ZlbmRvci5leGFtcGxlL2FwaS9zY2hlbWFzL3Byb2R1Y3QtcGFzc3BvcnQiLCJ0eXBlIjoiSnNvblNjaGVtYSJ9LCJjcmVkZW50aWFsU3RhdHVzIjpbeyJpZCI6Imh0dHBzOi8vdmVuZG9yLmV4YW1wbGUvYXBpL2NyZWRlbnRpYWxzL3N0YXR1cy8zIzk0NTY3IiwidHlwZSI6IkJpdHN0cmluZ1N0YXR1c0xpc3RFbnRyeSIsInN0YXR1c1B1cnBvc2UiOiJyZXZvY2F0aW9uIiwic3RhdHVzTGlzdEluZGV4IjoiOTQ1NjciLCJzdGF0dXNMaXN0Q3JlZGVudGlhbCI6Imh0dHBzOi8vdmVuZG9yLmV4YW1wbGUvYXBpL2NyZWRlbnRpYWxzL3N0YXR1cy8zIn0seyJpZCI6Imh0dHBzOi8vdmVuZG9yLmV4YW1wbGUvYXBpL2NyZWRlbnRpYWxzL3N0YXR1cy80IzIzNDUyIiwidHlwZSI6IkJpdHN0cmluZ1N0YXR1c0xpc3RFbnRyeSIsInN0YXR1c1B1cnBvc2UiOiJzdXNwZW5zaW9uIiwic3RhdHVzTGlzdEluZGV4IjoiMjM0NTIiLCJzdGF0dXNMaXN0Q3JlZGVudGlhbCI6Imh0dHBzOi8vdmVuZG9yLmV4YW1wbGUvYXBpL2NyZWRlbnRpYWxzL3N0YXR1cy80In1dLCJjcmVkZW50aWFsU3ViamVjdCI6eyJpZCI6ImRpZDpleGFtcGxlOmViZmViMWY3MTJlYmM2ZjFjMjc2ZTEyZWMyMSIsImRlZ3JlZSI6eyJ0eXBlIjoiRXhhbXBsZUJhY2hlbG9yRGVncmVlIiwic3VidHlwZSI6IkJhY2hlbG9yIG9mIFNjaWVuY2UgYW5kIEFydHMifX19.xHjfiUwx61qmoVMGLrHT8FI-ZYUHXQy4B6oF0Cb5EOTYYPXdwjW9sa1l5aa008xvsFvrcNats9TywmN2nNKz6A +``` + +### Validating Credentials + +```ts +const validated = await transmute + .validator({ + resolver: { + resolve: async ({ id, type, content }) => { + // Resolve external resources according to verifier policy + // In this case, we return inline exampes... + if (id === `${baseURL}/schemas/product-passport`) { + return { + type: `application/schema+json`, + content: transmute.text.encoder.encode(` +{ + "$id": "${baseURL}/schemas/product-passport", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Example JSON Schema", + "description": "This is a test schema", + "type": "object", + "properties": { + "credentialSubject": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + } + } + } +} + `), + }; + } + if (id === `${baseURL}/credentials/status/3`) { + return { + type: `application/vc+ld+json+jwt`, + content: await transmute + .issuer({ + alg: "ES384", + type: "application/vc+ld+json+cose", + signer: { + sign: async (bytes: Uint8Array) => { + const jws = await new jose.CompactSign(bytes) + .setProtectedHeader({ kid: `${issuer}#key-42`, alg }) + .sign( + await transmute.key.importKeyLike({ + type: "application/jwk+json", + content: privateKey, + }) + ); + return transmute.text.encoder.encode(jws); + }, + }, + }) + .issue({ + claimset: transmute.text.encoder.encode( + ` +"@context": + - https://www.w3.org/ns/credentials/v2 +id: ${baseURL}/status/3#list +type: + - VerifiableCredential + - BitstringStatusListCredential +issuer: + id: ${issuer} +validFrom: ${moment().toISOString()} +credentialSubject: + id: ${baseURL}/status/3#list#list + type: BitstringStatusList + statusPurpose: revocation + encodedList: ${await transmute.status + .bs(statusListSize) + .set(revocationIndex, false) + .encode()} +`.trim() + ), + }), + }; + } + if (id === `${baseURL}/credentials/status/4`) { + return { + type: `application/vc+ld+json+jwt`, + content: await transmute + .issuer({ + alg: "ES384", + type: "application/vc+ld+json+cose", + signer: { + sign: async (bytes: Uint8Array) => { + const jws = await new jose.CompactSign(bytes) + .setProtectedHeader({ kid: `${issuer}#key-42`, alg }) + .sign( + await transmute.key.importKeyLike({ + type: "application/jwk+json", + content: privateKey, + }) + ); + return transmute.text.encoder.encode(jws); + }, + }, + }) + .issue({ + claimset: transmute.text.encoder.encode( + ` +"@context": + - https://www.w3.org/ns/credentials/v2 +id: ${baseURL}/status/4#list +type: + - VerifiableCredential + - BitstringStatusListCredential +issuer: + id: ${issuer} +validFrom: ${moment().toISOString()} +credentialSubject: + id: ${baseURL}/status/4#list#list + type: BitstringStatusList + statusPurpose: suspension + encodedList: ${await transmute.status + .bs(statusListSize) + .set(suspensionIndex, false) + .encode()} +`.trim() + ), + }), + }; + } + if (content != undefined && type === `application/vc+ld+json+jwt`) { + const { kid } = jose.decodeProtectedHeader( + transmute.text.decoder.decode(content) + ); + // lookup public key by kid on a trusted resolver + if (kid === `did:example:123#key-42`) { + return { + type: "application/jwk+json", + content: publicKey, + }; + } + } + throw new Error("Resolver option not supported."); + }, + }, + }) + .validate({ + type: "application/vc+ld+json+jwt", + content: issued, + }); + +// expect(validated.valid).toBe(true) +// expect(validated.schema[`${baseURL}/schemas/product-passport`].valid).toBe(true) +// expect(validated.status[`${baseURL}/credentials/status/3#${revocationIndex}`].valid).toBe(false) +// expect(validated.status[`${baseURL}/credentials/status/4#${suspensionIndex}`].valid).toBe(false) +``` + +### Issuing Presentations + +```ts +const presentation = await transmute + .holder({ + alg, + type: "application/vp+ld+json+jwt", + }) + .issue({ + signer: { + sign: async (bytes: Uint8Array) => { + const jws = await new jose.CompactSign(bytes) + .setProtectedHeader({ kid: `${issuer}#key-42`, alg }) + .sign( + await transmute.key.importKeyLike({ + type: "application/jwk+json", + content: privateKey, + }) + ); + return transmute.text.encoder.encode(jws); + }, + }, + presentation: { + "@context": ["https://www.w3.org/ns/credentials/v2"], + type: ["VerifiablePresentation"], + holder: `${baseURL}/holders/565049`, + // this part is built from disclosures without key binding below. + // "verifiableCredential": [{ + // "@context": "https://www.w3.org/ns/credentials/v2", + // "id": "data:application/vc+ld+json+sd-jwt;QzVjV...RMjU", + // "type": "EnvelopedVerifiableCredential" + // }] + }, + disclosures: [ + { + type: `application/vc+ld+json+jwt`, + credential: issued, + }, + ], + }); +``` + +### Validating Presentations + ```ts -// todo... +const validation = await transmute + .validator({ + resolver: { + resolve: async ({ type, content }) => { + // Resolve external resources according to verifier policy + // In this case, we return inline exampes... + if (content != undefined && type === `application/vp+ld+json+jwt`) { + const { kid } = jose.decodeProtectedHeader( + transmute.text.decoder.decode(content) + ); + // lookup public key on a resolver + if (kid === `did:example:123#key-42`) { + return { + type: "application/jwk+json", + content: publicKey, + }; + } + } + throw new Error("Resolver option not supported."); + }, + }, + }) + .validate({ + type: `application/vp+ld+json+jwt`, + content: presentation, + }); +// { +// "valid": true, +// "content": { +// "@context": [ +// "https://www.w3.org/ns/credentials/v2" +// ], +// "type": [ +// "VerifiablePresentation" +// ], +// "holder": "https://vendor.example/api/holders/565049", +// "verifiableCredential": [ +// { +// "@context": "https://www.w3.org/ns/credentials/v2", +// "id": "data:application/vc+ld+json+jwt;eyJraWQiOiJkaWQ6ZX... ``` ## Develop diff --git a/src/cr1/key/generate.ts b/src/cr1/key/generate.ts index cf77f35..4971399 100644 --- a/src/cr1/key/generate.ts +++ b/src/cr1/key/generate.ts @@ -17,15 +17,15 @@ export type RequestGenerateCredentialKey = { } -export const generate = async (req: RequestGenerateCredentialKey): Promise => { +export const generate = async (req: RequestGenerateCredentialKey): Promise => { if (req.type === 'application/jwk+json') { const obj = await cose.key.generate(req.alg, 'application/jwk+json') const text = JSON.stringify(obj, null, 2) - return encoder.encode(text) + return encoder.encode(text) as T } if (req.type === 'application/cose-key') { const result = await cose.key.generate(req.alg, 'application/cose-key') - return new Uint8Array(cose.cbor.encode(result)) + return new Uint8Array(cose.cbor.encode(result)) as T } if (req.type === 'application/pkcs8') { const result = await cose.certificate.root({ @@ -35,7 +35,7 @@ export const generate = async (req: RequestGenerateCredentialKey nbf: req.nbf || moment().toISOString(), // now exp: req.nbf || moment().add(5, 'minutes').toISOString() // in 5 minutes }) - return encoder.encode(result.private) + return encoder.encode(result.private) as T } throw new Error('Unsupported content type for private key') } \ No newline at end of file diff --git a/src/cr1/types.ts b/src/cr1/types.ts index 81a672f..6cda8d9 100644 --- a/src/cr1/types.ts +++ b/src/cr1/types.ts @@ -250,4 +250,9 @@ export type ValidationResult = { schema: Record status: Record warnings: ConformanceWarningMessage[] +} + + +export type TraceablePresentationValidationResult = ValidationResult & { + content: VerifiablePresentationWithHolderObject & VerifiablePresentationOfEnveloped } \ No newline at end of file diff --git a/src/cr1/validator/index.ts b/src/cr1/validator/index.ts index fc1e1cf..0ab52cf 100644 --- a/src/cr1/validator/index.ts +++ b/src/cr1/validator/index.ts @@ -7,7 +7,8 @@ import { BitstringStatusListCredential, ValidationResult, VerifiableCredential, - JsonSchemaError + JsonSchemaError, + TraceablePresentationValidationResult } from "../types" import { verifier } from "../verifier" @@ -22,7 +23,7 @@ import { ajv } from "./ajv" export const validator = ({ resolver }: RequestValidator) => { return { - validate: async ({ type, content }: SecuredContentType) => { + validate: async ({ type, content }: SecuredContentType) => { const verified = await verifier({ resolver }).verify({ type, content }) const validation: ValidationResult = { valid: true, @@ -82,7 +83,7 @@ export const validator = ({ resolver }: RequestValidator) => { } } } - return conformance(validation) + return conformance(validation) as T } } } \ No newline at end of file diff --git a/test/jwt-product-passports/integration.test.ts b/test/jwt-product-passports/integration.test.ts index 540d0b0..5e429c9 100644 --- a/test/jwt-product-passports/integration.test.ts +++ b/test/jwt-product-passports/integration.test.ts @@ -2,7 +2,7 @@ import * as jose from 'jose' import moment from 'moment' -import * as vc from '../../src' +import * as transmute from '../../src' const alg = `ES256` const statusListSize = 131072 @@ -10,23 +10,24 @@ const revocationIndex = 94567 const suspensionIndex = 23452 const issuer = `did:example:123` -const baseURL = `https://vendor.example/api/` +const baseURL = `https://vendor.example/api` describe('product passport', () => { it('issue application/vc+ld+json+jwt using application/jwk+json', async () => { - const privateKey = await vc.key.generate({ + const privateKey = await transmute.key.generate({ alg, type: 'application/jwk+json' }) - expect(vc.text.decoder.decode(privateKey).startsWith(`{ + + expect(transmute.text.decoder.decode(privateKey).startsWith(`{ "kid"`)).toBe(true) - const publicKey = await vc.key.publicFromPrivate({ + const publicKey = await transmute.key.publicFromPrivate({ type: 'application/jwk+json', content: privateKey }) - const issued = await vc + const issued = await transmute .issuer({ - alg: 'ES256', + alg, type: 'application/vc+ld+json+jwt', signer: { sign: async (bytes: Uint8Array) => { @@ -34,19 +35,19 @@ describe('product passport', () => { bytes ) .setProtectedHeader({ kid: `${issuer}#key-42`, alg }) - .sign(await vc.key.importKeyLike({ + .sign(await transmute.key.importKeyLike({ type: "application/jwk+json", content: privateKey })) - return vc.text.encoder.encode(jws) + return transmute.text.encoder.encode(jws) } } }) .issue({ - claimset: vc.text.encoder.encode(` + claimset: transmute.text.encoder.encode(` "@context": - https://www.w3.org/ns/credentials/v2 - - https://www.w3.org/ns/credentials/examples/v2 + - ${baseURL}/context/v2 id: ${baseURL}/credentials/3732 type: @@ -78,7 +79,9 @@ credentialSubject: `), }) - const validated = await vc.validator({ + + + const validated = await transmute.validator({ resolver: { resolve: async ({ id, type, content }) => { // Resolve external resources according to verifier policy @@ -86,7 +89,7 @@ credentialSubject: if (id === `${baseURL}/schemas/product-passport`) { return { type: `application/schema+json`, - content: vc.text.encoder.encode(` + content: transmute.text.encoder.encode(` { "$id": "${baseURL}/schemas/product-passport", "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -110,7 +113,7 @@ credentialSubject: if (id === `${baseURL}/credentials/status/3`) { return { type: `application/vc+ld+json+jwt`, - content: await vc + content: await transmute .issuer({ alg: 'ES384', type: 'application/vc+ld+json+cose', @@ -120,16 +123,16 @@ credentialSubject: bytes ) .setProtectedHeader({ kid: `${issuer}#key-42`, alg }) - .sign(await vc.key.importKeyLike({ + .sign(await transmute.key.importKeyLike({ type: "application/jwk+json", content: privateKey })) - return vc.text.encoder.encode(jws) + return transmute.text.encoder.encode(jws) } } }) .issue({ - claimset: vc.text.encoder.encode( + claimset: transmute.text.encoder.encode( ` "@context": - https://www.w3.org/ns/credentials/v2 @@ -144,7 +147,7 @@ credentialSubject: id: ${baseURL}/status/3#list#list type: BitstringStatusList statusPurpose: revocation - encodedList: ${await vc.status.bs(statusListSize).set(revocationIndex, false).encode()} + encodedList: ${await transmute.status.bs(statusListSize).set(revocationIndex, false).encode()} `.trim() ) }) @@ -153,7 +156,7 @@ credentialSubject: if (id === `${baseURL}/credentials/status/4`) { return { type: `application/vc+ld+json+jwt`, - content: await vc + content: await transmute .issuer({ alg: 'ES384', type: 'application/vc+ld+json+cose', @@ -163,16 +166,16 @@ credentialSubject: bytes ) .setProtectedHeader({ kid: `${issuer}#key-42`, alg }) - .sign(await vc.key.importKeyLike({ + .sign(await transmute.key.importKeyLike({ type: "application/jwk+json", content: privateKey })) - return vc.text.encoder.encode(jws) + return transmute.text.encoder.encode(jws) } } }) .issue({ - claimset: vc.text.encoder.encode( + claimset: transmute.text.encoder.encode( ` "@context": - https://www.w3.org/ns/credentials/v2 @@ -187,14 +190,14 @@ credentialSubject: id: ${baseURL}/status/4#list#list type: BitstringStatusList statusPurpose: suspension - encodedList: ${await vc.status.bs(statusListSize).set(suspensionIndex, false).encode()} + encodedList: ${await transmute.status.bs(statusListSize).set(suspensionIndex, false).encode()} `.trim() ) }) } } if (content != undefined && type === `application/vc+ld+json+jwt`) { - const { kid } = jose.decodeProtectedHeader(vc.text.decoder.decode(content)) + const { kid } = jose.decodeProtectedHeader(transmute.text.decoder.decode(content)) // lookup public key on a resolver if (kid === `did:example:123#key-42`) { return { @@ -214,5 +217,71 @@ credentialSubject: expect(validated.schema[`${baseURL}/schemas/product-passport`].valid).toBe(true) expect(validated.status[`${baseURL}/credentials/status/3#${revocationIndex}`].valid).toBe(false) expect(validated.status[`${baseURL}/credentials/status/4#${suspensionIndex}`].valid).toBe(false) + + + const vp = await transmute + .holder({ + alg, + type: 'application/vp+ld+json+jwt', + }) + .issue({ + signer: { + sign: async (bytes: Uint8Array) => { + const jws = await new jose.CompactSign( + bytes + ) + .setProtectedHeader({ kid: `${issuer}#key-42`, alg }) + .sign(await transmute.key.importKeyLike({ + type: "application/jwk+json", + content: privateKey + })) + return transmute.text.encoder.encode(jws) + } + }, + presentation: { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + ], + "type": ["VerifiablePresentation"], + holder: `${baseURL}/holders/565049`, + // this part is built from disclosures without key binding below. + // "verifiableCredential": [{ + // "@context": "https://www.w3.org/ns/credentials/v2", + // "id": "data:application/vc+ld+json+sd-jwt;QzVjV...RMjU", + // "type": "EnvelopedVerifiableCredential" + // }] + }, + disclosures: [ + { + type: `application/vc+ld+json+jwt`, + credential: issued + } + ] + }) + const presentation = await transmute.validator({ + resolver: { + resolve: async ({ type, content }) => { + // Resolve external resources according to verifier policy + // In this case, we return inline exampes... + if (content != undefined && type === `application/vp+ld+json+jwt`) { + const { kid } = jose.decodeProtectedHeader(transmute.text.decoder.decode(content)) + // lookup public key on a resolver + if (kid === `did:example:123#key-42`) { + return { + type: "application/jwk+json", + content: publicKey + } + } + } + throw new Error('Resolver option not supported.') + } + } + }) + .validate({ + type: `application/vp+ld+json+jwt`, + content: vp + }) + expect(presentation.content.holder).toBe('https://vendor.example/api/holders/565049') + expect(presentation.content.verifiableCredential[0].id.startsWith('data:application/vc+ld+json+jwt;')).toBe(true) }) })