UNPKG

@yeepay/yop-typescript-sdk

Version:

TypeScript SDK for interacting with YOP (YeePay Open Platform)

490 lines 20.1 kB
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