src/verifier/presentations/credential/sdjwtvcverifier/sdjwtvcverifier.service.ts
Properties |
|
Methods |
|
constructor(resolverService: ResolverService, cryptoService: CryptoImplementationService, chainValidation: CredentialChainValidationService)
|
||||||||||||
|
Parameters :
|
| Private validateTransactionDataHashes | ||||||||||||
validateTransactionDataHashes(result: VerificationResult, transactionData: string[])
|
||||||||||||
|
Validates that the KB-JWT contains transaction_data_hashes that match the SHA-256 hashes of the provided transaction data. According to OID4VP spec Appendix B.3.3.1:
Parameters :
Returns :
void
|
| Async verify | |||||||||
verify(cred: string, options: VerifierOptions)
|
|||||||||
|
Verifies an SD-JWT-VC credential. Creates a fresh SDJwtVcInstance per verification to safely capture the matched TrustedEntity for status list verification. If transaction data is provided in options, this method also validates that the KB-JWT contains matching transaction_data_hashes. See OID4VP spec Appendix B.3.3.1 for details.
Parameters :
Returns :
Promise<VerificationResult>
|
| Private Async verifyCredential | ||||||||||||
verifyCredential(data: string, signature: string, options: VerifierOptions)
|
||||||||||||
|
Verifies the SD-JWT-VC credential signature and trust chain. Returns both the verification result and the matched TrustedEntity. data = "<b64url(header)>.<b64url(payload)>" signature = "<b64url(signature)>"
Parameters :
Returns :
Promise<literal type>
|
| Private Async verifyStatusList | ||||||||||||||||||||
verifyStatusList(data: string, signature: string, options: VerifierOptions, matchedEntity: MatchedTrustedEntity | null)
|
||||||||||||||||||||
|
Verifies the status list JWT using the revocation certificate from the same TrustedEntity that issued the credential. This ensures that the status list is signed by the authorized revocation service of the same entity that issued the credential.
Parameters :
Returns :
Promise<boolean>
true if the status list is validly signed by the entity's revocation cert |
| Private Readonly kbVerifier | ||||
Type : KbVerifier
|
||||
Default value : () => {...}
|
||||
|
Verifier for keybindings. It will verify the signature of the keybinding and return true if it is valid. |
||||
|
Parameters :
|
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(SdjwtvcverifierService.name)
|
import { createHash } from "node:crypto";
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { digest } from "@sd-jwt/crypto-nodejs";
import { SDJwtVcInstance, VerificationResult } from "@sd-jwt/sd-jwt-vc";
import { KbVerifier } from "@sd-jwt/types";
import { base64url, JWK } from "jose";
import { CryptoImplementationService } from "../../../../crypto/key/crypto-implementation/crypto-implementation.service";
import { VerifierOptions } from "../../../../shared/trust/types";
import { MatchedTrustedEntity } from "../../../../shared/trust/x509-validation.service";
import { ResolverService } from "../../../resolver/resolver.service";
import { CredentialChainValidationService } from "../credential-chain-validation.service";
@Injectable()
export class SdjwtvcverifierService {
private readonly logger = new Logger(SdjwtvcverifierService.name);
constructor(
private readonly resolverService: ResolverService,
private readonly cryptoService: CryptoImplementationService,
private readonly chainValidation: CredentialChainValidationService,
) {}
/**
* Verifies an SD-JWT-VC credential.
* Creates a fresh SDJwtVcInstance per verification to safely capture
* the matched TrustedEntity for status list verification.
*
* If transaction data is provided in options, this method also validates
* that the KB-JWT contains matching transaction_data_hashes.
* See OID4VP spec Appendix B.3.3.1 for details.
*
* @param cred
* @param options
* @returns
*/
async verify(
cred: string,
options: VerifierOptions,
): Promise<VerificationResult> {
// Closure to capture the matched TrustedEntity during verification
let matchedEntity: MatchedTrustedEntity | null = null;
// Create a fresh instance per verification to ensure thread safety
const sdjwtInstance = new SDJwtVcInstance({
hasher: digest,
verifier: async (data: string, signature: string) => {
const result = await this.verifyCredential(
data,
signature,
options,
);
matchedEntity = result.matchedEntity;
return result.verified;
},
kbVerifier: this.kbVerifier.bind(this),
statusListFetcher: (uri: string) =>
this.chainValidation.fetchStatusListJwt(uri),
statusVerifier: (data: string, signature: string) => {
// Verify status list JWT using the revocation cert from the same entity
return this.verifyStatusList(
data,
signature,
options,
matchedEntity,
);
},
});
const result = await sdjwtInstance.verify(cred, options as any);
// Validate transaction data hashes if transaction data was provided
if (options.transactionData && options.transactionData.length > 0) {
this.validateTransactionDataHashes(result, options.transactionData);
}
return result;
}
/**
* Validates that the KB-JWT contains transaction_data_hashes that match
* the SHA-256 hashes of the provided transaction data.
*
* According to OID4VP spec Appendix B.3.3.1:
* - transaction_data_hashes: A non-empty array of strings where each element
* is a base64url-encoded hash calculated over the transaction data string
* (base64url decoding is NOT performed before hashing).
* - The hash function defaults to SHA-256 unless transaction_data_hashes_alg
* specifies otherwise.
*
* @param result The verification result containing the KB-JWT payload
* @param transactionData The base64url-encoded transaction data strings from the request
* @throws BadRequestException if validation fails
*/
private validateTransactionDataHashes(
result: VerificationResult,
transactionData: string[],
): void {
const kbPayload = result.kb?.payload as
| (Record<string, unknown> & {
transaction_data_hashes?: string[];
transaction_data_hashes_alg?: string;
})
| undefined;
if (!kbPayload) {
throw new BadRequestException(
"Transaction data was provided but KB-JWT is missing",
);
}
const receivedHashes = kbPayload.transaction_data_hashes;
if (!receivedHashes || !Array.isArray(receivedHashes)) {
throw new BadRequestException(
"Transaction data was provided but KB-JWT does not contain transaction_data_hashes",
);
}
if (receivedHashes.length !== transactionData.length) {
throw new BadRequestException(
`Transaction data hash count mismatch: expected ${transactionData.length}, received ${receivedHashes.length}`,
);
}
// Determine hash algorithm - defaults to sha-256 per spec
const hashAlg = kbPayload.transaction_data_hashes_alg ?? "sha-256";
// Map OID4VP hash algorithm names to Node.js crypto names
const algoMap: Record<string, string> = {
"sha-256": "sha256",
"sha-384": "sha384",
"sha-512": "sha512",
};
const nodeAlgo = algoMap[hashAlg];
if (!nodeAlgo) {
throw new BadRequestException(
`Unsupported transaction_data_hashes_alg: ${hashAlg}`,
);
}
// Compute expected hashes and compare
// Per spec: hash is computed over the string as-is (no base64url decoding)
for (let i = 0; i < transactionData.length; i++) {
const expectedHash = base64url.encode(
createHash(nodeAlgo).update(transactionData[i]).digest(),
);
if (receivedHashes[i] !== expectedHash) {
this.logger.debug(
`Transaction data hash mismatch at index ${i}: expected ${expectedHash}, received ${receivedHashes[i]}`,
);
throw new BadRequestException(
`Transaction data hash mismatch at index ${i}`,
);
}
}
this.logger.debug(
`Transaction data hashes validated successfully (${transactionData.length} entries)`,
);
}
/**
* Verifies the SD-JWT-VC credential signature and trust chain.
* Returns both the verification result and the matched TrustedEntity.
*
* data = "<b64url(header)>.<b64url(payload)>"
* signature = "<b64url(signature)>"
*/
private async verifyCredential(
data: string,
signature: string,
options: VerifierOptions,
): Promise<{
verified: boolean;
matchedEntity: MatchedTrustedEntity | null;
}> {
try {
// 1) Verify SD-JWT signature first (fast fail)
const [headerB64] = data.split(".");
const headerJson = Buffer.from(
headerB64.replaceAll("-", "+").replaceAll("_", "/"),
"base64",
).toString("utf8");
const header = JSON.parse(headerJson);
const publicKey =
await this.resolverService.resolvePublicKey(header);
const crypto = this.cryptoService.getCryptoFromJwk(publicKey);
const verifier = await crypto.getVerifier(publicKey);
const sigOk = await verifier(data, signature)
.then(() => true)
.catch((e) => {
this.logger.debug(
`SD-JWT signature invalid: ${e?.message ?? e}`,
);
return false;
});
if (!sigOk) return { verified: false, matchedEntity: null };
// 2) Validate certificate chain using shared service
const x5c: string[] | undefined = header?.x5c;
const chainResult = await this.chainValidation.validateChain(
x5c ?? [],
options.trustListSource,
{
requireX5c: options?.policy.requireX5c,
pinnedCertMode: options?.policy.pinnedCertMode ?? "leaf",
serviceTypeFilter: "/Issuance",
},
);
if (!chainResult.verified) {
if (chainResult.errorDetails) {
this.logger.warn(
`Certificate chain validation failed: ${chainResult.errorDetails}`,
);
}
return { verified: false, matchedEntity: null };
}
return { verified: true, matchedEntity: chainResult.matchedEntity };
} catch (e: any) {
this.logger.error(`Error in verifier: ${e?.message ?? e}`);
return { verified: false, matchedEntity: null };
}
}
/**
* Verifies the status list JWT using the revocation certificate from
* the same TrustedEntity that issued the credential.
*
* This ensures that the status list is signed by the authorized revocation
* service of the same entity that issued the credential.
*
* @param data The JWT data to verify (header.payload)
* @param signature The JWT signature
* @param options The verification options
* @param matchedEntity The TrustedEntity that matched during credential verification
* @returns true if the status list is validly signed by the entity's revocation cert
*/
private async verifyStatusList(
data: string,
signature: string,
options: VerifierOptions,
matchedEntity: MatchedTrustedEntity | null,
): Promise<boolean> {
try {
// 1) Verify the signature of the status list JWT
const [headerB64] = data.split(".");
const headerJson = Buffer.from(
headerB64.replaceAll("-", "+").replaceAll("_", "/"),
"base64",
).toString("utf8");
const header = JSON.parse(headerJson);
const publicKey =
await this.resolverService.resolvePublicKey(header);
const crypto = this.cryptoService.getCryptoFromJwk(publicKey);
const verifier = await crypto.getVerifier(publicKey);
const sigOk = await verifier(data, signature)
.then(() => true)
.catch((e) => {
this.logger.debug(
`Status list JWT signature invalid: ${e?.message ?? e}`,
);
return false;
});
if (!sigOk) return false;
// 2) Verify status list certificate chain using shared service
const x5c: string[] | undefined = header?.x5c;
return await this.chainValidation.verifyStatusListSignature(
x5c,
matchedEntity,
options.trustListSource,
{
pinnedCertMode: options.policy.pinnedCertMode ?? "leaf",
},
);
} catch (e: any) {
this.logger.error(
`Error verifying status list: ${e?.message ?? e}`,
);
return false;
}
}
/**
* Verifier for keybindings. It will verify the signature of the keybinding and return true if it is valid.
* @param data
* @param signature
* @param payload
* @returns
*/
private readonly kbVerifier: KbVerifier = async (
data,
signature,
payload,
) => {
if (!payload.cnf) {
throw new Error("No cnf found in the payload");
}
const jwk: JWK = (payload.cnf as any).jwk;
const crypto = this.cryptoService.getCryptoFromJwk(jwk);
const verifier = await crypto.getVerifier(jwk);
return verifier(data, signature);
};
}