From ab9c2fea3df35daaf9e2e75387805b51cdc92d21 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Fri, 15 May 2026 14:49:12 -0300 Subject: [PATCH 1/5] feat: support importing decoded SD-JWT VC payloads --- integration-tests/sd-jwt.test.ts | 32 +++++++++++++++++++ packages/core/src/credential-provider.ts | 22 +++++++------ .../wasm/src/services/credential/sd-jwt.ts | 29 +++++++++++++++-- 3 files changed, 71 insertions(+), 12 deletions(-) diff --git a/integration-tests/sd-jwt.test.ts b/integration-tests/sd-jwt.test.ts index 7215703a..c841e838 100644 --- a/integration-tests/sd-jwt.test.ts +++ b/integration-tests/sd-jwt.test.ts @@ -50,6 +50,38 @@ describe('SD JWT Credentials', () => { expect(credential.expirationDate).toBe(undefined); }); + it('expect to import a decoded SD-JWT payload object', async () => { + const decodedPayload = { + iat: 1778854828, + iss: 'did:cheqd:testnet:c0890f1c-c7bb-4ea6-be7a-8c31404743b7#keys-1', + vct: 'BasicCredential', + _sd: [ + '4bqpGCWfOvT_RcZYuyteRnUuFMaHsynG6RNQDh0v4UA', + 'ocOxmMDZJuQvIaHsCFPur2fkM6q6eeAMGvS5uvBRvJ8', + ], + _sd_alg: 'sha-256', + }; + + const result = await addCredentialIfNotExists(decodedPayload); + expect(result?.id).toBeDefined(); + + const credential = await getCredentialProvider().getById(result.id); + + expect(credential).toBeDefined(); + expect(credential.type).toEqual([ + 'VerifiableCredential', + decodedPayload.vct, + ]); + expect(credential.issuer).toBe(decodedPayload.iss); + expect(credential.issuanceDate).toBe( + new Date(decodedPayload.iat * 1000).toISOString(), + ); + + expect(credential.credentialSubject).toEqual({}); + expect(credential._sd_jwt).toBeDefined(); + expect(credential._sd_jwt.encoded).toBeUndefined(); + }); + it('expect to create presentation from SD-JWT credential', async () => { const credential = await getCredentialProvider().getById(credentialId); diff --git a/packages/core/src/credential-provider.ts b/packages/core/src/credential-provider.ts index 37394af9..3ca3715f 100644 --- a/packages/core/src/credential-provider.ts +++ b/packages/core/src/credential-provider.ts @@ -173,18 +173,22 @@ export const ACUMM_WITNESS_PROP_KEY = '$$accum__witness$$'; * @private */ export async function addCredential({wallet, credential}) { - // Check if the credential is an SD-JWT (string format) - if (typeof credential === 'string') { - try { - const isSDJWT = await credentialServiceRPC.isSDJWTCredential({credential}); - - if (isSDJWT) { + if ( + credential && + (typeof credential === 'string' || typeof credential === 'object') + ) { + const isSDJWT = await credentialServiceRPC.isSDJWTCredential({credential}); + + if (isSDJWT) { + try { // Convert SD-JWT to W3C format (includes _sd_jwt metadata for unwrapping) credential = await credentialServiceRPC.credentialToW3C({credential}); + } catch (error) { + console.error('Error checking/converting SD-JWT credential:', error); + throw new Error( + 'Failed to process SD-JWT credential: ' + error.message, + ); } - } catch (error) { - console.error('Error checking/converting SD-JWT credential:', error); - throw new Error('Failed to process SD-JWT credential: ' + error.message); } } diff --git a/packages/wasm/src/services/credential/sd-jwt.ts b/packages/wasm/src/services/credential/sd-jwt.ts index 3d7bd622..f6b9f189 100644 --- a/packages/wasm/src/services/credential/sd-jwt.ts +++ b/packages/wasm/src/services/credential/sd-jwt.ts @@ -3,10 +3,33 @@ import {digest, generateSalt} from '@sd-jwt/crypto-nodejs'; import base64url from 'base64url'; /** - * Checks if a JWT string is an SD-JWT credential + * Checks if a value is a decoded SD-JWT payload object — i.e. an SD-JWT VC + * payload returned as JSON rather than the compact `header.payload.sig~...` form. */ -export function isSDJWTCredential(jwt) { - const jwtHeader = jwt.split('.')[0]; +export function isDecodedSDJWTPayload(value): boolean { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + Array.isArray((value as any)._sd) && + typeof (value as any)._sd_alg === 'string' + ); +} + +/** + * Checks if a credential is an SD-JWT credential. + * Accepts either the compact SD-JWT string or a decoded SD-JWT VC payload object. + */ +export function isSDJWTCredential(credential) { + if (isDecodedSDJWTPayload(credential)) { + return true; + } + + if (typeof credential !== 'string') { + return false; + } + + const jwtHeader = credential.split('.')[0]; const decodedHeader = JSON.parse(base64url.decode(jwtHeader)); return decodedHeader.typ === 'dc+sd-jwt' || decodedHeader.typ === 'vc+sd-jwt'; } From af3d813ef2f714ce41959d07c4dab4bf06b834c0 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Fri, 15 May 2026 15:39:20 -0300 Subject: [PATCH 2/5] feat: honor issuer-advertised format in OID4VCI credential acquisition --- .../wasm/src/services/credential/oid4vci.js | 25 ++++ .../src/services/credential/oid4vci.test.js | 117 ++++++++++++++++++ .../wasm/src/services/credential/sd-jwt.ts | 8 ++ .../wasm/src/services/credential/service.ts | 7 +- 4 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 packages/wasm/src/services/credential/oid4vci.js create mode 100644 packages/wasm/src/services/credential/oid4vci.test.js diff --git a/packages/wasm/src/services/credential/oid4vci.js b/packages/wasm/src/services/credential/oid4vci.js new file mode 100644 index 00000000..fec1ccf7 --- /dev/null +++ b/packages/wasm/src/services/credential/oid4vci.js @@ -0,0 +1,25 @@ +export function resolveOfferedCredentialConfig(client) { + const supported = client.getCredentialsSupported(); + const offerIds = + client.credentialOffer?.credential_offer?.credential_configuration_ids ?? + client.credentialOffer?.credential_configuration_ids ?? + []; + + if (Array.isArray(supported)) { + return supported[0]; + } + + const matched = offerIds.map(id => supported[id]).find(Boolean); + return matched ?? Object.values(supported)[0]; +} + +export function resolveFormatAndType(config) { + const format = config.format; + const scopeTail = config.scope?.split(':').slice(-1)[0]; + const definitionType = config.credential_definition?.type?.slice(-1)[0]; + + const credentialTypes = + format === 'vc+sd-jwt' ? config.vct : definitionType ?? scopeTail; + + return {format, credentialTypes}; +} diff --git a/packages/wasm/src/services/credential/oid4vci.test.js b/packages/wasm/src/services/credential/oid4vci.test.js new file mode 100644 index 00000000..002de67b --- /dev/null +++ b/packages/wasm/src/services/credential/oid4vci.test.js @@ -0,0 +1,117 @@ +import { + resolveOfferedCredentialConfig, + resolveFormatAndType, +} from './oid4vci'; + +function makeClient({supported, offerIds}) { + return { + getCredentialsSupported: () => supported, + credentialOffer: offerIds + ? {credential_offer: {credential_configuration_ids: offerIds}} + : undefined, + }; +} + +describe('OID4VCI offer resolution', () => { + describe('resolveOfferedCredentialConfig', () => { + it('returns the entry matching the offered configuration id', () => { + const supported = { + 'ldp_vc:MyCredential': {format: 'vc+sd-jwt', vct: 'MyCredential'}, + 'ldp_vc:OtherCredential': {format: 'ldp_vc'}, + }; + const client = makeClient({ + supported, + offerIds: ['ldp_vc:MyCredential'], + }); + + expect(resolveOfferedCredentialConfig(client)).toBe( + supported['ldp_vc:MyCredential'], + ); + }); + + it('falls back to the first record entry when the offer id does not match', () => { + const supported = { + 'ldp_vc:A': {format: 'ldp_vc'}, + 'ldp_vc:B': {format: 'vc+sd-jwt'}, + }; + const client = makeClient({supported, offerIds: ['nonexistent']}); + + expect(resolveOfferedCredentialConfig(client)).toBe(supported['ldp_vc:A']); + }); + + it('handles array-shaped credentialsSupported by returning the first entry', () => { + const supported = [ + {format: 'vc+sd-jwt', vct: 'MyCredential'}, + {format: 'ldp_vc'}, + ]; + const client = makeClient({supported, offerIds: []}); + + expect(resolveOfferedCredentialConfig(client)).toBe(supported[0]); + }); + + it('reads offer ids from credentialOffer.credential_configuration_ids when not nested', () => { + const supported = { + 'ldp_vc:Wanted': {format: 'vc+sd-jwt', vct: 'Wanted'}, + 'ldp_vc:Other': {format: 'ldp_vc'}, + }; + const client = { + getCredentialsSupported: () => supported, + credentialOffer: {credential_configuration_ids: ['ldp_vc:Wanted']}, + }; + + expect(resolveOfferedCredentialConfig(client)).toBe( + supported['ldp_vc:Wanted'], + ); + }); + }); + + describe('resolveFormatAndType', () => { + it('returns vct as credentialTypes for vc+sd-jwt', () => { + const result = resolveFormatAndType({ + format: 'vc+sd-jwt', + vct: 'MyCredential', + scope: 'ldp_vc:MyCredential', + }); + + expect(result).toEqual({ + format: 'vc+sd-jwt', + credentialTypes: 'MyCredential', + }); + }); + + it('returns the last credential_definition.type entry for ldp_vc', () => { + const result = resolveFormatAndType({ + format: 'ldp_vc', + credential_definition: { + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + }, + }); + + expect(result).toEqual({ + format: 'ldp_vc', + credentialTypes: 'UniversityDegreeCredential', + }); + }); + + it('falls back to the scope suffix when credential_definition is missing', () => { + const result = resolveFormatAndType({ + format: 'ldp_vc', + scope: 'ldp_vc:LegacyCredential', + }); + + expect(result).toEqual({ + format: 'ldp_vc', + credentialTypes: 'LegacyCredential', + }); + }); + + it('returns undefined credentialTypes when nothing identifies the type', () => { + const result = resolveFormatAndType({format: 'jwt_vc_json'}); + + expect(result).toEqual({ + format: 'jwt_vc_json', + credentialTypes: undefined, + }); + }); + }); +}); diff --git a/packages/wasm/src/services/credential/sd-jwt.ts b/packages/wasm/src/services/credential/sd-jwt.ts index f6b9f189..809b6e86 100644 --- a/packages/wasm/src/services/credential/sd-jwt.ts +++ b/packages/wasm/src/services/credential/sd-jwt.ts @@ -223,6 +223,11 @@ export async function decodeSDJWTToW3C(sdJwtString) { * @returns {Promise} W3C Verifiable Credential format */ export async function credentialToW3C(credential) { + // Decoded SD-JWT VC payload (no compact serialization available) + if (isDecodedSDJWTPayload(credential)) { + return sdJwtToW3C({jwt: {header: {}, payload: credential}, disclosures: []}); + } + // If it's already an object with a type field, assume it's already W3C format if (typeof credential === 'object' && credential.type) { return credential; @@ -233,6 +238,9 @@ export async function credentialToW3C(credential) { // First try to parse as JSON try { const parsed = JSON.parse(credential); + if (isDecodedSDJWTPayload(parsed)) { + return sdJwtToW3C({jwt: {header: {}, payload: parsed}, disclosures: []}); + } if (parsed.type) { return parsed; } diff --git a/packages/wasm/src/services/credential/service.ts b/packages/wasm/src/services/credential/service.ts index 863905f5..7655d684 100644 --- a/packages/wasm/src/services/credential/service.ts +++ b/packages/wasm/src/services/credential/service.ts @@ -48,6 +48,7 @@ import {getIsRevoked, getWitnessDetails, prefetchWitnessCache} from './bbs-revoc import {getPexRequiredAttributes, shouldSkipAttribute, findMatchingDescriptor} from './pex-helpers'; import {didService} from '../dids/service'; import {isSDJWTCredential as checkIsSDJWT, credentialToW3C as convertCredentialToW3C, verifySDJWT, createSDJWTPresentation} from './sd-jwt'; +import {resolveOfferedCredentialConfig, resolveFormatAndType} from './oid4vci'; export const credentialUtils = {...credentialSdkVc}; @@ -540,10 +541,8 @@ class CredentialService { }, }); - const format = 'ldp_vc'; - const { scope } = client.getCredentialsSupported()[0]; - const scopeSplit = scope.split(':'); - const credentialTypes = scopeSplit[scopeSplit.length - 1]; + const config = resolveOfferedCredentialConfig(client); + const {format, credentialTypes} = resolveFormatAndType(config); let code; From 23e053a9173b1374263a353d8018037a5c033f9c Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Mon, 18 May 2026 16:06:40 -0300 Subject: [PATCH 3/5] fix: lint formatting in OID4VCI tests --- packages/wasm/src/services/credential/oid4vci.test.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/wasm/src/services/credential/oid4vci.test.js b/packages/wasm/src/services/credential/oid4vci.test.js index 002de67b..2a98c317 100644 --- a/packages/wasm/src/services/credential/oid4vci.test.js +++ b/packages/wasm/src/services/credential/oid4vci.test.js @@ -1,7 +1,4 @@ -import { - resolveOfferedCredentialConfig, - resolveFormatAndType, -} from './oid4vci'; +import {resolveOfferedCredentialConfig, resolveFormatAndType} from './oid4vci'; function makeClient({supported, offerIds}) { return { @@ -36,7 +33,9 @@ describe('OID4VCI offer resolution', () => { }; const client = makeClient({supported, offerIds: ['nonexistent']}); - expect(resolveOfferedCredentialConfig(client)).toBe(supported['ldp_vc:A']); + expect(resolveOfferedCredentialConfig(client)).toBe( + supported['ldp_vc:A'], + ); }); it('handles array-shaped credentialsSupported by returning the first entry', () => { From 6f1f5923441753d99c343663af7081df35216e81 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Wed, 20 May 2026 14:03:28 -0300 Subject: [PATCH 4/5] fix: address PR review feedback for SD-JWT and OID4VCI --- packages/core/src/credential-provider.ts | 17 ++++++------ .../wasm/src/services/credential/oid4vci.js | 22 +++++++++++++--- .../src/services/credential/oid4vci.test.js | 26 +++++++++++++++++++ .../wasm/src/services/credential/sd-jwt.ts | 13 +++++++--- 4 files changed, 62 insertions(+), 16 deletions(-) diff --git a/packages/core/src/credential-provider.ts b/packages/core/src/credential-provider.ts index 3ca3715f..91cc095f 100644 --- a/packages/core/src/credential-provider.ts +++ b/packages/core/src/credential-provider.ts @@ -177,18 +177,17 @@ export async function addCredential({wallet, credential}) { credential && (typeof credential === 'string' || typeof credential === 'object') ) { - const isSDJWT = await credentialServiceRPC.isSDJWTCredential({credential}); + try { + const isSDJWT = await credentialServiceRPC.isSDJWTCredential({ + credential, + }); - if (isSDJWT) { - try { - // Convert SD-JWT to W3C format (includes _sd_jwt metadata for unwrapping) + if (isSDJWT) { credential = await credentialServiceRPC.credentialToW3C({credential}); - } catch (error) { - console.error('Error checking/converting SD-JWT credential:', error); - throw new Error( - 'Failed to process SD-JWT credential: ' + error.message, - ); } + } catch (error) { + console.error('Error checking/converting SD-JWT credential:', error); + throw new Error('Failed to process SD-JWT credential: ' + error.message); } } diff --git a/packages/wasm/src/services/credential/oid4vci.js b/packages/wasm/src/services/credential/oid4vci.js index fec1ccf7..ea8b9dec 100644 --- a/packages/wasm/src/services/credential/oid4vci.js +++ b/packages/wasm/src/services/credential/oid4vci.js @@ -13,13 +13,29 @@ export function resolveOfferedCredentialConfig(client) { return matched ?? Object.values(supported)[0]; } +const KNOWN_FORMATS = new Set([ + 'vc+sd-jwt', + 'dc+sd-jwt', + 'ldp_vc', + 'jwt_vc_json', + 'jwt_vc_json-ld', + 'jwt_vc', +]); + export function resolveFormatAndType(config) { - const format = config.format; - const scopeTail = config.scope?.split(':').slice(-1)[0]; + const scopeSegments = config.scope?.split(':') ?? []; + const scopeTail = scopeSegments.slice(-1)[0]; + const scopePrefix = scopeSegments.length > 1 ? scopeSegments[0] : undefined; const definitionType = config.credential_definition?.type?.slice(-1)[0]; + const format = + config.format ?? + (scopePrefix && KNOWN_FORMATS.has(scopePrefix) ? scopePrefix : 'ldp_vc'); + const credentialTypes = - format === 'vc+sd-jwt' ? config.vct : definitionType ?? scopeTail; + format === 'vc+sd-jwt' || format === 'dc+sd-jwt' + ? config.vct + : definitionType ?? scopeTail; return {format, credentialTypes}; } diff --git a/packages/wasm/src/services/credential/oid4vci.test.js b/packages/wasm/src/services/credential/oid4vci.test.js index 2a98c317..1d48288b 100644 --- a/packages/wasm/src/services/credential/oid4vci.test.js +++ b/packages/wasm/src/services/credential/oid4vci.test.js @@ -112,5 +112,31 @@ describe('OID4VCI offer resolution', () => { credentialTypes: undefined, }); }); + + it('derives format from a known scope prefix when format is missing', () => { + const result = resolveFormatAndType({ + scope: 'vc+sd-jwt:MyCredential', + vct: 'MyCredential', + }); + + expect(result).toEqual({ + format: 'vc+sd-jwt', + credentialTypes: 'MyCredential', + }); + }); + + it('defaults to ldp_vc when format and a recognizable scope prefix are missing', () => { + const result = resolveFormatAndType({ + scope: 'MyCredential', + credential_definition: { + type: ['VerifiableCredential', 'MyCredential'], + }, + }); + + expect(result).toEqual({ + format: 'ldp_vc', + credentialTypes: 'MyCredential', + }); + }); }); }); diff --git a/packages/wasm/src/services/credential/sd-jwt.ts b/packages/wasm/src/services/credential/sd-jwt.ts index 809b6e86..2dea1579 100644 --- a/packages/wasm/src/services/credential/sd-jwt.ts +++ b/packages/wasm/src/services/credential/sd-jwt.ts @@ -25,13 +25,18 @@ export function isSDJWTCredential(credential) { return true; } - if (typeof credential !== 'string') { + if (typeof credential !== 'string' || !credential.includes('.')) { return false; } - const jwtHeader = credential.split('.')[0]; - const decodedHeader = JSON.parse(base64url.decode(jwtHeader)); - return decodedHeader.typ === 'dc+sd-jwt' || decodedHeader.typ === 'vc+sd-jwt'; + try { + const decodedHeader = JSON.parse(base64url.decode(credential.split('.')[0])); + return ( + decodedHeader?.typ === 'dc+sd-jwt' || decodedHeader?.typ === 'vc+sd-jwt' + ); + } catch { + return false; + } } export async function createSDJWTPresentation({ From b83124fe8c03044af7ce749fe24c0856027c0c86 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Thu, 21 May 2026 09:17:55 -0300 Subject: [PATCH 5/5] fix: match offered credential id in legacy array-shaped supported list --- .../wasm/src/services/credential/oid4vci.js | 5 ++++- .../src/services/credential/oid4vci.test.js | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/wasm/src/services/credential/oid4vci.js b/packages/wasm/src/services/credential/oid4vci.js index ea8b9dec..c6518d91 100644 --- a/packages/wasm/src/services/credential/oid4vci.js +++ b/packages/wasm/src/services/credential/oid4vci.js @@ -6,7 +6,10 @@ export function resolveOfferedCredentialConfig(client) { []; if (Array.isArray(supported)) { - return supported[0]; + const matchedLegacy = supported.find( + entry => entry?.id && offerIds.includes(entry.id), + ); + return matchedLegacy ?? supported[0]; } const matched = offerIds.map(id => supported[id]).find(Boolean); diff --git a/packages/wasm/src/services/credential/oid4vci.test.js b/packages/wasm/src/services/credential/oid4vci.test.js index 1d48288b..3c798eb5 100644 --- a/packages/wasm/src/services/credential/oid4vci.test.js +++ b/packages/wasm/src/services/credential/oid4vci.test.js @@ -48,6 +48,26 @@ describe('OID4VCI offer resolution', () => { expect(resolveOfferedCredentialConfig(client)).toBe(supported[0]); }); + it('matches an array-shaped entry by id when the offer provides one', () => { + const supported = [ + {id: 'ldp_vc:A', format: 'ldp_vc'}, + {id: 'sdjwt:B', format: 'vc+sd-jwt', vct: 'B'}, + ]; + const client = makeClient({supported, offerIds: ['sdjwt:B']}); + + expect(resolveOfferedCredentialConfig(client)).toBe(supported[1]); + }); + + it('falls back to the first array entry when no offered id matches', () => { + const supported = [ + {id: 'ldp_vc:A', format: 'ldp_vc'}, + {id: 'sdjwt:B', format: 'vc+sd-jwt'}, + ]; + const client = makeClient({supported, offerIds: ['nonexistent']}); + + expect(resolveOfferedCredentialConfig(client)).toBe(supported[0]); + }); + it('reads offer ids from credentialOffer.credential_configuration_ids when not nested', () => { const supported = { 'ldp_vc:Wanted': {format: 'vc+sd-jwt', vct: 'Wanted'},