diff --git a/src/core/rsa-digestinfo-workaround.ts b/src/core/rsa-digestinfo-workaround.ts new file mode 100644 index 0000000..b2e4206 --- /dev/null +++ b/src/core/rsa-digestinfo-workaround.ts @@ -0,0 +1,304 @@ +/** + * RSA DigestInfo Workaround + * + * Some older signing tools (particularly pre-Java 8) produced RSA signatures with + * non-standard DigestInfo format - missing the NULL parameter in AlgorithmIdentifier. + * + * Standard DigestInfo for SHA-1: 30 21 30 09 06 05 2b0e03021a 05 00 04 14 [hash] + * Non-standard (missing NULL): 30 1f 30 07 06 05 2b0e03021a 04 14 [hash] + * + * Web Crypto API's subtle.verify() is strict and rejects the non-standard format. + * This module provides a fallback that manually performs RSA verification using + * BigInt math, which works in both browser and Node.js environments. + */ + +/** + * Parse RSA public key from SPKI format to extract modulus and exponent + */ +function parseRSAPublicKey(spkiData: ArrayBuffer): { n: bigint; e: bigint } | null { + const bytes = new Uint8Array(spkiData); + + // SPKI structure: + // SEQUENCE { + // SEQUENCE { algorithm OID, parameters (NULL or absent) } + // BIT STRING { RSAPublicKey } + // } + // RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER } + + let pos = 0; + + // Helper to read ASN.1 length + const readLength = (): number => { + const first = bytes[pos++]; + if ((first & 0x80) === 0) { + return first; + } + const numBytes = first & 0x7f; + let length = 0; + for (let i = 0; i < numBytes; i++) { + length = (length << 8) | bytes[pos++]; + } + return length; + }; + + // Helper to read INTEGER as BigInt + const readInteger = (): bigint => { + if (bytes[pos++] !== 0x02) return BigInt(0); // INTEGER tag + const len = readLength(); + let value = BigInt(0); + for (let i = 0; i < len; i++) { + value = (value << BigInt(8)) | BigInt(bytes[pos++]); + } + return value; + }; + + try { + // Outer SEQUENCE + if (bytes[pos++] !== 0x30) return null; + readLength(); + + // AlgorithmIdentifier SEQUENCE + if (bytes[pos++] !== 0x30) return null; + const algoLen = readLength(); + pos += algoLen; // Skip algorithm identifier + + // BIT STRING containing RSAPublicKey + if (bytes[pos++] !== 0x03) return null; + readLength(); + pos++; // Skip unused bits byte + + // RSAPublicKey SEQUENCE + if (bytes[pos++] !== 0x30) return null; + readLength(); + + // Read modulus and exponent + const n = readInteger(); + const e = readInteger(); + + return { n, e }; + } catch { + return null; + } +} + +/** + * Perform modular exponentiation: base^exp mod mod + * Uses square-and-multiply algorithm for efficiency + */ +function modPow(base: bigint, exp: bigint, mod: bigint): bigint { + let result = BigInt(1); + base = base % mod; + while (exp > 0) { + if (exp % BigInt(2) === BigInt(1)) { + result = (result * base) % mod; + } + exp = exp >> BigInt(1); + base = (base * base) % mod; + } + return result; +} + +/** + * Convert Uint8Array to BigInt + */ +function bytesToBigInt(bytes: Uint8Array): bigint { + let result = BigInt(0); + for (const byte of bytes) { + result = (result << BigInt(8)) | BigInt(byte); + } + return result; +} + +/** + * Convert BigInt to Uint8Array with specified length + */ +function bigIntToBytes(value: bigint, length: number): Uint8Array { + const result = new Uint8Array(length); + for (let i = length - 1; i >= 0; i--) { + result[i] = Number(value & BigInt(0xff)); + value = value >> BigInt(8); + } + return result; +} + +/** + * Verify PKCS#1 v1.5 signature padding and extract DigestInfo + * @param decrypted The decrypted signature block + * @returns The DigestInfo bytes, or null if padding is invalid + */ +function extractDigestInfoFromPKCS1(decrypted: Uint8Array): Uint8Array | null { + // PKCS#1 v1.5 signature format: + // 0x00 0x01 [0xFF padding] 0x00 [DigestInfo] + if (decrypted[0] !== 0x00 || decrypted[1] !== 0x01) { + return null; + } + + // Find the 0x00 separator after padding + let separatorIndex = -1; + for (let i = 2; i < decrypted.length; i++) { + if (decrypted[i] === 0x00) { + separatorIndex = i; + break; + } + if (decrypted[i] !== 0xff) { + return null; // Invalid padding byte + } + } + + if (separatorIndex === -1 || separatorIndex < 10) { + return null; // No separator found or padding too short + } + + return decrypted.slice(separatorIndex + 1); +} + +/** + * Extract hash from DigestInfo structure + * Handles both standard (with NULL) and non-standard (without NULL) formats + */ +function extractHashFromDigestInfo( + digestInfo: Uint8Array, + expectedHashLength: number, +): Uint8Array | null { + // DigestInfo ::= SEQUENCE { digestAlgorithm AlgorithmIdentifier, digest OCTET STRING } + // Look for OCTET STRING tag (0x04) followed by the hash + for (let i = 0; i < digestInfo.length - 1; i++) { + if (digestInfo[i] === 0x04) { + const len = digestInfo[i + 1]; + if (len === expectedHashLength && i + 2 + len <= digestInfo.length) { + return digestInfo.slice(i + 2, i + 2 + len); + } + } + } + return null; +} + +/** + * Get hash length in bytes for a given algorithm + */ +function getHashLength(hashAlgorithm: string): number { + const algo = hashAlgorithm.toLowerCase().replace("-", ""); + switch (algo) { + case "sha1": + return 20; + case "sha256": + return 32; + case "sha384": + return 48; + case "sha512": + return 64; + default: + return 32; + } +} + +/** + * Detects if code is running in a browser environment + */ +function isBrowser(): boolean { + return ( + typeof window !== "undefined" && + typeof window.crypto !== "undefined" && + typeof window.crypto.subtle !== "undefined" + ); +} + +/** + * Verify RSA signature with non-standard DigestInfo format. + * + * This function performs RSA signature verification that tolerates + * non-standard DigestInfo formats (missing NULL in AlgorithmIdentifier). + * + * - Node.js: Uses native crypto.publicDecrypt() for speed + * - Browser: Uses BigInt math (Web Crypto doesn't expose raw RSA) + * + * @param publicKeyData SPKI-formatted public key + * @param signatureBytes Raw signature bytes + * @param dataToVerify The data that was signed + * @param hashAlgorithm Hash algorithm name (e.g., "SHA-1", "SHA-256") + * @returns true if signature is valid, false otherwise + */ +export async function verifyRsaWithNonStandardDigestInfo( + publicKeyData: ArrayBuffer, + signatureBytes: Uint8Array, + dataToVerify: Uint8Array, + hashAlgorithm: string, +): Promise { + try { + let digestInfo: Uint8Array | null; + + if (isBrowser()) { + // Browser: Use BigInt math (Web Crypto doesn't expose raw RSA decryption) + const keyParams = parseRSAPublicKey(publicKeyData); + if (!keyParams) { + return false; + } + + const { n, e } = keyParams; + const keyLength = Math.ceil(n.toString(16).length / 2); + + const signatureInt = bytesToBigInt(signatureBytes); + const decryptedInt = modPow(signatureInt, e, n); + const decrypted = bigIntToBytes(decryptedInt, keyLength); + + digestInfo = extractDigestInfoFromPKCS1(decrypted); + } else { + // Node.js: Use native crypto.publicDecrypt() for speed + // eslint-disable-next-line @typescript-eslint/no-var-requires + const nodeCrypto = require("crypto"); + + const publicKey = nodeCrypto.createPublicKey({ + key: Buffer.from(publicKeyData), + format: "der", + type: "spki", + }); + + const decrypted = nodeCrypto.publicDecrypt( + { key: publicKey, padding: nodeCrypto.constants.RSA_PKCS1_PADDING }, + Buffer.from(signatureBytes), + ); + + // Node's publicDecrypt already strips PKCS#1 padding, returns DigestInfo directly + digestInfo = new Uint8Array(decrypted); + } + + if (!digestInfo) { + return false; + } + + // Extract hash from DigestInfo (tolerates missing NULL) + const hashLength = getHashLength(hashAlgorithm); + const extractedHash = extractHashFromDigestInfo(digestInfo, hashLength); + if (!extractedHash) { + return false; + } + + // Compute expected hash + let expectedHash: Uint8Array; + if (isBrowser()) { + // Normalize to Web Crypto format: SHA-1, SHA-256, SHA-384, SHA-512 + let hashName = hashAlgorithm.toUpperCase().replace(/-/g, ""); + hashName = hashName.replace(/^SHA(\d)/, "SHA-$1"); + const hashBuffer = await window.crypto.subtle.digest(hashName, dataToVerify); + expectedHash = new Uint8Array(hashBuffer); + } else { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const nodeCrypto = require("crypto"); + const hashName = hashAlgorithm.toLowerCase().replace("-", ""); + expectedHash = nodeCrypto.createHash(hashName).update(Buffer.from(dataToVerify)).digest(); + } + + // Compare hashes (constant-time comparison) + if (extractedHash.length !== expectedHash.length) { + return false; + } + let diff = 0; + for (let i = 0; i < extractedHash.length; i++) { + diff |= extractedHash[i] ^ expectedHash[i]; + } + + return diff === 0; + } catch { + return false; + } +} diff --git a/src/core/verification.ts b/src/core/verification.ts index 9308b20..4182b45 100644 --- a/src/core/verification.ts +++ b/src/core/verification.ts @@ -10,6 +10,7 @@ import { RevocationResult, RevocationCheckOptions } from "./revocation/types"; import { verifyTimestamp, getTimestampTime } from "./timestamp/verify"; import { TimestampVerificationResult } from "./timestamp/types"; import { base64ToUint8Array } from "../utils/encoding"; +import { verifyRsaWithNonStandardDigestInfo } from "./rsa-digestinfo-workaround"; /** * Options for verification process @@ -744,11 +745,48 @@ export async function verifySignedInfo( const subtle = getCryptoSubtle(); const result = await subtle.verify(algorithm, publicKey, signatureBytes, signedData); + if (result) { + return { + isValid: true, + }; + } + + // Standard verification failed - try fallback for RSA signatures + // Some older signatures use non-standard DigestInfo format (missing NULL in AlgorithmIdentifier) + if (algorithm.name === "RSASSA-PKCS1-v1_5") { + const fallbackResult = await verifyRsaWithNonStandardDigestInfo( + publicKeyData, + signatureBytes, + signedData, + algorithm.hash, + ); + if (fallbackResult) { + return { + isValid: true, + }; + } + } + return { - isValid: result, - reason: result ? undefined : "Signature verification failed", + isValid: false, + reason: "Signature verification failed", }; } catch (error) { + // Try fallback for RSA signatures when subtle.verify throws + if (algorithm.name === "RSASSA-PKCS1-v1_5") { + const fallbackResult = await verifyRsaWithNonStandardDigestInfo( + publicKeyData, + signatureBytes, + signedData, + algorithm.hash, + ); + if (fallbackResult) { + return { + isValid: true, + }; + } + } + return { isValid: false, reason: `Signature verification error: ${error instanceof Error ? error.message : String(error)}`, @@ -1007,6 +1045,16 @@ export async function verifySignature( statusMessage = certResult.reason || "Certificate validation inconclusive"; } } + // Timestamp parsing/verification failed - can't establish POE + else if (timestampResult && !timestampResult.isValid) { + status = "INDETERMINATE"; + statusMessage = timestampResult.reason || "Timestamp verification failed"; + limitations.push({ + code: "TIMESTAMP_VERIFICATION_FAILED", + description: + timestampResult.reason || "Could not verify timestamp to establish proof of existence", + }); + } // Revocation unknown else if (certResult.revocation?.status === "unknown") { status = "INDETERMINATE"; diff --git a/tests-browser/rsa-digestinfo.spec.ts b/tests-browser/rsa-digestinfo.spec.ts new file mode 100644 index 0000000..9d45272 --- /dev/null +++ b/tests-browser/rsa-digestinfo.spec.ts @@ -0,0 +1,112 @@ +import { expect } from "@esm-bundle/chai"; +import { verifyRsaWithNonStandardDigestInfo } from "../src/core/rsa-digestinfo-workaround"; + +/** + * Synthetic test vectors for non-standard DigestInfo RSA verification. + * + * These vectors were generated with a custom script that creates an RSA signature + * using non-standard DigestInfo format (missing NULL in AlgorithmIdentifier). + * + * Standard DigestInfo: 30 21 30 09 06 05 2b0e03021a 05 00 04 14 [hash] + * Non-standard DigestInfo: 30 1f 30 07 06 05 2b0e03021a 04 14 [hash] + * + * This mimics old Java signing tools (pre-Java 8) that produced non-standard signatures. + */ +const testVectors = { + // RSA 2048-bit public key (SPKI format, base64) + publicKeyBase64: + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmFFN33TkjlQGqNXi4x4VTI5mEaWKXrjGle8PtZzWWyGB3t1Yg9/ZBspPtKrtHAOWO564997O0m62YA+2pWZg2FCMohIO6q2HApDZBSN7o3y9YpTl1erpBaPA2ni5GZJOvArcwkSIRMSbDwAVszqkAr0XlvvpuY+QhBU7hycBhyR2Be+xLcysHY3JlR14r/doxgh0isCquTA6EdM5Es08hymKMGWiRUssClY3IwYxr2O4RgMUJu+cFX1U7l+VUXOTn03t20rniP4aQJ+Ns11qiqK72aGYF5XOC4X2EWf1uDH9uubRIQx+dcIgRrW/mS8T9Ile8sak7bsYkLNIw3lHwwIDAQAB", + // Signature with non-standard DigestInfo (missing NULL in AlgorithmIdentifier) + signatureBase64: + "kHXyzYVLPZAp4/zxi3yMhl2e6/vaard926H0nXINtXKG7LwAwfPWzUu6ovv/g6BaH6bzUNJt9heQ1fZi6vCvygRScuf5InTzhQbvMV8jxWktJl0K3XDtO3D0DYM3ArncwxcR6C7rdOWMdD5IRNAyDddCTviQHerkTBUOHFrqaIdNCffHJMGmnn5oqfM/kdcMpogZsa8ySM6WoX8u3gm3wP13B5Ny+iV178G941NrKyf3sYkZPCksA6gAzIK8NWUwbIKG33+/LvHLwBJVSW2SCbkNC+NMqCPVAj5WumoDqtZea5sVqMrcjDzRsCcV286zSh3rAd1mY+7hzcNRJCzP2A==", + // Test data that was signed + testData: "Test data for RSA signature with non-standard DigestInfo format", + // Hash algorithm + hashAlgorithm: "SHA-1", +}; + +function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +function base64ToUint8Array(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +describe("RSA DigestInfo Workaround (Browser)", () => { + it("should verify signature with non-standard DigestInfo format", async () => { + const publicKeyData = base64ToArrayBuffer(testVectors.publicKeyBase64); + const signatureBytes = base64ToUint8Array(testVectors.signatureBase64); + const dataToVerify = new TextEncoder().encode(testVectors.testData); + + const result = await verifyRsaWithNonStandardDigestInfo( + publicKeyData, + signatureBytes, + dataToVerify, + testVectors.hashAlgorithm, + ); + + expect(result).to.equal(true); + }); + + it("should fail verification with tampered data", async () => { + const publicKeyData = base64ToArrayBuffer(testVectors.publicKeyBase64); + const signatureBytes = base64ToUint8Array(testVectors.signatureBase64); + const tamperedData = new TextEncoder().encode(testVectors.testData + "tampered"); + + const result = await verifyRsaWithNonStandardDigestInfo( + publicKeyData, + signatureBytes, + tamperedData, + testVectors.hashAlgorithm, + ); + + expect(result).to.equal(false); + }); + + it("should fail verification with wrong signature", async () => { + const publicKeyData = base64ToArrayBuffer(testVectors.publicKeyBase64); + // Corrupt the signature + const signatureBytes = base64ToUint8Array(testVectors.signatureBase64); + signatureBytes[0] ^= 0xff; + const dataToVerify = new TextEncoder().encode(testVectors.testData); + + const result = await verifyRsaWithNonStandardDigestInfo( + publicKeyData, + signatureBytes, + dataToVerify, + testVectors.hashAlgorithm, + ); + + expect(result).to.equal(false); + }); + + it("should handle different hash algorithm name formats", async () => { + const publicKeyData = base64ToArrayBuffer(testVectors.publicKeyBase64); + const signatureBytes = base64ToUint8Array(testVectors.signatureBase64); + const dataToVerify = new TextEncoder().encode(testVectors.testData); + + // Test with various formats of "SHA-1" + const formats = ["SHA-1", "sha1", "SHA1", "sha-1"]; + + for (const format of formats) { + const result = await verifyRsaWithNonStandardDigestInfo( + publicKeyData, + signatureBytes, + dataToVerify, + format, + ); + expect(result, `Failed with format: ${format}`).to.equal(true); + } + }); +}); diff --git a/tests-browser/validatesamples.spec.ts b/tests-browser/validatesamples.spec.ts index 6509a9b..136c680 100644 --- a/tests-browser/validatesamples.spec.ts +++ b/tests-browser/validatesamples.spec.ts @@ -22,8 +22,18 @@ describe("eDoc/ASiC-E Files Validation", () => { } }; + // File entry from filelist.json + interface FileEntry { + name: string; + expectedStatus?: string; + expectedLimitation?: string; + note?: string; + } + // Helper to get file list from a directory - const getFileList = async (baseDir: string): Promise => { + const getFileList = async ( + baseDir: string, + ): Promise<{ path: string; expectedStatus?: string; expectedLimitation?: string }[]> => { try { const response = await fetch(`${baseDir}/filelist.json`); if (!response.ok) { @@ -31,12 +41,27 @@ describe("eDoc/ASiC-E Files Validation", () => { return []; } - const files = await response.json(); + const data = await response.json(); + + // Handle new format: { "files": [{ "name": "...", "expectedStatus": "..." }, ...] } + let files: FileEntry[]; + if (data && Array.isArray(data.files)) { + files = data.files; + } else if (Array.isArray(data)) { + // Handle old format: ["file1.edoc", "file2.edoc", ...] + files = data.map((name: string) => ({ name })); + } else { + console.error(`Invalid filelist.json format in ${baseDir}`); + return []; + } + return files - .filter((filename: string) => - fileExtensions.some((ext) => filename.toLowerCase().endsWith(ext)), - ) - .map((filename: string) => `${baseDir}/${filename}`); + .filter((entry) => fileExtensions.some((ext) => entry.name.toLowerCase().endsWith(ext))) + .map((entry) => ({ + path: `${baseDir}/${entry.name}`, + expectedStatus: entry.expectedStatus, + expectedLimitation: entry.expectedLimitation, + })); } catch (error) { console.error(`Error reading file list from ${baseDir}:`, error); return []; @@ -180,37 +205,38 @@ describe("eDoc/ASiC-E Files Validation", () => { // Test suite for sensitive samples describe("Sensitive Sample Files Validation", function () { const sampleDir = "/tests/fixtures/sensitive/valid_samples"; - let files: string[] = []; + let fileEntries: { path: string; expectedStatus?: string; expectedLimitation?: string }[] = []; before(async function () { - files = await getFileList(sampleDir); - if (files.length === 0) { + fileEntries = await getFileList(sampleDir); + if (fileEntries.length === 0) { console.log(`No sample files found in ${sampleDir} - tests will be skipped`); this.skip(); } else { - console.log(`Found ${files.length} files to validate in ${sampleDir}`); + console.log(`Found ${fileEntries.length} files to validate in ${sampleDir}`); } }); it(`should access files in ${sampleDir}`, function () { - expect(files.length).to.be.greaterThan(0); + expect(fileEntries.length).to.be.greaterThan(0); }); it(`should validate all sensitive sample files`, async function () { // Skip if no files - if (files.length === 0) { + if (fileEntries.length === 0) { this.skip(); return; } // Test each file one by one - for (const path of files) { - const filename = path.split("/").pop() || ""; + for (const entry of fileEntries) { + const filename = entry.path.split("/").pop() || ""; + const expectedStatus = entry.expectedStatus || "VALID"; // Fetch and validate the file - const fileBuffer = await fetchSample(path); + const fileBuffer = await fetchSample(entry.path); if (!fileBuffer) { - console.log(`Could not fetch file: ${path} - skipping`); + console.log(`Could not fetch file: ${entry.path} - skipping`); continue; } @@ -219,18 +245,25 @@ describe("eDoc/ASiC-E Files Validation", () => { // Log results concisely const signersList = result.signerNames.length > 0 ? result.signerNames.join(", ") : "Unknown signer(s)"; - console.warn( - `📋 VALIDATION RESULT - ${filename}: ${result.isValid ? "✅ Valid" : "❌ Invalid"} - Signed at: ${result.signingTime} - By: ${signersList}`, + const statusIcon = result.isValid ? "✅" : expectedStatus === "INDETERMINATE" ? "âš ī¸" : "❌"; + console.log( + `📋 ${filename}: ${statusIcon} ${result.isValid ? "Valid" : "Not fully valid"} (expected: ${expectedStatus}) - By: ${signersList}`, ); - // Only log detailed errors if validation failed - if (!result.isValid) { + // Only log detailed errors if unexpected + if (!result.isValid && expectedStatus === "VALID") { console.error(` âš ī¸ Validation errors in ${filename}:`); result.validationErrors.forEach((error) => console.error(` - ${error}`)); } - // Assertion for each file - expect(result.isValid, `File ${filename} should be valid`).to.be.true; + // Assertion based on expected status + if (expectedStatus === "VALID") { + expect(result.isValid, `File ${filename} should be valid`).to.be.true; + } else if (expectedStatus === "INDETERMINATE") { + // INDETERMINATE files may pass or fail depending on timestamp/cert status + // Just log, don't fail the test + console.log(` â„šī¸ ${filename} is INDETERMINATE as expected`); + } } }); }); @@ -238,37 +271,37 @@ describe("eDoc/ASiC-E Files Validation", () => { // Test suite for public samples describe("Public Sample Files Validation", function () { const sampleDir = "/tests/fixtures/valid_samples"; - let files: string[] = []; + let fileEntries: { path: string; expectedStatus?: string; expectedLimitation?: string }[] = []; before(async function () { - files = await getFileList(sampleDir); - if (files.length === 0) { + fileEntries = await getFileList(sampleDir); + if (fileEntries.length === 0) { console.log(`No sample files found in ${sampleDir} - tests will be skipped`); this.skip(); } else { - console.log(`Found ${files.length} files to validate in ${sampleDir}`); + console.log(`Found ${fileEntries.length} files to validate in ${sampleDir}`); } }); it(`should access files in ${sampleDir}`, function () { - expect(files.length).to.be.greaterThan(0); + expect(fileEntries.length).to.be.greaterThan(0); }); it(`should validate all public sample files`, async function () { // Skip if no files - if (files.length === 0) { + if (fileEntries.length === 0) { this.skip(); return; } // Test each file one by one - for (const path of files) { - const filename = path.split("/").pop() || ""; + for (const entry of fileEntries) { + const filename = entry.path.split("/").pop() || ""; // Fetch and validate the file - const fileBuffer = await fetchSample(path); + const fileBuffer = await fetchSample(entry.path); if (!fileBuffer) { - console.log(`Could not fetch file: ${path} - skipping`); + console.log(`Could not fetch file: ${entry.path} - skipping`); continue; } @@ -277,7 +310,7 @@ describe("eDoc/ASiC-E Files Validation", () => { // Log results concisely const signersList = result.signerNames.length > 0 ? result.signerNames.join(", ") : "Unknown signer(s)"; - console.warn( + console.log( `📋 VALIDATION RESULT - ${filename}: ${result.isValid ? "✅ Valid" : "❌ Invalid"} - Signed at: ${result.signingTime} - By: ${signersList}`, ); diff --git a/tests/integration/batch_edoc.test.ts b/tests/integration/batch_edoc.test.ts index 814631e..f49a4c0 100644 --- a/tests/integration/batch_edoc.test.ts +++ b/tests/integration/batch_edoc.test.ts @@ -6,7 +6,7 @@ import { getSignerDisplayName, formatValidityPeriod, } from "../../src/core/certificate"; -import { verifyChecksums, verifySignature } from "../../src/core/verification"; +import { verifyChecksums, verifySignature, ValidationStatus } from "../../src/core/verification"; // Path to directories containing eDoc samples const sensitiveSamplesDir = join(__dirname, "../fixtures/sensitive/valid_samples"); @@ -14,6 +14,32 @@ const validSamplesDir = join(__dirname, "../fixtures/valid_samples"); const sensitiveSamplesDirExists = existsSync(sensitiveSamplesDir); const validSamplesDirExists = existsSync(validSamplesDir); +// File entry in filelist.json +interface FileEntry { + name: string; + expectedStatus: ValidationStatus; + expectedLimitation?: string; + note?: string; +} + +// Load filelist.json if it exists +const loadFilelist = (directory: string): FileEntry[] | null => { + const filelistPath = join(directory, "filelist.json"); + if (!existsSync(filelistPath)) return null; + + try { + const content = readFileSync(filelistPath, "utf-8"); + const parsed = JSON.parse(content); + // Support both old format (string[]) and new format ({ files: FileEntry[] }) + if (Array.isArray(parsed)) { + return parsed.map((name: string) => ({ name, expectedStatus: "VALID" as ValidationStatus })); + } + return parsed.files || null; + } catch { + return null; + } +}; + // Function to collect files with specific extensions const collectFiles = (directory: string, extensions: string[]): string[] => { if (!existsSync(directory)) return []; @@ -39,6 +65,8 @@ const verifyEdocFiles = async ( filePath: string, ): Promise<{ isValid: boolean; + status: ValidationStatus; + limitations?: { code: string }[]; signerNames: string[]; signingTime: string; validationErrors: string[]; @@ -49,6 +77,8 @@ const verifyEdocFiles = async ( // Track validation status let isFileValid = container.signatures.length > 0; + let overallStatus: ValidationStatus = "VALID"; + let allLimitations: { code: string }[] = []; let signerNames: string[] = []; let validationErrors: string[] = []; @@ -57,6 +87,7 @@ const verifyEdocFiles = async ( validationErrors.push("No signatures found in container"); return { isValid: false, + status: "INVALID", signerNames, signingTime: "Unknown time", validationErrors, @@ -120,9 +151,30 @@ const verifyEdocFiles = async ( checkRevocation: false, }); + // Track status from verification result + if (verificationResult.status !== "VALID") { + // Update overall status (worst case wins: INVALID > INDETERMINATE > UNSUPPORTED > VALID) + if ( + verificationResult.status === "INVALID" || + (verificationResult.status === "INDETERMINATE" && overallStatus !== "INVALID") || + (verificationResult.status === "UNSUPPORTED" && + overallStatus !== "INVALID" && + overallStatus !== "INDETERMINATE") + ) { + overallStatus = verificationResult.status; + } + } + + // Collect limitations + if (verificationResult.limitations) { + allLimitations.push(...verificationResult.limitations); + } + if (!verificationResult.isValid) { isFileValid = false; - validationErrors.push(`Signature #${i + 1}: Signature verification failed`); + validationErrors.push( + `Signature #${i + 1}: Signature verification failed (${verificationResult.status})`, + ); if (!verificationResult.certificate.isValid) { validationErrors.push(`Signature #${i + 1}: Certificate is invalid`); @@ -142,6 +194,7 @@ const verifyEdocFiles = async ( } } catch (error) { isFileValid = false; + overallStatus = "INVALID"; validationErrors.push( `Signature #${i + 1}: Error during signature verification: ${error instanceof Error ? error.message : String(error)}`, ); @@ -154,6 +207,8 @@ const verifyEdocFiles = async ( return { isValid: isFileValid, + status: overallStatus, + limitations: allLimitations.length > 0 ? allLimitations : undefined, signerNames, signingTime, validationErrors, @@ -165,6 +220,7 @@ const describeSensitive = sensitiveSamplesDirExists ? describe : describe.skip; describeSensitive("Sensitive Samples Batch Verification", () => { const sensitiveFiles = collectFiles(sensitiveSamplesDir, [".edoc", ".asice"]); + const filelist = loadFilelist(sensitiveSamplesDir); it("should find the sensitive samples directory", () => { expect(sensitiveSamplesDirExists).toBe(true); @@ -185,25 +241,42 @@ describeSensitive("Sensitive Samples Batch Verification", () => { sensitiveFiles.forEach((filePath) => { const filename = filePath.split("/").pop() || ""; - it(`should validate sensitive file: ${filename}`, async () => { + // Find expected status from filelist (default to VALID if not specified) + const fileEntry = filelist?.find((f) => f.name === filename); + const expectedStatus = fileEntry?.expectedStatus || "VALID"; + const expectedLimitation = fileEntry?.expectedLimitation; + + it(`should validate sensitive file: ${filename} (expected: ${expectedStatus})`, async () => { try { const result = await verifyEdocFiles(filePath); // Log results concisely const signersList = result.signerNames.length > 0 ? result.signerNames.join(", ") : "Unknown signer(s)"; + const statusIcon = + result.status === "VALID" ? "✓" : result.status === "INDETERMINATE" ? "?" : "✗"; console.log( - `${filename}: ${result.isValid ? "✓ Valid" : "✗ Invalid"} - Signed at: ${result.signingTime} - By: ${signersList}`, + `${filename}: ${statusIcon} ${result.status} - Signed at: ${result.signingTime} - By: ${signersList}`, ); - // Only log detailed errors if validation failed - if (!result.isValid) { + // Log limitations if present + if (result.limitations && result.limitations.length > 0) { + console.log(` Limitations: ${result.limitations.map((l) => l.code).join(", ")}`); + } + + // Only log detailed errors if unexpected status + if (result.status !== expectedStatus) { console.log(` Validation errors in ${filename}:`); result.validationErrors.forEach((error) => console.log(` - ${error}`)); } - // Final assertion - expect(result.isValid).toBe(true); + // Assert expected status + expect(result.status).toBe(expectedStatus); + + // Assert expected limitation if specified + if (expectedLimitation) { + expect(result.limitations?.some((l) => l.code === expectedLimitation)).toBe(true); + } } catch (error) { console.error( `Error processing ${filename}: ${error instanceof Error ? error.message : String(error)}`, diff --git a/tests/unit/core/rsa-digestinfo-workaround.test.ts b/tests/unit/core/rsa-digestinfo-workaround.test.ts new file mode 100644 index 0000000..b1ae45a --- /dev/null +++ b/tests/unit/core/rsa-digestinfo-workaround.test.ts @@ -0,0 +1,118 @@ +import { verifyRsaWithNonStandardDigestInfo } from "../../../src/core/rsa-digestinfo-workaround"; + +/** + * Synthetic test vectors for non-standard DigestInfo RSA verification. + * + * These vectors were generated with a custom script that creates an RSA signature + * using non-standard DigestInfo format (missing NULL in AlgorithmIdentifier). + * + * Standard DigestInfo: 30 21 30 09 06 05 2b0e03021a 05 00 04 14 [hash] + * Non-standard DigestInfo: 30 1f 30 07 06 05 2b0e03021a 04 14 [hash] + * + * This mimics old Java signing tools (pre-Java 8) that produced non-standard signatures. + */ +const testVectors = { + // RSA 2048-bit public key (SPKI format, base64) + publicKeyBase64: + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmFFN33TkjlQGqNXi4x4VTI5mEaWKXrjGle8PtZzWWyGB3t1Yg9/ZBspPtKrtHAOWO564997O0m62YA+2pWZg2FCMohIO6q2HApDZBSN7o3y9YpTl1erpBaPA2ni5GZJOvArcwkSIRMSbDwAVszqkAr0XlvvpuY+QhBU7hycBhyR2Be+xLcysHY3JlR14r/doxgh0isCquTA6EdM5Es08hymKMGWiRUssClY3IwYxr2O4RgMUJu+cFX1U7l+VUXOTn03t20rniP4aQJ+Ns11qiqK72aGYF5XOC4X2EWf1uDH9uubRIQx+dcIgRrW/mS8T9Ile8sak7bsYkLNIw3lHwwIDAQAB", + // Signature with non-standard DigestInfo (missing NULL in AlgorithmIdentifier) + signatureBase64: + "kHXyzYVLPZAp4/zxi3yMhl2e6/vaard926H0nXINtXKG7LwAwfPWzUu6ovv/g6BaH6bzUNJt9heQ1fZi6vCvygRScuf5InTzhQbvMV8jxWktJl0K3XDtO3D0DYM3ArncwxcR6C7rdOWMdD5IRNAyDddCTviQHerkTBUOHFrqaIdNCffHJMGmnn5oqfM/kdcMpogZsa8ySM6WoX8u3gm3wP13B5Ny+iV178G941NrKyf3sYkZPCksA6gAzIK8NWUwbIKG33+/LvHLwBJVSW2SCbkNC+NMqCPVAj5WumoDqtZea5sVqMrcjDzRsCcV286zSh3rAd1mY+7hzcNRJCzP2A==", + // Test data that was signed + testData: "Test data for RSA signature with non-standard DigestInfo format", + // Hash algorithm + hashAlgorithm: "SHA-1", +}; + +function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = Buffer.from(base64, "base64"); + return binary.buffer.slice(binary.byteOffset, binary.byteOffset + binary.byteLength); +} + +function base64ToUint8Array(base64: string): Uint8Array { + return new Uint8Array(Buffer.from(base64, "base64")); +} + +describe("RSA DigestInfo Workaround (Node.js)", () => { + it("should verify signature with non-standard DigestInfo format", async () => { + const publicKeyData = base64ToArrayBuffer(testVectors.publicKeyBase64); + const signatureBytes = base64ToUint8Array(testVectors.signatureBase64); + const dataToVerify = Buffer.from(testVectors.testData); + + const result = await verifyRsaWithNonStandardDigestInfo( + publicKeyData, + signatureBytes, + dataToVerify, + testVectors.hashAlgorithm, + ); + + expect(result).toBe(true); + }); + + it("should fail verification with tampered data", async () => { + const publicKeyData = base64ToArrayBuffer(testVectors.publicKeyBase64); + const signatureBytes = base64ToUint8Array(testVectors.signatureBase64); + const tamperedData = Buffer.from(testVectors.testData + "tampered"); + + const result = await verifyRsaWithNonStandardDigestInfo( + publicKeyData, + signatureBytes, + tamperedData, + testVectors.hashAlgorithm, + ); + + expect(result).toBe(false); + }); + + it("should fail verification with wrong signature", async () => { + const publicKeyData = base64ToArrayBuffer(testVectors.publicKeyBase64); + const signatureBytes = base64ToUint8Array(testVectors.signatureBase64); + signatureBytes[0] ^= 0xff; // Corrupt the signature + const dataToVerify = Buffer.from(testVectors.testData); + + const result = await verifyRsaWithNonStandardDigestInfo( + publicKeyData, + signatureBytes, + dataToVerify, + testVectors.hashAlgorithm, + ); + + expect(result).toBe(false); + }); + + it("should handle different hash algorithm name formats", async () => { + const publicKeyData = base64ToArrayBuffer(testVectors.publicKeyBase64); + const signatureBytes = base64ToUint8Array(testVectors.signatureBase64); + const dataToVerify = Buffer.from(testVectors.testData); + + // Test with various formats of "SHA-1" + const formats = ["SHA-1", "sha1", "SHA1", "sha-1"]; + + for (const format of formats) { + // Need fresh signature bytes since we don't mutate + const freshSignatureBytes = base64ToUint8Array(testVectors.signatureBase64); + const result = await verifyRsaWithNonStandardDigestInfo( + publicKeyData, + freshSignatureBytes, + dataToVerify, + format, + ); + expect(result).toBe(true); + } + }); + + it("should fail with invalid public key", async () => { + const invalidKeyData = new ArrayBuffer(10); // Invalid key + const signatureBytes = base64ToUint8Array(testVectors.signatureBase64); + const dataToVerify = Buffer.from(testVectors.testData); + + const result = await verifyRsaWithNonStandardDigestInfo( + invalidKeyData, + signatureBytes, + dataToVerify, + testVectors.hashAlgorithm, + ); + + expect(result).toBe(false); + }); +});