diff --git a/packages/core/README.md b/packages/core/README.md index 92443c1..40972bf 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -41,3 +41,68 @@ If you want to use the pure sd-jwt class or implement your own sd-jwt credential - @sd-jwt/present - @sd-jwt/types - @sd-jwt/utils + +### Verification + +The library provides two verification approaches: + +#### Standard Verification (Fail-Fast) + +The `verify()` method throws an error immediately when the first validation failure is encountered: + +```typescript +try { + const result = await sdjwt.verify(credential); + console.log('Verified payload:', result.payload); +} catch (error) { + console.error('Verification failed:', error.message); +} +``` + +#### Safe Verification (Collect All Errors) + +The `safeVerify()` method collects all validation errors instead of failing on the first one. This is useful when you want to show users all issues with a credential at once: + +```typescript +import type { SafeVerifyResult, VerificationError } from '@sd-jwt/types'; + +const result = await sdjwt.safeVerify(credential); + +if (result.success) { + // Verification succeeded + console.log('Verified payload:', result.data.payload); + console.log('Header:', result.data.header); + if (result.data.kb) { + console.log('Key binding:', result.data.kb); + } +} else { + // Verification failed - inspect all errors + for (const error of result.errors) { + console.error(`[${error.code}] ${error.message}`); + if (error.details) { + console.error('Details:', error.details); + } + } +} +``` + +##### Error Codes + +The `safeVerify()` method returns errors with the following codes: + +| Code | Description | +|------|-------------| +| `HASHER_NOT_FOUND` | Hasher function not configured | +| `VERIFIER_NOT_FOUND` | Verifier function not configured | +| `INVALID_SD_JWT` | SD-JWT structure is invalid or cannot be decoded | +| `INVALID_JWT_FORMAT` | JWT format is malformed | +| `JWT_NOT_YET_VALID` | JWT `iat` or `nbf` claim is in the future | +| `JWT_EXPIRED` | JWT `exp` claim is in the past | +| `INVALID_JWT_SIGNATURE` | Signature verification failed | +| `MISSING_REQUIRED_CLAIMS` | Required claim keys are not present | +| `KEY_BINDING_JWT_MISSING` | Key binding JWT required but not present | +| `KEY_BINDING_VERIFIER_NOT_FOUND` | Key binding verifier not configured | +| `KEY_BINDING_SIGNATURE_INVALID` | Key binding signature verification failed | +| `KEY_BINDING_SD_HASH_INVALID` | Key binding `sd_hash` does not match | +| `UNKNOWN_ERROR` | An unexpected error occurred | + diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4e40829..3bcbbfd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,9 +7,12 @@ import { KB_JWT_TYP, type KBOptions, type PresentationFrame, + type SafeVerifyResult, type SDJWTCompact, type SDJWTConfig, type Signer, + type VerificationError, + type VerificationErrorCode, } from '@sd-jwt/types'; import { base64urlDecode, @@ -259,6 +262,202 @@ export class SDJwtInstance { return { payload, header, kb }; } + /** + * Safe verification that collects all errors instead of failing fast. + * Returns a result object with either the verified data or an array of all errors. + * + * @param encodedSDJwt - The encoded SD-JWT to verify + * @param options - Verification options + * @returns A SafeVerifyResult containing either success data or collected errors + */ + public async safeVerify( + encodedSDJwt: string, + options?: T & VerifierOptions, + ): Promise< + SafeVerifyResult<{ + payload: ExtendedPayload; + header: Record | undefined; + kb?: { + payload: Record; + header: Record; + }; + }> + > { + const errors: VerificationError[] = []; + + // Helper to add errors + const addError = ( + code: VerificationErrorCode, + message: string, + details?: unknown, + ) => { + errors.push({ code, message, details }); + }; + + // Helper to convert exception to error code + const exceptionToCode = (error: Error): VerificationErrorCode => { + const message = error.message.toLowerCase(); + if (message.includes('hasher not found')) return 'HASHER_NOT_FOUND'; + if (message.includes('verifier not found')) return 'VERIFIER_NOT_FOUND'; + if (message.includes('invalid sd jwt') || message.includes('invalid jwt')) + return 'INVALID_SD_JWT'; + if (message.includes('not yet valid')) return 'JWT_NOT_YET_VALID'; + if (message.includes('expired')) return 'JWT_EXPIRED'; + if (message.includes('signature')) return 'INVALID_JWT_SIGNATURE'; + if (message.includes('missing required claim')) + return 'MISSING_REQUIRED_CLAIMS'; + if (message.includes('key binding jwt not exist')) + return 'KEY_BINDING_JWT_MISSING'; + if (message.includes('key binding verifier not found')) + return 'KEY_BINDING_VERIFIER_NOT_FOUND'; + if (message.includes('sd_hash')) return 'KEY_BINDING_SD_HASH_INVALID'; + return 'UNKNOWN_ERROR'; + }; + + // Check basic configuration first + if (!this.userConfig.hasher) { + addError('HASHER_NOT_FOUND', 'Hasher not found'); + } + if (!this.userConfig.verifier) { + addError('VERIFIER_NOT_FOUND', 'Verifier not found'); + } + + // If basic config is missing, return early + if (errors.length > 0) { + return { success: false, errors }; + } + + const hasher = this.userConfig.hasher as Hasher; + + // Try to decode and validate the SD-JWT + let sdjwt: SDJwt | undefined; + let payload: ExtendedPayload | undefined; + let header: Record | undefined; + + try { + sdjwt = await SDJwt.fromEncode(encodedSDJwt, hasher); + if (!sdjwt.jwt || !sdjwt.jwt.payload) { + addError('INVALID_SD_JWT', 'Invalid SD JWT: missing JWT or payload'); + } + } catch (error) { + addError( + 'INVALID_SD_JWT', + `Failed to decode SD-JWT: ${(error as Error).message}`, + error, + ); + } + + // Validate signature and claims + if (sdjwt?.jwt) { + try { + const result = await this.VerifyJwt(sdjwt.jwt, options); + header = result.header; + const claims = await sdjwt.getClaims(hasher); + payload = claims as ExtendedPayload; + } catch (error) { + const code = exceptionToCode(error as Error); + addError(code, (error as Error).message, error); + } + } + + // Check required claim keys + if (sdjwt && options?.requiredClaimKeys) { + try { + const keys = await sdjwt.keys(hasher); + const missingKeys = options.requiredClaimKeys.filter( + (k) => !keys.includes(k), + ); + if (missingKeys.length > 0) { + addError( + 'MISSING_REQUIRED_CLAIMS', + `Missing required claim keys: ${missingKeys.join(', ')}`, + { missingKeys }, + ); + } + } catch (error) { + addError( + 'UNKNOWN_ERROR', + `Failed to check required claims: ${(error as Error).message}`, + error, + ); + } + } + + // Verify key binding if requested + let kb: + | { payload: Record; header: Record } + | undefined; + if (options?.keyBindingNonce && sdjwt) { + if (!sdjwt.kbJwt) { + addError('KEY_BINDING_JWT_MISSING', 'Key Binding JWT not exist'); + } else if (!this.userConfig.kbVerifier) { + addError( + 'KEY_BINDING_VERIFIER_NOT_FOUND', + 'Key Binding Verifier not found', + ); + } else if (payload) { + try { + const kbResult = await sdjwt.kbJwt.verifyKB({ + verifier: this.userConfig.kbVerifier, + payload: payload as JwtPayload, + nonce: options.keyBindingNonce, + }); + if (!kbResult) { + addError( + 'KEY_BINDING_SIGNATURE_INVALID', + 'Key binding signature is not valid', + ); + } else { + kb = kbResult; + + // Verify sd_hash + const sdjwtWithoutKb = new SDJwt({ + jwt: sdjwt.jwt, + disclosures: sdjwt.disclosures, + }); + const presentSdJwtWithoutKb = sdjwtWithoutKb.encodeSDJwt(); + const sdHashStr = await this.calculateSDHash( + presentSdJwtWithoutKb, + sdjwt, + hasher, + ); + + if (sdHashStr !== kbResult.payload.sd_hash) { + addError( + 'KEY_BINDING_SD_HASH_INVALID', + 'Invalid sd_hash in Key Binding JWT', + { + expected: sdHashStr, + received: kbResult.payload.sd_hash, + }, + ); + } + } + } catch (error) { + addError( + 'KEY_BINDING_SIGNATURE_INVALID', + `Key binding verification failed: ${(error as Error).message}`, + error, + ); + } + } + } + + // Return result + if (errors.length > 0) { + return { success: false, errors }; + } + + return { + success: true, + data: { + payload: payload as ExtendedPayload, + header, + kb, + }, + }; + } + private async calculateSDHash( presentSdJwtWithoutKb: string, sdjwt: SDJwt, diff --git a/packages/core/src/test/index.spec.ts b/packages/core/src/test/index.spec.ts index d69124d..f5f2966 100644 --- a/packages/core/src/test/index.spec.ts +++ b/packages/core/src/test/index.spec.ts @@ -627,4 +627,156 @@ describe('index', () => { expect(decode).toBeDefined(); }, ); + + test('safeVerify - success case', async () => { + const { signer, verifier } = createSignerVerifier(); + const sdjwt = new SDJwtInstance({ + signer, + signAlg: 'EdDSA', + verifier, + hasher: digest, + saltGenerator: generateSalt, + }); + + const credential = await sdjwt.issue( + { + foo: 'bar', + iss: 'Issuer', + iat: Math.floor(Date.now() / 1000), + }, + { + _sd: ['foo'], + }, + ); + + const result = await sdjwt.safeVerify(credential); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.payload).toBeDefined(); + expect(result.data.payload.foo).toBe('bar'); + } + }); + + test('safeVerify - collect multiple errors', async () => { + const sdjwt = new SDJwtInstance({}); + + const result = await sdjwt.safeVerify('invalid.jwt.token'); + + expect(result.success).toBe(false); + if (!result.success) { + // Should have multiple errors: hasher not found, verifier not found + expect(result.errors.length).toBeGreaterThanOrEqual(2); + const errorCodes = result.errors.map((e) => e.code); + expect(errorCodes).toContain('HASHER_NOT_FOUND'); + expect(errorCodes).toContain('VERIFIER_NOT_FOUND'); + } + }); + + test('safeVerify - invalid signature error', async () => { + const { signer } = createSignerVerifier(); + const { verifier: wrongVerifier } = createSignerVerifier(); // Different key pair + + const issuer = new SDJwtInstance({ + signer, + signAlg: 'EdDSA', + hasher: digest, + saltGenerator: generateSalt, + }); + + const verifierInstance = new SDJwtInstance({ + verifier: wrongVerifier, + hasher: digest, + }); + + const credential = await issuer.issue( + { + foo: 'bar', + iss: 'Issuer', + iat: Math.floor(Date.now() / 1000), + }, + { + _sd: ['foo'], + }, + ); + + const result = await verifierInstance.safeVerify(credential); + + expect(result.success).toBe(false); + if (!result.success) { + // The error message from the verifier contains 'signature' which maps to INVALID_JWT_SIGNATURE + const hasSignatureError = result.errors.some( + (e) => + e.code === 'INVALID_JWT_SIGNATURE' || + e.message.toLowerCase().includes('signature'), + ); + expect(hasSignatureError).toBe(true); + } + }); + + test('safeVerify - expired JWT error', async () => { + const { signer, verifier } = createSignerVerifier(); + const sdjwt = new SDJwtInstance({ + signer, + signAlg: 'EdDSA', + verifier, + hasher: digest, + saltGenerator: generateSalt, + }); + + const credential = await sdjwt.issue( + { + foo: 'bar', + iss: 'Issuer', + iat: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago + exp: Math.floor(Date.now() / 1000) - 1800, // Expired 30 min ago + }, + { + _sd: ['foo'], + }, + ); + + const result = await sdjwt.safeVerify(credential); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors.some((e) => e.code === 'JWT_EXPIRED')).toBe(true); + } + }); + + test('safeVerify - missing required claims error', async () => { + const { signer, verifier } = createSignerVerifier(); + const sdjwt = new SDJwtInstance({ + signer, + signAlg: 'EdDSA', + verifier, + hasher: digest, + saltGenerator: generateSalt, + }); + + const credential = await sdjwt.issue( + { + foo: 'bar', + iss: 'Issuer', + iat: Math.floor(Date.now() / 1000), + }, + { + _sd: ['foo'], + }, + ); + + // Present without disclosing 'foo' + const presentation = await sdjwt.present(credential, {}); + + const result = await sdjwt.safeVerify(presentation, { + requiredClaimKeys: ['foo', 'missing_claim'], + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.errors.some((e) => e.code === 'MISSING_REQUIRED_CLAIMS'), + ).toBe(true); + } + }); }); diff --git a/packages/sd-jwt-vc/README.md b/packages/sd-jwt-vc/README.md index 27c459c..02888f7 100644 --- a/packages/sd-jwt-vc/README.md +++ b/packages/sd-jwt-vc/README.md @@ -109,6 +109,63 @@ The library will load load the type metadata format based on the `vct` value acc Since at this point the display is not yet implemented, the library will only validate the schema and return the type metadata format. In the future the values of the type metadata can be fetched via a function call. +### Verification + +The library provides two verification approaches: + +#### Standard Verification (Fail-Fast) + +The `verify()` method throws an error immediately when the first validation failure is encountered: + +```typescript +try { + const result = await sdjwt.verify(presentation); + console.log('Verified payload:', result.payload); +} catch (error) { + console.error('Verification failed:', error.message); +} +``` + +#### Safe Verification (Collect All Errors) + +The `safeVerify()` method collects all validation errors instead of failing on the first one. This is useful when you want to show users all issues with a credential at once, including signature, status (revocation), and VCT metadata validation: + +```typescript +import type { SafeVerifyResult, VerificationError } from '@sd-jwt/types'; + +const result = await sdjwt.safeVerify(presentation); + +if (result.success) { + // Verification succeeded + console.log('Verified payload:', result.payload); + console.log('Header:', result.header); + if (result.kb) { + console.log('Key binding:', result.kb); + } + if (result.typeMetadata) { + console.log('Type metadata:', result.typeMetadata); + } +} else { + // Verification failed - inspect all errors + for (const error of result.errors) { + console.error(`[${error.code}] ${error.message}`); + if (error.details) { + console.error('Details:', error.details); + } + } +} +``` + +##### SD-JWT-VC Specific Error Codes + +In addition to the [core error codes](../core/README.md#error-codes), `safeVerify()` in SD-JWT-VC can return: + +| Code | Description | +|------|-------------| +| `STATUS_VERIFICATION_FAILED` | Status list fetch or verification failed | +| `STATUS_INVALID` | Credential status indicates revocation | +| `VCT_VERIFICATION_FAILED` | VCT type metadata fetch or validation failed | + ### Dependencies - @sd-jwt/core diff --git a/packages/sd-jwt-vc/src/sd-jwt-vc-instance.ts b/packages/sd-jwt-vc/src/sd-jwt-vc-instance.ts index 9e22454..a39ae81 100644 --- a/packages/sd-jwt-vc/src/sd-jwt-vc-instance.ts +++ b/packages/sd-jwt-vc/src/sd-jwt-vc-instance.ts @@ -5,7 +5,14 @@ import { type StatusListJWTHeaderParameters, type StatusListJWTPayload, } from '@sd-jwt/jwt-status-list'; -import type { DisclosureFrame, Hasher, Verifier } from '@sd-jwt/types'; +import type { + DisclosureFrame, + Hasher, + SafeVerifyResult, + VerificationError, + VerificationErrorCode, + Verifier, +} from '@sd-jwt/types'; import { SDJWTException } from '@sd-jwt/utils'; import z from 'zod'; import type { @@ -133,6 +140,122 @@ export class SDJwtVcInstance extends SDJwtInstance { return result; } + /** + * Safe verification that collects all errors instead of failing fast. + * Returns a result object with either the verified data or an array of all errors. + * This includes SD-JWT-VC specific validations like status and VCT metadata. + * + * @param encodedSDJwt - The encoded SD-JWT-VC to verify + * @param options - Verification options + * @returns A SafeVerifyResult containing either success data or collected errors + */ + async safeVerify( + encodedSDJwt: string, + options?: VerifierOptions, + ): Promise> { + const errors: VerificationError[] = []; + + // Helper to add errors + const addError = ( + code: VerificationErrorCode, + message: string, + details?: unknown, + ) => { + errors.push({ code, message, details }); + }; + + // First, call the parent's safeVerify to get base verification results + const baseResult = await super.safeVerify(encodedSDJwt, options); + + // Collect errors from base verification + if (!baseResult.success) { + errors.push(...baseResult.errors); + } + + // Build partial result for additional verifications + let result: VerificationResult | undefined; + if (baseResult.success) { + result = { + payload: baseResult.data.payload as SdJwtVcPayload, + header: baseResult.data.header, + kb: baseResult.data.kb as VerificationResult['kb'], + }; + } else { + // Try to extract payload even if verification failed for status/vct checks + try { + const { payload, header } = await SDJwt.extractJwt< + Record, + SdJwtVcPayload + >(encodedSDJwt); + if (payload) { + result = { + payload, + header, + kb: undefined, + }; + } + } catch { + // Cannot extract payload, skip additional checks + } + } + + // Verify status (if payload is available) + if (result) { + try { + await this.verifyStatus(result, options); + } catch (error) { + const errorMessage = (error as Error).message; + if (errorMessage.includes('Status is not valid')) { + addError('STATUS_INVALID', errorMessage, error); + } else { + addError( + 'STATUS_VERIFICATION_FAILED', + `Status verification failed: ${errorMessage}`, + error, + ); + } + } + + // Verify VCT metadata (if configured) + if (this.userConfig.loadTypeMetadataFormat) { + try { + const resolvedTypeMetadata = await this.fetchVct(result); + if (result) { + result.typeMetadata = resolvedTypeMetadata; + } + } catch (error) { + addError( + 'VCT_VERIFICATION_FAILED', + `VCT verification failed: ${(error as Error).message}`, + error, + ); + } + } + } + + // Return result + if (errors.length > 0) { + return { success: false, errors }; + } + + if (!result) { + return { + success: false, + errors: [ + { + code: 'INVALID_SD_JWT', + message: 'Failed to construct verification result', + }, + ], + }; + } + + return { + success: true, + data: result, + }; + } + /** * Gets VCT Metadata of the raw SD-JWT-VC. Returns the type metadata format. If the SD-JWT-VC is invalid or does not contain a vct claim, an error is thrown. * diff --git a/packages/types/README.md b/packages/types/README.md index 5cbe34c..e3d61f4 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -34,6 +34,27 @@ Ensure you have Node.js installed as a prerequisite. Check out more details in our [documentation](https://github.com/openwallet-foundation/sd-jwt-js/tree/main/docs) or [examples](https://github.com/openwallet-foundation/sd-jwt-js/tree/main/examples) +### Verification Types + +The package exports types for safe verification that collects all errors: + +```typescript +import type { + SafeVerifyResult, + VerificationError, + VerificationErrorCode, +} from '@sd-jwt/types'; + +// SafeVerifyResult is a discriminated union: +// - { success: true; data: T } on success +// - { success: false; errors: VerificationError[] } on failure + +// VerificationError contains: +// - code: VerificationErrorCode (e.g., 'INVALID_JWT_SIGNATURE') +// - message: string +// - details?: unknown +``` + ### Dependencies None diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index b38ebc9..4b0e73b 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1 +1,2 @@ export * from './type'; +export * from './verification-error'; diff --git a/packages/types/src/verification-error.ts b/packages/types/src/verification-error.ts new file mode 100644 index 0000000..11a1ed2 --- /dev/null +++ b/packages/types/src/verification-error.ts @@ -0,0 +1,55 @@ +/** + * Error codes for SD-JWT verification errors. + */ +export type VerificationErrorCode = + | 'HASHER_NOT_FOUND' + | 'VERIFIER_NOT_FOUND' + | 'INVALID_SD_JWT' + | 'INVALID_JWT_FORMAT' + | 'JWT_NOT_YET_VALID' + | 'JWT_EXPIRED' + | 'INVALID_JWT_SIGNATURE' + | 'MISSING_REQUIRED_CLAIMS' + | 'KEY_BINDING_JWT_MISSING' + | 'KEY_BINDING_VERIFIER_NOT_FOUND' + | 'KEY_BINDING_SIGNATURE_INVALID' + | 'KEY_BINDING_SD_HASH_INVALID' + | 'STATUS_VERIFICATION_FAILED' + | 'STATUS_INVALID' + | 'VCT_VERIFICATION_FAILED' + | 'UNKNOWN_ERROR'; + +/** + * Represents a single verification error. + */ +export type VerificationError = { + /** + * The error code identifying the type of error. + */ + code: VerificationErrorCode; + + /** + * Human-readable error message. + */ + message: string; + + /** + * Optional additional details about the error. + */ + details?: unknown; +}; + +/** + * Result type for safe verification that collects all errors. + */ +export type SafeVerifyResult = + | { + success: true; + data: T; + errors?: never; + } + | { + success: false; + data?: never; + errors: VerificationError[]; + };