Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
65 changes: 65 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

199 changes: 199 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import {
type SDJWTCompact,
type SDJWTConfig,
type Signer,
type SafeVerifyResult,
type VerificationError,
type VerificationErrorCode,
} from '@sd-jwt/types';
import {
base64urlDecode,
Expand Down Expand Up @@ -259,6 +262,202 @@ export class SDJwtInstance<ExtendedPayload extends SdJwtPayload, T = unknown> {
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?: VerifierOptions,
): Promise<
SafeVerifyResult<{
payload: ExtendedPayload;
header: Record<string, unknown> | undefined;
kb?: {
payload: Record<string, unknown>;
header: Record<string, unknown>;
};
}>
> {
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<string, unknown> | 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<string, unknown>; header: Record<string, unknown> }
| 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,
Expand Down
Loading
Loading