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..91cc095f 100644 --- a/packages/core/src/credential-provider.ts +++ b/packages/core/src/credential-provider.ts @@ -173,13 +173,16 @@ 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') { + if ( + credential && + (typeof credential === 'string' || typeof credential === 'object') + ) { try { - const isSDJWT = await credentialServiceRPC.isSDJWTCredential({credential}); + const isSDJWT = await credentialServiceRPC.isSDJWTCredential({ + credential, + }); if (isSDJWT) { - // Convert SD-JWT to W3C format (includes _sd_jwt metadata for unwrapping) credential = await credentialServiceRPC.credentialToW3C({credential}); } } catch (error) { diff --git a/packages/wasm/src/services/credential/oid4vci.js b/packages/wasm/src/services/credential/oid4vci.js new file mode 100644 index 00000000..c6518d91 --- /dev/null +++ b/packages/wasm/src/services/credential/oid4vci.js @@ -0,0 +1,44 @@ +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)) { + const matchedLegacy = supported.find( + entry => entry?.id && offerIds.includes(entry.id), + ); + return matchedLegacy ?? supported[0]; + } + + const matched = offerIds.map(id => supported[id]).find(Boolean); + 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 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' || 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 new file mode 100644 index 00000000..3c798eb5 --- /dev/null +++ b/packages/wasm/src/services/credential/oid4vci.test.js @@ -0,0 +1,162 @@ +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('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'}, + '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, + }); + }); + + 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 3d7bd622..2dea1579 100644 --- a/packages/wasm/src/services/credential/sd-jwt.ts +++ b/packages/wasm/src/services/credential/sd-jwt.ts @@ -3,12 +3,40 @@ 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]; - const decodedHeader = JSON.parse(base64url.decode(jwtHeader)); - return decodedHeader.typ === 'dc+sd-jwt' || decodedHeader.typ === 'vc+sd-jwt'; +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' || !credential.includes('.')) { + return false; + } + + 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({ @@ -200,6 +228,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; @@ -210,6 +243,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;