From 6c8bdd1e41ec57b08b6b32b2fb5a4a52a649daf4 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Mon, 25 Sep 2023 16:36:57 +0200 Subject: [PATCH] feat: support receiving JFF JWT credential Signed-off-by: Timo Glastra --- ...-framework-core-npm-0.4.1-bf3ca88ff9.patch | 50 +++++ package.json | 1 + packages/agent/src/parsers.ts | 18 +- .../src/OpenId4VcClientService.ts | 190 ++++++++++++------ .../src/OpenId4VcClientServiceOptions.ts | 17 +- yarn.lock | 4 +- 6 files changed, 208 insertions(+), 72 deletions(-) create mode 100644 .yarn/patches/@aries-framework-core-npm-0.4.1-bf3ca88ff9.patch diff --git a/.yarn/patches/@aries-framework-core-npm-0.4.1-bf3ca88ff9.patch b/.yarn/patches/@aries-framework-core-npm-0.4.1-bf3ca88ff9.patch new file mode 100644 index 00000000..a3bb5361 --- /dev/null +++ b/.yarn/patches/@aries-framework-core-npm-0.4.1-bf3ca88ff9.patch @@ -0,0 +1,50 @@ +diff --git a/build/modules/vc/models/credential/W3cCredential.js b/build/modules/vc/models/credential/W3cCredential.js +index 0c03676b24f394c7b48e9b41d8101a576c94484b..d5b1ff8847721223e5bb486eb4716d790a849013 100644 +--- a/build/modules/vc/models/credential/W3cCredential.js ++++ b/build/modules/vc/models/credential/W3cCredential.js +@@ -68,16 +68,37 @@ class W3cCredential { + return utils_1.JsonTransformer.fromJSON(json, W3cCredential); + } + } +-__decorate([ +- (0, class_transformer_1.Expose)({ name: '@context' }), ++__decorate( ++ [ ++ (0, class_transformer_1.Expose)({ name: "@context" }), + (0, validators_2.IsCredentialJsonLdContext)(), +- __metadata("design:type", Array) +-], W3cCredential.prototype, "context", void 0); +-__decorate([ ++ // FIXME: credentials issued by MATTR in JWT format use a string value for the @context ++ // The spec requires it to be an array. ++ // See: https://jfflabs.slack.com/archives/C05FTNY6GH2/p1695650296840639?thread_ts=1695648357.864189&cid=C05FTNY6GH2 ++ (0, class_transformer_1.Transform)(({ type, value }) => { ++ if (type !== class_transformer_1.TransformationType.PLAIN_TO_CLASS) ++ return value; ++ if (Array.isArray(value)) return value; ++ if (typeof value === "string") return [value]; ++ }), ++ __metadata("design:type", Array), ++ ], ++ W3cCredential.prototype, ++ "context", ++ void 0 ++); ++__decorate( ++ [ + (0, class_validator_1.IsOptional)(), +- (0, validators_1.IsUri)(), +- __metadata("design:type", String) +-], W3cCredential.prototype, "id", void 0); ++ // FIXME: credential issued by MATTR launchpad uses an UUID as the `jti`, which is not a valid URI ++ // See: https://jfflabs.slack.com/archives/C05FTNY6GH2/p1695648357864189 ++ // (0, validators_1.IsUri)(), ++ __metadata("design:type", String), ++ ], ++ W3cCredential.prototype, ++ "id", ++ void 0 ++); + __decorate([ + IsCredentialType(), + __metadata("design:type", Array) diff --git a/package.json b/package.json index 8c28ae3a..40f487b6 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@cosmjs/utils": "npm:@cosmjs-rn/utils@^0.27.1", "@cosmjs/proto-signing": "npm:@cosmjs-rn/proto-signing@^0.27.1", "@cosmjs/crypto": "npm:@cosmjs-rn/crypto@^0.27.1", + "@aries-framework/core@0.4.1": "patch:@aries-framework/core@npm%3A0.4.1#./.yarn/patches/@aries-framework-core-npm-0.4.1-bf3ca88ff9.patch", "@sphereon/did-auth-siop@0.3.2-unstable.0": "patch:@sphereon/did-auth-siop@npm%3A0.3.2-unstable.0#./.yarn/patches/@sphereon-did-auth-siop-npm-0.3.2-unstable.0-6a34120d09.patch" }, "dependencies": { diff --git a/packages/agent/src/parsers.ts b/packages/agent/src/parsers.ts index e24cd836..e7c2b899 100644 --- a/packages/agent/src/parsers.ts +++ b/packages/agent/src/parsers.ts @@ -72,17 +72,22 @@ export const receiveCredentialFromOpenId4VciOffer = async ({ }) => { // Prefer did:jwk, otherwise use did:key, otherwise use undefined const didMethod = - supportsAllDidMethods || supportedDidMethods.includes('did:jwk') + supportsAllDidMethods || supportedDidMethods?.includes('did:jwk') ? 'jwk' - : supportedDidMethods.includes('did:key') + : // If supportedDidMethods is undefined, it means we couldn't determine the supported did methods + // This is either because an inline credential offer was used, or the issuer didn't declare which + // did methods are supported. + // NOTE: MATTR launchpad for JFF MUST use did:key. So it is important that the default + // method is did:key if supportedDidMethods is undefined. + supportedDidMethods?.includes('did:key') || supportedDidMethods === undefined ? 'key' : undefined if (!didMethod) { throw new Error( - `No supported did method could be found. Supported methods are did:key and did:jwk. Issuer supports ${supportedDidMethods.join( - ', ' - )}` + `No supported did method could be found. Supported methods are did:key and did:jwk. Issuer supports ${ + supportedDidMethods?.join(', ') ?? 'Unknown' + }` ) } @@ -111,6 +116,9 @@ export const receiveCredentialFromOpenId4VciOffer = async ({ verifyCredentialStatus: false, allowedCredentialFormats: [OpenIdCredentialFormatProfile.JwtVcJson], allowedProofOfPossessionSignatureAlgorithms: [ + // NOTE: MATTR launchpad for JFF MUST use EdDSA. So it is important that the default (first allowed one) + // is EdDSA. The list is ordered by preference, so if no suites are defined by the issuer, the first one + // will be used JwaSignatureAlgorithm.EdDSA, JwaSignatureAlgorithm.ES256, ], diff --git a/packages/openid4vc-client/src/OpenId4VcClientService.ts b/packages/openid4vc-client/src/OpenId4VcClientService.ts index 7e628ae2..5a34c295 100644 --- a/packages/openid4vc-client/src/OpenId4VcClientService.ts +++ b/packages/openid4vc-client/src/OpenId4VcClientService.ts @@ -14,9 +14,10 @@ import type { W3cVerifyCredentialResult, } from '@aries-framework/core' import type { + CredentialOfferFormat, CredentialResponse, - CredentialSupported, Jwt, + OfferedCredentialsWithMetadata, OpenIDResponse, } from '@sphereon/oid4vci-common' @@ -187,21 +188,28 @@ export class OpenId4VcClientService { // Loop through all the credentialTypes in the credential offer for (const offeredCredential of client.getOfferedCredentialsWithMetadata()) { + const format = ( + isInlineCredentialOffer(offeredCredential) + ? offeredCredential.inlineCredentialOffer.format + : offeredCredential.credentialSupported.format + ) as SupportedCredentialFormats + // TODO: support inline credential offers. Not clear to me how to determine the did method / alg, etc.. if (offeredCredential.type === OfferedCredentialType.InlineCredentialOffer) { - throw new AriesFrameworkError("Inline credential offers aren't supported") - } - - const supportedCredentialMetadata = offeredCredential.credentialSupported - - // FIXME - // If the credential id ends with the format, it is a v8 credential supported that has been - // split into multiple entries (each entry can now only have one format). For now we continue - // as assume there will be another entry with the correct format. - if (supportedCredentialMetadata.id?.endsWith(`-${supportedCredentialMetadata.format}`)) { - const format = getUniformFormat(supportedCredentialMetadata.format) - if (!allowedCredentialFormats.includes(format as SupportedCredentialFormats)) { - continue + // Check if the format is supported/allowed + if (!allowedCredentialFormats.includes(format)) continue + } else { + const supportedCredentialMetadata = offeredCredential.credentialSupported + + // FIXME + // If the credential id ends with the format, it is a v8 credential supported that has been + // split into multiple entries (each entry can now only have one format). For now we continue + // as assume there will be another entry with the correct format. + if (supportedCredentialMetadata.id?.endsWith(`-${supportedCredentialMetadata.format}`)) { + const uniformFormat = getUniformFormat( + supportedCredentialMetadata.format + ) as SupportedCredentialFormats + if (!allowedCredentialFormats.includes(uniformFormat)) continue } } @@ -211,7 +219,7 @@ export class OpenId4VcClientService { { allowedCredentialFormats, allowedProofOfPossessionSignatureAlgorithms, - credentialMetadata: supportedCredentialMetadata, + offeredCredentialWithMetadata: offeredCredential, proofOfPossessionVerificationMethodResolver: options.proofOfPossessionVerificationMethodResolver, } @@ -243,10 +251,19 @@ export class OpenId4VcClientService { .withTokenFromResponse(accessToken) .build() - const credentialResponse = await credentialRequestClient.acquireCredentialsUsingProof({ - proofInput, - credentialSupported: supportedCredentialMetadata, - }) + let credentialResponse: OpenIDResponse + + if (isInlineCredentialOffer(offeredCredential)) { + credentialResponse = await credentialRequestClient.acquireCredentialsUsingProof({ + proofInput, + inlineCredentialOffer: offeredCredential.inlineCredentialOffer, + }) + } else { + credentialResponse = await credentialRequestClient.acquireCredentialsUsingProof({ + proofInput, + credentialSupported: offeredCredential.credentialSupported, + }) + } const credential = await this.handleCredentialResponse(agentContext, credentialResponse, { verifyCredentialStatus: options.verifyCredentialStatus, @@ -261,8 +278,15 @@ export class OpenId4VcClientService { }) this.logger.debug('Full credential', credentialRecord) - // Set the OpenId4Vc credential metadata and update record - setOpenId4VcCredentialMetadata(credentialRecord, supportedCredentialMetadata, serverMetadata) + if (!isInlineCredentialOffer(offeredCredential)) { + const supportedCredentialMetadata = offeredCredential.credentialSupported + // Set the OpenId4Vc credential metadata and update record + setOpenId4VcCredentialMetadata( + credentialRecord, + supportedCredentialMetadata, + serverMetadata + ) + } receivedCredentials.push(credentialRecord) } @@ -282,12 +306,12 @@ export class OpenId4VcClientService { proofOfPossessionVerificationMethodResolver: ProofOfPossessionVerificationMethodResolver allowedCredentialFormats: SupportedCredentialFormats[] allowedProofOfPossessionSignatureAlgorithms: JwaSignatureAlgorithm[] - credentialMetadata: CredentialSupported + offeredCredentialWithMetadata: OfferedCredentialsWithMetadata } ) { const { signatureAlgorithm, supportedDidMethods, supportsAllDidMethods } = this.getProofOfPossessionRequirements(agentContext, { - credentialMetadata: options.credentialMetadata, + offeredCredentialWithMetadata: options.offeredCredentialWithMetadata, allowedCredentialFormats: options.allowedCredentialFormats, allowedProofOfPossessionSignatureAlgorithms: options.allowedProofOfPossessionSignatureAlgorithms, @@ -305,7 +329,9 @@ export class OpenId4VcClientService { JwkClass.keyType ) - const format = getUniformFormat(options.credentialMetadata.format) + const format = isInlineCredentialOffer(options.offeredCredentialWithMetadata) + ? options.offeredCredentialWithMetadata.inlineCredentialOffer.format + : options.offeredCredentialWithMetadata.credentialSupported.format // Now we need to determine the did method and alg based on the cryptographic suite const verificationMethod = await options.proofOfPossessionVerificationMethodResolver({ @@ -313,7 +339,9 @@ export class OpenId4VcClientService { proofOfPossessionSignatureAlgorithm: signatureAlgorithm, supportedVerificationMethods, keyType: JwkClass.keyType, - supportedCredentialId: options.credentialMetadata.id as string, + supportedCredentialId: !isInlineCredentialOffer(options.offeredCredentialWithMetadata) + ? options.offeredCredentialWithMetadata.credentialSupported.id + : undefined, supportsAllDidMethods, supportedDidMethods, }) @@ -321,6 +349,9 @@ export class OpenId4VcClientService { // Make sure the verification method uses a supported did method if ( !supportsAllDidMethods && + // If supportedDidMethods is undefined, it means the issuer didn't include the binding methods in the metadata + // The user can still select a verification method, but we can't validate it + supportedDidMethods !== undefined && !supportedDidMethods.find((supportedDidMethod) => verificationMethod.id.startsWith(supportedDidMethod) ) @@ -353,32 +384,44 @@ export class OpenId4VcClientService { agentContext: AgentContext, options: { allowedCredentialFormats: SupportedCredentialFormats[] - credentialMetadata: CredentialSupported + offeredCredentialWithMetadata: OfferedCredentialsWithMetadata allowedProofOfPossessionSignatureAlgorithms: JwaSignatureAlgorithm[] } ): ProofOfPossessionRequirements { - const { credentialMetadata, allowedCredentialFormats } = options + const { offeredCredentialWithMetadata, allowedCredentialFormats } = options + + // Extract format from offer + let format = + offeredCredentialWithMetadata.type === OfferedCredentialType.InlineCredentialOffer + ? offeredCredentialWithMetadata.inlineCredentialOffer.format + : offeredCredentialWithMetadata.credentialSupported.format // Get uniform format, so we don't have to deal with the different spec versions - const format = getUniformFormat(credentialMetadata.format) + format = getUniformFormat(format) - if (!allowedCredentialFormats.includes(format as SupportedCredentialFormats)) { - throw new AriesFrameworkError( - `Issuer only supports format '${format}' for credential type '${ - credentialMetadata.id as string - }', but the wallet only allows formats '${options.allowedCredentialFormats.join(', ')}'` - ) - } + const credentialMetadata = + offeredCredentialWithMetadata.type === OfferedCredentialType.CredentialSupported + ? offeredCredentialWithMetadata.credentialSupported + : undefined - const issuerSupportedCryptographicSuites = - credentialMetadata.cryptographic_suites_supported ?? [] - const issuerSupportedBindingMethods: string[] = - credentialMetadata.cryptographic_binding_methods_supported ?? + const issuerSupportedCryptographicSuites = credentialMetadata?.cryptographic_suites_supported + const issuerSupportedBindingMethods = + credentialMetadata?.cryptographic_binding_methods_supported ?? // FIXME: somehow the MATTR Launchpad returns binding_methods_supported instead of cryptographic_binding_methods_supported // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - (credentialMetadata.binding_methods_supported as string[] | undefined) ?? - [] + (credentialMetadata?.binding_methods_supported as string[] | undefined) + + if (!isInlineCredentialOffer(offeredCredentialWithMetadata)) { + const credentialMetadata = offeredCredentialWithMetadata.credentialSupported + if (!allowedCredentialFormats.includes(format as SupportedCredentialFormats)) { + throw new AriesFrameworkError( + `Issuer only supports format '${format}' for credential type '${ + credentialMetadata.id as string + }', but the wallet only allows formats '${options.allowedCredentialFormats.join(', ')}'` + ) + } + } // For each of the supported algs, find the key types, then find the proof types const signatureSuiteRegistry = agentContext.dependencyManager.resolve(SignatureSuiteRegistry) @@ -388,41 +431,55 @@ export class OpenId4VcClientService { switch (format) { case 'jwt_vc_json': case 'jwt_vc_json-ld': - potentialSignatureAlgorithm = options.allowedProofOfPossessionSignatureAlgorithms.find( - (signatureAlgorithm) => issuerSupportedCryptographicSuites.includes(signatureAlgorithm) - ) + // If undefined, it means the issuer didn't include the cryptographic suites in the metadata + // We just guess that the first one is supported + if (issuerSupportedCryptographicSuites === undefined) { + potentialSignatureAlgorithm = options.allowedProofOfPossessionSignatureAlgorithms[0] + } else { + potentialSignatureAlgorithm = options.allowedProofOfPossessionSignatureAlgorithms.find( + (signatureAlgorithm) => issuerSupportedCryptographicSuites.includes(signatureAlgorithm) + ) + } break case 'ldp_vc': - // We need to find it based on the JSON-LD proof type - potentialSignatureAlgorithm = options.allowedProofOfPossessionSignatureAlgorithms.find( - (signatureAlgorithm) => { - const JwkClass = getJwkClassFromJwaSignatureAlgorithm(signatureAlgorithm) - if (!JwkClass) return false - - // TODO: getByKeyType should return a list - const matchingSuite = signatureSuiteRegistry.getByKeyType(JwkClass.keyType) - if (!matchingSuite) return false - - return issuerSupportedCryptographicSuites.includes(matchingSuite.proofType) - } - ) + // If undefined, it means the issuer didn't include the cryptographic suites in the metadata + // We just guess that the first one is supported + if (issuerSupportedCryptographicSuites === undefined) { + potentialSignatureAlgorithm = options.allowedProofOfPossessionSignatureAlgorithms[0] + } else { + // We need to find it based on the JSON-LD proof type + potentialSignatureAlgorithm = options.allowedProofOfPossessionSignatureAlgorithms.find( + (signatureAlgorithm) => { + const JwkClass = getJwkClassFromJwaSignatureAlgorithm(signatureAlgorithm) + if (!JwkClass) return false + + // TODO: getByKeyType should return a list + const matchingSuite = signatureSuiteRegistry.getByKeyType(JwkClass.keyType) + if (!matchingSuite) return false + + return issuerSupportedCryptographicSuites.includes(matchingSuite.proofType) + } + ) + } break default: throw new AriesFrameworkError( - `Unsupported requested credential format '${credentialMetadata.format}' with id ${ - credentialMetadata.id as string + `Unsupported requested credential format '${format}' with id ${ + credentialMetadata?.id ?? 'Inline credential offer' }` ) } - const supportsAllDidMethods = issuerSupportedBindingMethods.includes('did') - const supportedDidMethods = issuerSupportedBindingMethods.filter((method) => + const supportsAllDidMethods = issuerSupportedBindingMethods?.includes('did') ?? false + const supportedDidMethods = issuerSupportedBindingMethods?.filter((method) => method.startsWith('did:') ) if (!potentialSignatureAlgorithm) { throw new AriesFrameworkError( - `Could not establish signature algorithm for id ${credentialMetadata.id as string}` + `Could not establish signature algorithm for format ${format} and id ${ + credentialMetadata?.id ?? 'Inline credential offer' + }` ) } @@ -559,3 +616,12 @@ export class OpenId4VcClientService { } } } + +function isInlineCredentialOffer( + offeredCredential: OfferedCredentialsWithMetadata +): offeredCredential is { + inlineCredentialOffer: CredentialOfferFormat + type: OfferedCredentialType.InlineCredentialOffer +} { + return offeredCredential.type === OfferedCredentialType.InlineCredentialOffer +} diff --git a/packages/openid4vc-client/src/OpenId4VcClientServiceOptions.ts b/packages/openid4vc-client/src/OpenId4VcClientServiceOptions.ts index 8ab45403..3d224878 100644 --- a/packages/openid4vc-client/src/OpenId4VcClientServiceOptions.ts +++ b/packages/openid4vc-client/src/OpenId4VcClientServiceOptions.ts @@ -114,8 +114,11 @@ export interface ProofOfPossessionVerificationMethodResolverOptions { /** * The credential type that will be requested from the issuer. This is * based on the credential types that are included the credential offer. + * + * If the offered credential is an inline credential offer, the value + * will be `undefined`. */ - supportedCredentialId: string + supportedCredentialId?: string /** * Whether the issuer supports the `did` cryptographic binding method, @@ -134,8 +137,16 @@ export interface ProofOfPossessionVerificationMethodResolverOptions { * MUST be based on one of these did methods. * * The did methods are returned in the format `did:`, e.g. `did:web`. + * + * The value is undefined in the case the supported did methods could not be extracted. + * This is the case when an inline credential was used, or when the issuer didn't include + * the supported did methods in the issuer metadata. + * + * NOTE: an empty array (no did methods supported) has a different meaning from the value + * being undefined (the supported did methods could not be extracted). If `supportsAllDidMethods` + * is true, the value of this property MUST be ignored. */ - supportedDidMethods: string[] + supportedDidMethods?: string[] } /** @@ -152,7 +163,7 @@ export type ProofOfPossessionVerificationMethodResolver = ( */ export interface ProofOfPossessionRequirements { signatureAlgorithm: JwaSignatureAlgorithm - supportedDidMethods: string[] + supportedDidMethods?: string[] supportsAllDidMethods: boolean } diff --git a/yarn.lock b/yarn.lock index 2540b79a..8f4a18dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -128,7 +128,7 @@ __metadata: "@aries-framework/core@patch:@aries-framework/core@npm%3A0.4.1#./.yarn/patches/@aries-framework-core-npm-0.4.1-bf3ca88ff9.patch::locator=paradym-wallet%40workspace%3A.": version: 0.4.1 - resolution: "@aries-framework/core@patch:@aries-framework/core@npm%3A0.4.1#./.yarn/patches/@aries-framework-core-npm-0.4.1-bf3ca88ff9.patch::version=0.4.1&hash=0a10d8&locator=paradym-wallet%40workspace%3A." + resolution: "@aries-framework/core@patch:@aries-framework/core@npm%3A0.4.1#./.yarn/patches/@aries-framework-core-npm-0.4.1-bf3ca88ff9.patch::version=0.4.1&hash=9fdd8c&locator=paradym-wallet%40workspace%3A." dependencies: "@digitalcredentials/jsonld": ^5.2.1 "@digitalcredentials/jsonld-signatures": ^9.3.1 @@ -157,7 +157,7 @@ __metadata: uuid: ^9.0.0 varint: ^6.0.0 web-did-resolver: ^2.0.21 - checksum: c2aebcf66753e94f4f50d388180f34ae13817a74c29f4af8c83c896d024360797289b25b7eac619be4ea476e1d272414dc22d377b6fbeff6462d581b4ebecf35 + checksum: 7e910659517c56276f4bb1a14767bdd0d0243241f86cc14c00324370db3bf7028d6c5dfad9439f197b7261fee3549305378bb35e5b54acd7d091bbd7756c1dcb languageName: node linkType: hard