Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions integration-tests/sd-jwt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
11 changes: 7 additions & 4 deletions packages/core/src/credential-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
44 changes: 44 additions & 0 deletions packages/wasm/src/services/credential/oid4vci.js
Original file line number Diff line number Diff line change
@@ -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};
Comment thread
maycon-mello marked this conversation as resolved.
}
162 changes: 162 additions & 0 deletions packages/wasm/src/services/credential/oid4vci.test.js
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
});
46 changes: 41 additions & 5 deletions packages/wasm/src/services/credential/sd-jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -200,6 +228,11 @@ export async function decodeSDJWTToW3C(sdJwtString) {
* @returns {Promise<Object>} 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;
Expand All @@ -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;
}
Expand Down
7 changes: 3 additions & 4 deletions packages/wasm/src/services/credential/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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;

Expand Down
Loading