234 lines
11 KiB
TypeScript
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 字符')
|
|
})
|
|
})
|