import { constants, createPublicKey, generateKeyPairSync, sign, type KeyObject, } from 'node:crypto' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { encodeBase64, formatBytes } from '../src/lib/bytes' import { parseSignatureFile } from '../src/lib/signatureVerificationFile' import { decodeSignature, defaultPssSaltLength, hashLength, maximumPssSaltLength, parseRsaPublicKeyParameters, parseRsaPublicKeyPem, verifyRsaSignature, type ParsedRsaPublicKey, type SignatureHashAlgorithm, } from '../src/lib/signatureVerification' let privateKey: KeyObject let publicKey: KeyObject let spkiPem = '' let pkcs1Pem = '' let certificatePem = '' let parsedSpki: ParsedRsaPublicKey const message = new TextEncoder().encode('Web Tools RSA signature verification') function nodeHash(hash: SignatureHashAlgorithm): string { return hash.toLowerCase().replace('-', '') } function createSignature( data: Uint8Array, hash: SignatureHashAlgorithm, scheme: 'pkcs1-v1_5' | 'pss', saltLength = hashLength(hash), ): Uint8Array { return sign(nodeHash(hash), data, { key: privateKey, padding: scheme === 'pss' ? constants.RSA_PKCS1_PSS_PADDING : constants.RSA_PKCS1_PADDING, ...(scheme === 'pss' ? { saltLength } : {}), }) } beforeAll(async () => { const generated = generateKeyPairSync('rsa', { modulusLength: 2048, publicExponent: 0x10001, }) privateKey = generated.privateKey publicKey = generated.publicKey spkiPem = publicKey.export({ type: 'spki', format: 'pem' }).toString() pkcs1Pem = publicKey.export({ type: 'pkcs1', format: 'pem' }).toString() parsedSpki = await parseRsaPublicKeyPem(spkiPem) const { default: forge } = await import('node-forge') const forgePrivateKey = forge.pki.privateKeyFromPem(privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()) const forgePublicKey = forge.pki.publicKeyFromPem(spkiPem) const certificate = forge.pki.createCertificate() certificate.publicKey = forgePublicKey certificate.serialNumber = '01AB' certificate.validity.notBefore = new Date('2025-01-01T00:00:00.000Z') certificate.validity.notAfter = new Date('2030-01-01T00:00:00.000Z') certificate.setSubject([{ name: 'commonName', value: 'Web Tools Test' }]) certificate.setIssuer([{ name: 'commonName', value: 'Web Tools Test' }]) certificate.sign(forgePrivateKey, forge.md.sha256.create()) certificatePem = forge.pki.certificateToPem(certificate) }, 20_000) afterAll(() => { vi.unstubAllGlobals() }) describe('RSA public-key parsing', () => { it('parses SPKI and PKCS#1 PEM with a stable SPKI fingerprint', async () => { const [spki, pkcs1] = await Promise.all([ parseRsaPublicKeyPem(spkiPem), parseRsaPublicKeyPem(pkcs1Pem), ]) expect(spki.source).toBe('SPKI') expect(pkcs1.source).toBe('PKCS#1') expect(spki.modulusBits).toBe(2048) expect(spki.exponentHex).toBe('010001') expect(pkcs1.fingerprintSha256).toBe(spki.fingerprintSha256) expect(createPublicKey({ key: Buffer.from(spki.spkiDer), type: 'spki', format: 'der' }).asymmetricKeyType).toBe('rsa') }) it('extracts an RSA key and display-only metadata from one X.509 certificate', async () => { const parsed = await parseRsaPublicKeyPem(certificatePem) expect(parsed.source).toBe('X.509 certificate') expect(parsed.fingerprintSha256).toBe(parsedSpki.fingerprintSha256) expect(parsed.certificate).toMatchObject({ subject: 'CN=Web Tools Test', serialNumber: '01AB', notBefore: '2025-01-01T00:00:00.000Z', notAfter: '2030-01-01T00:00:00.000Z', }) }) it('builds the same public key from N/E Hex parameters', async () => { const jwk = publicKey.export({ format: 'jwk' }) if (!jwk.n || !jwk.e) throw new Error('Node did not export RSA JWK parameters') const fromParameters = await parseRsaPublicKeyParameters( Buffer.from(jwk.n, 'base64url').toString('hex'), Buffer.from(jwk.e, 'base64url').toString('hex'), ) expect(fromParameters.source).toBe('N/E parameters') expect(fromParameters.fingerprintSha256).toBe(parsedSpki.fingerprintSha256) }) it('accepts an odd-length Hex public exponent and pads it on the left', async () => { const fromOddLengthExponent = await parseRsaPublicKeyParameters(parsedSpki.modulusHex, '10001') const fromEvenLengthExponent = await parseRsaPublicKeyParameters(parsedSpki.modulusHex, '010001') const fromPrefixedExponent = await parseRsaPublicKeyParameters(parsedSpki.modulusHex, '0x10001') const fromLeadingZeros = await parseRsaPublicKeyParameters(parsedSpki.modulusHex, '00010001') expect(fromOddLengthExponent.exponentHex).toBe('010001') expect(fromOddLengthExponent.exponentDecimal).toBe('65537') expect(fromOddLengthExponent.fingerprintSha256).toBe(parsedSpki.fingerprintSha256) expect(fromEvenLengthExponent.fingerprintSha256).toBe(parsedSpki.fingerprintSha256) expect(fromPrefixedExponent.fingerprintSha256).toBe(parsedSpki.fingerprintSha256) expect(fromLeadingZeros.fingerprintSha256).toBe(parsedSpki.fingerprintSha256) }) it('rejects ambiguous, private, malformed and out-of-policy key input', async () => { await expect(parseRsaPublicKeyPem(`${spkiPem}${pkcs1Pem}`)).rejects.toThrow('一个完整的 PEM 块') await expect(parseRsaPublicKeyPem(privateKey.export({ type: 'pkcs8', format: 'pem' }).toString())).rejects.toThrow('仅支持') await expect(parseRsaPublicKeyPem('-----BEGIN PUBLIC KEY-----\nAA==\n-----END PUBLIC KEY-----')).rejects.toThrow('格式无效') await expect(parseRsaPublicKeyParameters('AA', '02')).rejects.toThrow('RSA 模数必须') await expect(parseRsaPublicKeyParameters(`${parsedSpki.modulusHex.slice(0, -1)}2`, '010001')).rejects.toThrow('模数 N 必须是奇数') }) }) describe('RSA signature verification', () => { for (const hash of ['SHA-256', 'SHA-384', 'SHA-512'] as const) { it(`verifies PKCS#1 v1.5 with ${hash} and rejects changed data`, async () => { const signature = createSignature(message, hash, 'pkcs1-v1_5') const valid = await verifyRsaSignature(parsedSpki, message, signature, { scheme: 'pkcs1-v1_5', hash }) const invalid = await verifyRsaSignature(parsedSpki, new Uint8Array([...message, 0]), signature, { scheme: 'pkcs1-v1_5', hash }) expect(valid.valid).toBe(true) expect(invalid.valid).toBe(false) expect(valid.dataHash).toHaveLength(hashLength(hash) * 2) }) } for (const hash of ['SHA-256', 'SHA-384', 'SHA-512'] as const) { it(`verifies RSA-PSS with ${hash} and an explicit salt length`, async () => { const saltLength = hashLength(hash) const signature = createSignature(message, hash, 'pss', saltLength) const valid = await verifyRsaSignature(parsedSpki, message, signature, { scheme: 'pss', hash, saltLength }) const wrongSalt = await verifyRsaSignature(parsedSpki, message, signature, { scheme: 'pss', hash, saltLength: Math.max(0, saltLength - 1) }) expect(valid.valid).toBe(true) expect(wrongSalt.valid).toBe(false) }) } it('supports an empty message and reports a corrupted signature as invalid', async () => { const empty = new Uint8Array() const signature = createSignature(empty, 'SHA-256', 'pkcs1-v1_5') const corrupted = Uint8Array.from(signature) corrupted[corrupted.length - 1] ^= 1 await expect(verifyRsaSignature(parsedSpki, empty, signature, { scheme: 'pkcs1-v1_5', hash: 'SHA-256' })) .resolves.toMatchObject({ valid: true, dataBytes: 0 }) await expect(verifyRsaSignature(parsedSpki, empty, corrupted, { scheme: 'pkcs1-v1_5', hash: 'SHA-256' })) .resolves.toMatchObject({ valid: false }) }) it('verifies the exact Intel HEX block-header byte stream', async () => { const intelHex = ':020000040001F9\n:040000001122334452\n:02001000AABB89\n:00000001FF\n' const bytes = new TextEncoder().encode(intelHex) const file = { name: 'firmware.hex', size: bytes.length, text: async () => intelHex, arrayBuffer: async () => bytes.slice().buffer, } as File const parsed = await parseSignatureFile(file, 'intel-hex-block-header', 'big-endian') const signature = createSignature(parsed.data, 'SHA-256', 'pkcs1-v1_5') await expect(verifyRsaSignature(parsedSpki, parsed.data, signature, { scheme: 'pkcs1-v1_5', hash: 'SHA-256' })) .resolves.toMatchObject({ valid: true, dataBytes: 22 }) }) it('validates signature length and the RSA-PSS salt boundary before verification', async () => { const maximum = maximumPssSaltLength(parsedSpki.modulusBits, 'SHA-512') await expect(verifyRsaSignature(parsedSpki, message, new Uint8Array(255), { scheme: 'pkcs1-v1_5', hash: 'SHA-256' })) .rejects.toThrow('签名长度必须为 256 字节') await expect(verifyRsaSignature(parsedSpki, message, new Uint8Array(256), { scheme: 'pss', hash: 'SHA-512', saltLength: maximum + 1 })) .rejects.toThrow(`0 至 ${maximum}`) }) it('keeps the default RSA-PSS salt valid for a 1024-bit key with SHA-512', () => { expect(defaultPssSaltLength(1024, 'SHA-512')).toBe(62) expect(defaultPssSaltLength(2048, 'SHA-512')).toBe(64) }) it('verifies RSA-PSS SHA-512 with the maximum valid salt on a 1024-bit key', async () => { const generated = generateKeyPairSync('rsa', { modulusLength: 1024, publicExponent: 0x10001 }) const shortKey = await parseRsaPublicKeyPem(generated.publicKey.export({ type: 'spki', format: 'pem' }).toString()) const saltLength = defaultPssSaltLength(shortKey.modulusBits, 'SHA-512') const signature = sign('sha512', message, { key: generated.privateKey, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength, }) await expect(verifyRsaSignature(shortKey, message, signature, { scheme: 'pss', hash: 'SHA-512', saltLength })) .resolves.toMatchObject({ valid: true }) }) it('uses node-forge when SubtleCrypto is unavailable', async () => { const getRandomValues = globalThis.crypto.getRandomValues.bind(globalThis.crypto) const signature = createSignature(message, 'SHA-256', 'pss', 32) vi.stubGlobal('crypto', { getRandomValues }) const verification = await verifyRsaSignature(parsedSpki, message, signature, { scheme: 'pss', hash: 'SHA-256', saltLength: 32 }) expect(verification).toMatchObject({ valid: true, provider: 'node-forge' }) vi.unstubAllGlobals() }) }) describe('signature decoding', () => { it('decodes strict Hex, Base64 and Base64 URL representations', () => { const bytes = Uint8Array.from([0xFB, 0xEF, 0xFF]) expect(decodeSignature(formatBytes(bytes), 'hex')).toEqual(bytes) expect(decodeSignature(encodeBase64(bytes), 'base64')).toEqual(bytes) expect(decodeSignature(encodeBase64(bytes, true), 'base64url')).toEqual(bytes) expect(() => decodeSignature('--__', 'base64')).toThrow('Base64 URL 字符') expect(() => decodeSignature('++//', 'base64url')).toThrow('标准 Base64 字符') }) })