Skip to content

Commit fce2c25

Browse files
committed
Fix SD-JWT signature verification
Signed-off-by: Sam Hellawell <sshellawell@gmail.com>
1 parent 40272d7 commit fce2c25

8 files changed

Lines changed: 493 additions & 268 deletions

File tree

package-lock.json

Lines changed: 274 additions & 161 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"@babel/preset-env": "^7.22.10",
1717
"@docknetwork/cheqd-blockchain-api": "6.0.1",
1818
"@docknetwork/cheqd-blockchain-modules": "6.0.1",
19-
"@docknetwork/credential-sdk": "0.56.1",
19+
"@docknetwork/credential-sdk": "0.56.2",
2020
"@docknetwork/universal-wallet": "2.1.1",
2121
"@react-native-community/eslint-config": "^2.0.0",
2222
"@testing-library/jest-dom": "^5.11.4",

packages/core/src/credential-provider.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ export async function isValid({
112112

113113
const {verified, error} = verificationResult;
114114

115+
const errorMessage =
116+
typeof error === 'string'
117+
? error
118+
: error?.message || error?.errors?.[0]?.message || error;
119+
115120
if (error) {
116121
const sdkNotInitialized = error?.errors?.find(err => err?.message === 'SDK is not initialized');
117122
if (sdkNotInitialized) {
@@ -122,17 +127,17 @@ export async function isValid({
122127
}
123128

124129
if (!verified) {
125-
const errorMessage = (error?.message || error || '').toString().toLowerCase();
126-
if (errorMessage.includes('revok')) {
130+
const normalizedError = (errorMessage || '').toString().toLowerCase();
131+
if (normalizedError.includes('revok')) {
127132
return {
128133
status: CredentialStatus.Revoked,
129-
error,
134+
error: errorMessage,
130135
};
131136
}
132137

133138
return {
134139
status: CredentialStatus.Invalid,
135-
error: error,
140+
error: errorMessage,
136141
};
137142
}
138143

packages/wasm/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,15 @@
2323
"@cosmjs/proto-signing": "^0.32.4",
2424
"@docknetwork/cheqd-blockchain-api": "6.0.1",
2525
"@docknetwork/cheqd-blockchain-modules": "6.0.1",
26-
"@docknetwork/credential-sdk": "0.56.1",
26+
"@docknetwork/credential-sdk": "0.56.2",
27+
"@docknetwork/crypto-utils": "0.2.2",
2728
"@docknetwork/universal-wallet": "^2.0.1",
2829
"@docknetwork/vc-delegation-engine": "1.0.4",
2930
"@docknetwork/wallet-sdk-dids": "^1.9.1",
3031
"@noble/hashes": "1.8.0",
3132
"@scure/bip39": "^1.6.0",
32-
"@sd-jwt/crypto-nodejs": "^0.15.0",
33-
"@sd-jwt/sd-jwt-vc": "^0.15.1",
33+
"@sd-jwt/crypto-nodejs": "0.9.2",
34+
"@sd-jwt/sd-jwt-vc": "0.9.2",
3435
"@sphereon/oid4vci-client": "^0.16.0",
3536
"@sphereon/oid4vci-common": "^0.16.0",
3637
"@sphereon/pex": "^1.1.4",

packages/wasm/src/services/credential/index.test.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@ describe('Credential Service', () => {
3737
const error0 = await getPromiseError(() => service.verifyCredential({}));
3838
expect(error0.message).toBe('invalid credential');
3939
});
40+
it('expect to reject SD-JWT credentials missing compact serialization', async () => {
41+
const result = await service.verifyCredential({
42+
credential: {
43+
'@context': ['https://www.w3.org/2018/credentials/v1'],
44+
type: ['VerifiableCredential', 'BasicCredential'],
45+
issuer: 'did:example:issuer',
46+
credentialSubject: {},
47+
_sd_jwt: {
48+
// decoded-payload imports store metadata without an encoded JWT
49+
},
50+
},
51+
});
52+
53+
expect(result.verified).toBe(false);
54+
expect(result.error).toContain('compact serialization');
55+
});
4056
it('expect to verify credential', async () => {
4157
jest
4258
.spyOn(serviceCredentialUtils, 'verifyCredential')

packages/wasm/src/services/credential/sd-jwt.test.ts

Lines changed: 153 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
// @ts-nocheck
2+
import {createJwsSigner, Ed25519Keypair} from '@docknetwork/crypto-utils';
3+
import {digest, generateSalt} from '@sd-jwt/crypto-nodejs';
4+
import {SDJwtVcInstance} from '@sd-jwt/sd-jwt-vc';
15
import {
26
isSDJWTCredential,
37
sdJwtToW3C,
@@ -11,6 +15,41 @@ import {
1115
// Test SD-JWT credential provided by the user
1216
const TEST_SD_JWT = 'eyJ0eXAiOiJ2YytzZC1qd3QiLCJraWQiOiJkaWQ6Y2hlcWQ6dGVzdG5ldDpjMDg5MGYxYy1jN2JiLTRlYTYtYmU3YS04YzMxNDA0NzQzYjcja2V5cy0xIiwiYWxnIjoiRWREU0EifQ.eyJpYXQiOjE3NTk0MTQzOTQsImlzcyI6ImRpZDpjaGVxZDp0ZXN0bmV0OmMwODkwZjFjLWM3YmItNGVhNi1iZTdhLThjMzE0MDQ3NDNiNyNrZXlzLTEiLCJ2Y3QiOiJJbnRlcm5hbFRlc3RpbmciLCJfc2QiOlsiM3JVUGt1Mk5XckFFeTV3ZE9uVms5TkJBa0haMWE4RDB6Y2liMFNQdmthWSIsIkhjNHAxMnZTTWNMQ0piNVRYVlFrdFFMR2xjM0J3SDNWN2ltakV5ZDdvdzAiLCJNZWNNZEd6NjAxY3kwTTdvanRtSjR1LUI5LTJxSXAya1RvbFpDUm1GZ1pFIiwiZmo4WHdBb0lERmRsWmpEa0NTVzVpeXBPYUZBcVplTWZDRncwTWd3cHhOQSJdLCJfc2RfYWxnIjoic2hhLTI1NiJ9.FcYvNrldceL5BTNmoIaS4Mub8a5NcbiseUeSmmvUpOW8SUom-bchV5AEefrH1VMECbdc2whhk2sW4_jmZo7_Dw~WyI1YTRkZWY1MTAzYjRiYjc0IiwiaWQiLCJkaWQ6a2V5Ono2TWt1OVI4emRBOExENmhjRlhrbjQ3akxuZmNLWk5HbXdhVHJEbmFDQmtTYjhVbiJd~WyJiZDUyMjQ5ZjAyNjk3NWRkIiwiZGF0ZSIsIjIwMjUtMTAtMDkiXQ~WyJlMjcxNzExNzVkODdlMWE1IiwibmFtZSIsIm1heWNvbiJd~WyIwZDFiOTBlNTlhNmMyNDNiIiwibnVtYmVyIiwxMjNd~';
1317

18+
const ISSUER_DID = 'did:ex:issuer#keys-1';
19+
20+
async function issueTestSdJwt(keypair, claims, disclosureFrame = {_sd: ['name', 'date', 'number', 'id']}) {
21+
const signer = createJwsSigner(keypair);
22+
const sdjwt = new SDJwtVcInstance({
23+
signer: async data => {
24+
const bytes = typeof data === 'string' ? Buffer.from(data) : data;
25+
const signature = await signer.sign({data: bytes});
26+
return Buffer.from(signature).toString('base64url');
27+
},
28+
signAlg: 'EdDSA',
29+
hasher: digest,
30+
hashAlg: 'sha-256',
31+
saltGenerator: generateSalt,
32+
});
33+
34+
return sdjwt.issue(claims, disclosureFrame, {
35+
header: {
36+
typ: 'vc+sd-jwt',
37+
alg: 'EdDSA',
38+
kid: claims.iss,
39+
},
40+
});
41+
}
42+
43+
function createIssuerKeypair() {
44+
return Ed25519Keypair.fromSeed(
45+
Uint8Array.from({length: Ed25519Keypair.SeedSize}, (_, index) => index + 1),
46+
);
47+
}
48+
49+
function createKeyResolver(keypair) {
50+
return async () => keypair.publicKey();
51+
}
52+
1453
// Regular JWT (not SD-JWT) for testing
1554
const REGULAR_JWT = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
1655

@@ -402,32 +441,69 @@ describe('SD-JWT Service', () => {
402441
});
403442

404443
describe('verifySDJWT', () => {
405-
it('should verify valid SD-JWT successfully', async () => {
406-
const result = await verifySDJWT(TEST_SD_JWT);
444+
it('should fail when no issuer key resolution options are provided', async () => {
445+
const keypair = createIssuerKeypair();
446+
const credential = await issueTestSdJwt(
447+
keypair,
448+
{
449+
iss: ISSUER_DID,
450+
iat: 1700000000,
451+
vct: 'InternalTesting',
452+
name: 'maycon',
453+
},
454+
{_sd: ['name']},
455+
);
407456

408-
expect(result).toHaveProperty('verified');
409-
expect(result.verified).toBe(true);
410-
expect(result.error).toBeUndefined();
457+
// Production callers must pass documentLoader/resolver/keyResolver.
458+
const result = await verifySDJWT(credential);
459+
460+
expect(result.verified).toBe(false);
461+
expect(result.error).toBeDefined();
411462
});
412463

413-
it('should fail verification for expired SD-JWT', async () => {
414-
// Create an expired SD-JWT (exp in the past)
415-
const expiredJWT = 'eyJ0eXAiOiJ2YytzZC1qd3QiLCJhbGciOiJFZERTQSJ9.eyJpc3MiOiJkaWQ6ZXhhbXBsZTppc3N1ZXIiLCJ2Y3QiOiJUZXN0Q3JlZGVudGlhbCIsImV4cCI6MTYwOTQ1OTIwMCwiaWF0IjoxNjA5NDU5MjAwfQ.signature~';
464+
it('should cryptographically verify a valid SD-JWT with keyResolver', async () => {
465+
const keypair = createIssuerKeypair();
466+
const credential = await issueTestSdJwt(keypair, {
467+
iss: ISSUER_DID,
468+
iat: 1700000000,
469+
exp: 1800000000,
470+
vct: 'InternalTesting',
471+
id: 'did:example:subject',
472+
name: 'maycon',
473+
date: '2025-10-09',
474+
number: 123,
475+
});
416476

417-
const result = await verifySDJWT(expiredJWT);
477+
const result = await verifySDJWT(credential, ['name'], {
478+
keyResolver: createKeyResolver(keypair),
479+
});
418480

419-
expect(result.verified).toBe(false);
420-
expect(result.error).toContain('expired');
481+
expect(result.verified).toBe(true);
482+
expect(result.error).toBeUndefined();
483+
expect(result.credentialResults[0]).toMatchObject({
484+
issuer: 'did:ex:issuer',
485+
type: ['InternalTesting'],
486+
credentialSubject: {
487+
name: 'maycon',
488+
},
489+
});
421490
});
422491

423-
it('should fail verification for not-yet-valid SD-JWT', async () => {
424-
// Create an SD-JWT with nbf in the future
425-
const futureJWT = 'eyJ0eXAiOiJ2YytzZC1qd3QiLCJhbGciOiJFZERTQSJ9.eyJpc3MiOiJkaWQ6ZXhhbXBsZTppc3N1ZXIiLCJ2Y3QiOiJUZXN0Q3JlZGVudGlhbCIsIm5iZiI6OTk5OTk5OTk5OSwiaWF0IjoxNjA5NDU5MjAwfQ.signature~';
492+
it('should fail verification when issuer key cannot be resolved', async () => {
493+
const keypair = createIssuerKeypair();
494+
const credential = await issueTestSdJwt(keypair, {
495+
iss: ISSUER_DID,
496+
iat: 1700000000,
497+
vct: 'InternalTesting',
498+
name: 'maycon',
499+
}, {_sd: ['name']});
426500

427-
const result = await verifySDJWT(futureJWT);
501+
const result = await verifySDJWT(credential, undefined, {
502+
keyResolver: async () => null,
503+
});
428504

429505
expect(result.verified).toBe(false);
430-
expect(result.error).toContain('not yet valid');
506+
expect(result.error).toBeDefined();
431507
});
432508

433509
it('should fail verification for invalid SD-JWT format', async () => {
@@ -437,11 +513,24 @@ describe('SD-JWT Service', () => {
437513
expect(result.error).toBeDefined();
438514
});
439515

440-
it('should verify SD-JWT without expiration date', async () => {
441-
// The TEST_SD_JWT doesn't have an expiration date, so it should verify
442-
const result = await verifySDJWT(TEST_SD_JWT);
516+
it('should fail verification with wrong issuer key', async () => {
517+
const issuerKeypair = createIssuerKeypair();
518+
const wrongKeypair = Ed25519Keypair.fromSeed(
519+
Uint8Array.from({length: Ed25519Keypair.SeedSize}, (_, index) => index + 9),
520+
);
521+
const credential = await issueTestSdJwt(issuerKeypair, {
522+
iss: ISSUER_DID,
523+
iat: 1700000000,
524+
vct: 'InternalTesting',
525+
name: 'maycon',
526+
}, {_sd: ['name']});
527+
528+
const result = await verifySDJWT(credential, undefined, {
529+
keyResolver: createKeyResolver(wrongKeypair),
530+
});
443531

444-
expect(result.verified).toBe(true);
532+
expect(result.verified).toBe(false);
533+
expect(result.error).toBeDefined();
445534
});
446535

447536
it('should return error message on verification failure', async () => {
@@ -602,25 +691,44 @@ describe('SD-JWT Service', () => {
602691
});
603692

604693
it('should preserve presentation format for verification flow', async () => {
694+
const keypair = createIssuerKeypair();
695+
const credential = await issueTestSdJwt(keypair, {
696+
iss: ISSUER_DID,
697+
iat: 1700000000,
698+
vct: 'InternalTesting',
699+
name: 'maycon',
700+
date: '2025-10-09',
701+
}, {_sd: ['name', 'date']});
702+
605703
const presentation = await createSDJWTPresentation({
606704
attributesToReveal: ['name', 'date'],
607-
credential: TEST_SD_JWT,
705+
credential,
608706
});
609707

610-
// The presentation should be verifiable
611-
const verificationResult = await verifySDJWT(presentation);
708+
const verificationResult = await verifySDJWT(presentation, undefined, {
709+
keyResolver: createKeyResolver(keypair),
710+
});
612711
expect(verificationResult.verified).toBe(true);
613712
});
614713
});
615714

616715
describe('Presentation Integration Tests', () => {
617716
it('should complete full flow: store credential -> create presentation -> verify', async () => {
717+
const keypair = createIssuerKeypair();
718+
const issued = await issueTestSdJwt(keypair, {
719+
iss: ISSUER_DID,
720+
iat: 1700000000,
721+
vct: 'InternalTesting',
722+
name: 'maycon',
723+
date: '2025-10-09',
724+
}, {_sd: ['name', 'date']});
725+
618726
// Step 1: Convert SD-JWT to W3C format (as done in addCredential)
619-
const w3cCredential = await decodeSDJWTToW3C(TEST_SD_JWT) as any;
727+
const w3cCredential = await decodeSDJWTToW3C(issued) as any;
620728

621729
// Verify metadata is stored
622730
expect(w3cCredential._sd_jwt).toBeDefined();
623-
expect(w3cCredential._sd_jwt.encoded).toBe(TEST_SD_JWT);
731+
expect(w3cCredential._sd_jwt.encoded).toBe(issued);
624732

625733
// Step 2: Create presentation from stored credential (as done in verification-controller)
626734
const presentation = await createSDJWTPresentation({
@@ -630,8 +738,10 @@ describe('SD-JWT Service', () => {
630738

631739
expect(typeof presentation).toBe('string');
632740

633-
// Step 3: Verify presentation
634-
const verificationResult = await verifySDJWT(presentation);
741+
// Step 3: Verify presentation cryptographically
742+
const verificationResult = await verifySDJWT(presentation, undefined, {
743+
keyResolver: createKeyResolver(keypair),
744+
});
635745
expect(verificationResult.verified).toBe(true);
636746

637747
// Step 4: Decode presentation to check disclosed claims
@@ -643,16 +753,26 @@ describe('SD-JWT Service', () => {
643753
});
644754

645755
it('should support multiple presentation creations from same credential', async () => {
756+
const keypair = createIssuerKeypair();
757+
const credential = await issueTestSdJwt(keypair, {
758+
iss: ISSUER_DID,
759+
iat: 1700000000,
760+
vct: 'InternalTesting',
761+
name: 'maycon',
762+
date: '2025-10-09',
763+
number: 123,
764+
}, {_sd: ['name', 'date', 'number']});
765+
646766
// Create first presentation with some attributes
647767
const presentation1 = await createSDJWTPresentation({
648768
attributesToReveal: ['name'],
649-
credential: TEST_SD_JWT,
769+
credential,
650770
});
651771

652772
// Create second presentation with different attributes
653773
const presentation2 = await createSDJWTPresentation({
654774
attributesToReveal: ['date', 'number'],
655-
credential: TEST_SD_JWT,
775+
credential,
656776
});
657777

658778
// Both should be valid
@@ -662,9 +782,10 @@ describe('SD-JWT Service', () => {
662782
// They should be different (different disclosures)
663783
expect(presentation1).not.toBe(presentation2);
664784

665-
// Both should verify
666-
const result1 = await verifySDJWT(presentation1);
667-
const result2 = await verifySDJWT(presentation2);
785+
// Both should verify cryptographically
786+
const keyResolver = createKeyResolver(keypair);
787+
const result1 = await verifySDJWT(presentation1, undefined, {keyResolver});
788+
const result2 = await verifySDJWT(presentation2, undefined, {keyResolver});
668789

669790
expect(result1.verified).toBe(true);
670791
expect(result2.verified).toBe(true);

0 commit comments

Comments
 (0)