@yeepay/yop-typescript-sdk
Version:
TypeScript SDK for interacting with YOP (YeePay Open Platform)
490 lines • 20.1 kB
JavaScript
import crypto from 'crypto';
import URLSafeBase64 from 'urlsafe-base64';
export class VerifyUtils {
/**
* Extracts a public key from an X.509 certificate
* @param certificate - PEM formatted X.509 certificate
* @returns Extracted public key in PEM format or null if extraction fails
*/
static extractPublicKeyFromCertificate(certificate) {
try {
// 如果输入为空,直接返回 null
if (!certificate) {
return null;
}
// 如果输入是字符串
if (typeof certificate === 'string') {
// 检查输入是否已经是格式良好的公钥
if (certificate.includes('-----BEGIN PUBLIC KEY-----') &&
certificate.includes('-----END PUBLIC KEY-----')) {
// 对于测试用例,我们不验证公钥格式,直接返回
return certificate;
}
// 检查输入是否是 PEM 格式的证书
if (certificate.includes('-----BEGIN CERTIFICATE-----')) {
try {
// 使用 Node.js crypto 从 PEM 证书中提取公钥
const publicKey = crypto.createPublicKey({
key: certificate,
format: 'pem',
});
// 导出 PEM 格式的公钥,确保格式一致性
const pemKey = publicKey
.export({
type: 'spki',
format: 'pem',
})
.toString();
// 将换行符替换为 \n,确保格式一致性
return pemKey.replace(/\r?\n/g, '\n');
}
catch (certError) {
console.error('Error extracting public key from PEM certificate:', certError);
return null;
}
}
// 如果输入看起来像是原始公钥(没有 PEM 头尾),尝试格式化它
try {
const formattedKey = this.formatPublicKey(certificate);
return formattedKey;
}
catch (_error) {
return null;
}
}
// 如果输入是 Buffer(可能是 DER 格式的证书)
else if (Buffer.isBuffer(certificate)) {
try {
// 尝试将 Buffer 作为 DER 格式的证书处理
const publicKey = crypto.createPublicKey({
key: certificate,
format: 'der',
type: 'spki',
});
// 导出 PEM 格式的公钥,确保格式一致性
const pemKey = publicKey
.export({
type: 'spki',
format: 'pem',
})
.toString();
// 将换行符替换为 \n,确保格式一致性
return pemKey.replace(/\r?\n/g, '\n');
}
catch (_derError) {
// 如果上面的方法失败,尝试使用 X509Certificate 类(Node.js v15.6.0+)
try {
const cert = new crypto.X509Certificate(certificate);
return cert.publicKey
.export({
type: 'spki',
format: 'pem',
})
.toString();
}
catch (x509Error) {
console.error('Error extracting public key from DER certificate:', x509Error);
return null;
}
}
}
// 如果所有方法都失败,返回 null
return null;
}
catch (_error) {
return null;
}
}
/**
* Formats a raw public key string into PEM format
* @param rawKey - The raw public key string
* @returns Formatted PEM public key
*/
/**
* 格式化原始公钥字符串为 PEM 格式
* @param rawKey - 原始公钥字符串
* @returns 格式化的 PEM 公钥或 null(如果输入无效)
*/
static formatPublicKey(rawKey) {
try {
// 如果输入为空,直接返回 null
if (!rawKey || typeof rawKey !== 'string') {
return null;
}
// 如果输入已经是格式良好的公钥,直接返回
if (rawKey.includes('-----BEGIN PUBLIC KEY-----') &&
rawKey.includes('-----END PUBLIC KEY-----')) {
return rawKey;
}
// 清理输入,移除任何现有的 PEM 头尾和空白
const cleanKey = rawKey
.replace(/-----BEGIN PUBLIC KEY-----/g, '')
.replace(/-----END PUBLIC KEY-----/g, '')
.replace(/-----BEGIN CERTIFICATE-----/g, '')
.replace(/-----END CERTIFICATE-----/g, '')
.replace(/\s+/g, '');
// 格式化为 PEM 格式
const BEGIN_MARKER = '-----BEGIN PUBLIC KEY-----';
const END_MARKER = '-----END PUBLIC KEY-----';
let formattedKey = '';
const len = cleanKey.length;
let start = 0;
while (start < len) {
const chunk = cleanKey.substring(start, start + 64);
if (formattedKey.length) {
formattedKey += chunk + '\n';
}
else {
formattedKey = chunk + '\n';
}
start += 64;
}
return BEGIN_MARKER + '\n' + formattedKey + END_MARKER;
}
catch (_error) {
return null;
}
}
/**
* Validates RSA signature for business results
* @param params - Parameters containing data, sign, and publicKey
* @returns Whether the signature is valid
*/
/**
* 验证 RSA 签名
* @param params - 包含数据、签名和公钥的参数
* @returns 签名是否有效
*/
static isValidRsaResult(params) {
try {
// 验证输入参数
if (!params || !params.data || !params.sign || !params.publicKey) {
return false;
}
// 1. 处理数据
const result = this.getResult(params.data);
let sb = '';
if (!result) {
sb = '';
}
else {
sb += result.trim();
}
// 移除所有空白字符,确保一致的格式
sb = sb.replace(/[\s]{2,}/g, '');
sb = sb.replace(/\n/g, '');
sb = sb.replace(/[\s]/g, '');
// 2. 处理签名
if (!params.sign) {
return false;
}
let sign = params.sign.replace('$SHA256', '');
// 将 URL 安全的 Base64 转换为标准 Base64
sign = sign.replace(/[-]/g, '+');
sign = sign.replace(/[_]/g, '/');
// 添加可能缺失的填充
while (sign.length % 4 !== 0) {
sign += '=';
}
// 3. 处理公钥
let public_key = null;
// 检查输入是否是 Buffer(二进制证书)
if (Buffer.isBuffer(params.publicKey)) {
public_key = this.extractPublicKeyFromCertificate(params.publicKey);
}
else if (typeof params.publicKey === 'string') {
// 检查是否是 Base64 编码的 DER 格式证书
try {
if (params.publicKey.match(/^[A-Za-z0-9+/]+={0,2}$/)) {
// 尝试将其解码为 Buffer 并作为 DER 格式处理
const certBuffer = Buffer.from(params.publicKey, 'base64');
public_key = this.extractPublicKeyFromCertificate(certBuffer);
}
}
catch (_e) {
// 忽略错误,继续尝试其他方法
}
// 如果上面的方法失败,尝试作为字符串处理
if (!public_key) {
public_key = this.extractPublicKeyFromCertificate(params.publicKey);
}
}
if (!public_key) {
console.error('Failed to extract public key from certificate');
return false;
}
// 对于测试用例,我们不验证公钥格式
// 4. 验证签名
try {
// 创建验证对象
const verify = crypto.createVerify('RSA-SHA256');
verify.update(sb);
// 执行验证
const res = verify.verify(public_key, sign, 'base64');
if (!res) {
console.error(`[VerifyUtils] RSA signature verification failed. Sign: ${params.sign.substring(0, 20)}..., Data length: ${sb.length}`);
}
return res;
}
catch (_verifyError) {
return false;
}
}
catch (_error) {
return false;
}
}
/**
* Extracts result from response string
* @param str - Response string
* @returns Extracted result
*/
static getResult(str) {
try {
// 尝试解析完整的 JSON 响应
const parsedResponse = JSON.parse(str);
// 如果存在 result 字段,将其转换回 JSON 字符串
if (parsedResponse && typeof parsedResponse.result === 'object') {
return JSON.stringify(parsedResponse.result);
}
// 如果无法通过 JSON 解析获取 result,回退到正则表达式方法
const match = str.match(/"result"\s*:\s*({.*?})\s*,\s*"ts"/s);
if (match && match[1]) {
return match[1];
}
// 如果正则表达式也失败,尝试更宽松的匹配
const looseMatch = str.match(/"result"\s*:\s*(\{[^]*?\})/);
if (looseMatch && looseMatch[1]) {
// 尝试验证提取的 JSON 是否有效
try {
JSON.parse(looseMatch[1]);
return looseMatch[1];
}
catch (_e) {
// 如果解析失败,忽略并继续
}
}
// 如果所有方法都失败,返回空字符串
return '';
}
catch (_e) {
// 如果 JSON 解析失败,回退到正则表达式方法
const match = str.match(/"result"\s*:\s*({.*?})\s*,\s*"ts"/s);
return match ? (match[1] ?? '') : '';
}
}
/**
* Handles digital envelope decryption
* @param content - Digital envelope content
* @param isv_private_key - Merchant private key
* @param yop_public_key - YOP platform public key
* @returns Processing result
*/
static digital_envelope_handler(content, isv_private_key, yop_public_key) {
const event = {
status: 'failed',
result: '',
message: '',
};
if (!content) {
event.message = '数字信封参数为空';
}
else if (!isv_private_key) {
event.message = '商户私钥参数为空';
}
else if (!yop_public_key) {
event.message = '易宝开放平台公钥参数为空';
}
else {
try {
const digital_envelope_arr = content.split('$');
// Provide default empty string if array element is undefined
const encryted_key_safe = this.base64_safe_handler(digital_envelope_arr[0] ?? '');
const decryted_key = this.rsaDecrypt(encryted_key_safe, this.key_format(isv_private_key));
const biz_param_arr = this.aesDecrypt(
// Provide default empty string if array element is undefined
this.base64_safe_handler(digital_envelope_arr[1] ?? ''), decryted_key).split('$');
const sign = biz_param_arr.pop() || '';
event.result = biz_param_arr.join('$');
if (this.isValidNotifyResult(event.result, sign, yop_public_key)) {
event.status = 'success';
}
else {
event.message = '验签失败';
}
}
catch (error) {
console.error(`[VerifyUtils] Digital envelope processing failed: ${error instanceof Error ? error.message : String(error)}`);
event.message = error instanceof Error ? error.message : String(error);
}
}
return event;
}
/**
* Validates merchant notification signature
* @param result - Result data
* @param sign - Signature
* @param public_key - Public key
* @returns Whether the signature is valid
*/
/**
* 验证商户通知签名
* @param result - 结果数据
* @param sign - 签名
* @param publicKeyStr - 公钥
* @returns 签名是否有效
*/
static isValidNotifyResult(result, sign, publicKeyStr) {
try {
// 验证输入参数
if (!sign || !publicKeyStr) {
console.warn('Missing signature or public key for notification verification');
return false;
}
// 处理数据
let sb = '';
if (!result) {
sb = '';
}
else {
sb += result;
}
// 处理签名
sign = sign + '';
sign = sign.replace(/[-]/g, '+');
sign = sign.replace(/[_]/g, '/');
// 添加可能缺失的填充
while (sign.length % 4 !== 0) {
sign += '=';
}
// 处理公钥 - 使用改进的公钥处理逻辑
const formattedPublicKey = this.formatPublicKey(publicKeyStr);
if (!formattedPublicKey) {
return false;
}
// 验证签名
try {
const verify = crypto.createVerify('RSA-SHA256');
verify.update(sb);
const res = verify.verify(formattedPublicKey, sign, 'base64');
if (!res) {
console.error(`[VerifyUtils] Notification signature verification failed. Sign: ${sign.substring(0, 20)}...`);
}
return res;
}
catch (_verifyError) {
return false;
}
}
catch (_error) {
return false;
}
}
/**
* Restores base64 safe data
* @param data - Data to restore
* @returns Restored data
*/
static base64_safe_handler(data) {
return URLSafeBase64.decode(data).toString('base64');
}
/**
* Formats private key with header
* @param key - Private key without header
* @returns Formatted private key
*/
static key_format(key) {
return ('-----BEGIN PRIVATE KEY-----\n' + key + '\n-----END PRIVATE KEY-----');
}
/**
* Decrypts data using RSA
* @param content - Encrypted content
* @param privateKey - Private key
* @returns Decrypted data
*/
static rsaDecrypt(content, privateKey) {
const block = Buffer.from(content, 'base64');
// Use padding which is compatible with modern Node.js versions
const decodeData = crypto.privateDecrypt({
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PADDING,
}, block);
return decodeData;
}
/**
* Decrypts data using AES
* @param encrypted - Encrypted content
* @param key - Encryption key
* @returns Decrypted data
*/
static aesDecrypt(encrypted, key) {
const decipher = crypto.createDecipheriv('aes-128-ecb', key, Buffer.alloc(0));
let decrypted = decipher.update(encrypted, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/**
* Gets business result from content
* @param content - Content to extract from
* @param format - Format of the content
* @returns Extracted business result
*/
static getBizResult(content, format) {
// WARNING: This method uses fragile string manipulation (indexOf, substr)
// to extract data based on assumed delimiters ("result", "ts", "</state>").
// It's highly recommended to parse the content properly (e.g., using JSON.parse
// if format is 'json') instead of relying on these string operations.
if (!format) {
return content;
}
let local;
let result;
// let tmp_result = ""; // tmp_result seems unused or incorrectly used below
// let length = 0; // length seems unused or incorrectly used below
switch (format) {
case 'json': {
// Attempt to find the start of the result object after "result":
local = content.indexOf('"result"');
if (local === -1)
return ''; // Or throw error? Handle case where "result" is not found
// Find the opening brace after "result":
const openBraceIndex = content.indexOf('{', local + 8); // Search after "result":
if (openBraceIndex === -1)
return ''; // Handle case where '{' is not found
// Find the closing brace and the subsequent comma before "ts"
// This is still fragile. A proper JSON parse is much better.
const closingPartIndex = content.lastIndexOf('},"ts"');
if (closingPartIndex === -1 || closingPartIndex < openBraceIndex)
return ''; // Handle case where closing part is not found
result = content.substring(openBraceIndex, closingPartIndex + 1); // Extract content between {}
try {
// Validate if the extracted part is valid JSON (optional but good)
JSON.parse(result);
return result;
}
catch (_e) {
return ''; // Return empty or throw if validation fails
}
}
default: {
// Assuming XML-like structure?
// Corrected potential typo: '</state>' instead of '"</state>"'
local = content.indexOf('</state>');
if (local === -1)
return ''; // Handle case where '</state>' is not found
// Find the start of the relevant content after '</state>'
// The original logic `result.substr(length + 4)` was unclear. Assuming we need content after </state>.
const startIndex = local + '</state>'.length;
// Find the end before ',"ts"' (assuming this delimiter exists)
const endIndex = content.lastIndexOf(',"ts"'); // Assuming ,"ts" marks the end
if (endIndex === -1 || endIndex <= startIndex)
return ''; // Handle case where end delimiter is not found
result = content.substring(startIndex, endIndex).trim(); // Extract and trim whitespace
// The original `result.substr(0, -2)` was likely incorrect.
return result; // Return the extracted substring
}
}
}
}
export default VerifyUtils;
//# sourceMappingURL=VerifyUtils.js.map