Files
WebTools/tests/signatureVerification.test.ts
T

234 lines
11 KiB
TypeScript

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 字符')
})
})