fix: improve CORS proxy handling and update documentation for certificate fetching

This commit is contained in:
alam00000
2026-02-20 16:07:21 +05:30
parent 832e28aaca
commit e6c8eaf23c
4 changed files with 732 additions and 516 deletions

View File

@@ -2,82 +2,94 @@ import { PdfSigner, type SignOption } from 'zgapdfsigner';
import forge from 'node-forge';
import { CertificateData, SignPdfOptions } from '@/types';
export function parsePfxFile(pfxBytes: ArrayBuffer, password: string): CertificateData {
const pfxAsn1 = forge.asn1.fromDer(forge.util.createBuffer(new Uint8Array(pfxBytes)));
const pfx = forge.pkcs12.pkcs12FromAsn1(pfxAsn1, password);
export function parsePfxFile(
pfxBytes: ArrayBuffer,
password: string
): CertificateData {
const pfxAsn1 = forge.asn1.fromDer(
forge.util.createBuffer(new Uint8Array(pfxBytes))
);
const pfx = forge.pkcs12.pkcs12FromAsn1(pfxAsn1, password);
const certBags = pfx.getBags({ bagType: forge.pki.oids.certBag });
const keyBags = pfx.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
const certBags = pfx.getBags({ bagType: forge.pki.oids.certBag });
const keyBags = pfx.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
const certBagArray = certBags[forge.pki.oids.certBag];
const keyBagArray = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag];
const certBagArray = certBags[forge.pki.oids.certBag];
const keyBagArray = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag];
if (!certBagArray || certBagArray.length === 0) {
throw new Error('No certificate found in PFX file');
}
if (!certBagArray || certBagArray.length === 0) {
throw new Error('No certificate found in PFX file');
}
if (!keyBagArray || keyBagArray.length === 0) {
throw new Error('No private key found in PFX file');
}
if (!keyBagArray || keyBagArray.length === 0) {
throw new Error('No private key found in PFX file');
}
const certificate = certBagArray[0].cert;
const certificate = certBagArray[0].cert;
if (!certificate) {
throw new Error('Failed to extract certificate from PFX file');
}
if (!certificate) {
throw new Error('Failed to extract certificate from PFX file');
}
return { p12Buffer: pfxBytes, password, certificate };
return { p12Buffer: pfxBytes, password, certificate };
}
export function parsePemFiles(
certPem: string,
keyPem: string,
keyPassword?: string
certPem: string,
keyPem: string,
keyPassword?: string
): CertificateData {
const certificate = forge.pki.certificateFromPem(certPem);
const certificate = forge.pki.certificateFromPem(certPem);
let privateKey: forge.pki.PrivateKey;
if (keyPem.includes('ENCRYPTED')) {
if (!keyPassword) {
throw new Error('Password required for encrypted private key');
}
privateKey = forge.pki.decryptRsaPrivateKey(keyPem, keyPassword);
if (!privateKey) {
throw new Error('Failed to decrypt private key');
}
} else {
privateKey = forge.pki.privateKeyFromPem(keyPem);
let privateKey: forge.pki.PrivateKey;
if (keyPem.includes('ENCRYPTED')) {
if (!keyPassword) {
throw new Error('Password required for encrypted private key');
}
const p12Password = keyPassword || 'temp-password';
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
privateKey,
[certificate],
p12Password,
{ algorithm: '3des' }
);
const p12Der = forge.asn1.toDer(p12Asn1).getBytes();
const p12Buffer = new Uint8Array(p12Der.length);
for (let i = 0; i < p12Der.length; i++) {
p12Buffer[i] = p12Der.charCodeAt(i);
privateKey = forge.pki.decryptRsaPrivateKey(keyPem, keyPassword);
if (!privateKey) {
throw new Error('Failed to decrypt private key');
}
} else {
privateKey = forge.pki.privateKeyFromPem(keyPem);
}
return { p12Buffer: p12Buffer.buffer, password: p12Password, certificate };
const p12Password = keyPassword || crypto.randomUUID();
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
privateKey,
[certificate],
p12Password,
{ algorithm: '3des' }
);
const p12Der = forge.asn1.toDer(p12Asn1).getBytes();
const p12Buffer = new Uint8Array(p12Der.length);
for (let i = 0; i < p12Der.length; i++) {
p12Buffer[i] = p12Der.charCodeAt(i);
}
return { p12Buffer: p12Buffer.buffer, password: p12Password, certificate };
}
export function parseCombinedPem(pemContent: string, password?: string): CertificateData {
const certMatch = pemContent.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/);
const keyMatch = pemContent.match(/-----BEGIN (RSA |EC |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (RSA |EC |ENCRYPTED )?PRIVATE KEY-----/);
export function parseCombinedPem(
pemContent: string,
password?: string
): CertificateData {
const certMatch = pemContent.match(
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/
);
const keyMatch = pemContent.match(
/-----BEGIN (RSA |EC |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (RSA |EC |ENCRYPTED )?PRIVATE KEY-----/
);
if (!certMatch) {
throw new Error('No certificate found in PEM file');
}
if (!certMatch) {
throw new Error('No certificate found in PEM file');
}
if (!keyMatch) {
throw new Error('No private key found in PEM file');
}
if (!keyMatch) {
throw new Error('No private key found in PEM file');
}
return parsePemFiles(certMatch[0], keyMatch[0], password);
return parsePemFiles(certMatch[0], keyMatch[0], password);
}
/**
@@ -85,30 +97,30 @@ export function parseCombinedPem(pemContent: string, password?: string): Certifi
* The zgapdfsigner library tries to fetch issuer certificates from external URLs,
* but those servers often don't have CORS headers. This proxy adds the necessary
* CORS headers to allow the requests from the browser.
*
*
* If you are self-hosting, you MUST deploy your own proxy using cloudflare/cors-proxy-worker.js or any other way of your choice
* and set VITE_CORS_PROXY_URL environment variable.
*
*
* If not set, certificates requiring external chain fetching will fail.
*/
const CORS_PROXY_URL = import.meta.env.VITE_CORS_PROXY_URL || '';
/**
* Shared secret for signing proxy requests (HMAC-SHA256).
*
*
* SECURITY NOTE FOR PRODUCTION:
* Client-side secrets are NEVER truly hidden and they can be extracted from
* bundled JavaScript.
*
* bundled JavaScript.
*
* For production deployments with sensitive requirements, you should:
* 1. Use your own backend server to proxy certificate requests
* 2. Keep the HMAC secret on your server ONLY (never in frontend code)
* 3. Have your frontend call your server, which then calls the CORS proxy
*
*
* This client-side HMAC provides limited protection (deters casual abuse)
* but should NOT be considered secure against determined attackers. BentoPDF
* accepts this tradeoff because of it's client side architecture.
*
*
* To enable (optional):
* 1. Generate a secret: openssl rand -hex 32
* 2. Set PROXY_SECRET on your Cloudflare Worker: npx wrangler secret put PROXY_SECRET
@@ -116,26 +128,29 @@ const CORS_PROXY_URL = import.meta.env.VITE_CORS_PROXY_URL || '';
*/
const CORS_PROXY_SECRET = import.meta.env.VITE_CORS_PROXY_SECRET || '';
async function generateProxySignature(url: string, timestamp: number): Promise<string> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(CORS_PROXY_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
async function generateProxySignature(
url: string,
timestamp: number
): Promise<string> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(CORS_PROXY_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const message = `${url}${timestamp}`;
const signature = await crypto.subtle.sign(
'HMAC',
key,
encoder.encode(message)
);
const message = `${url}${timestamp}`;
const signature = await crypto.subtle.sign(
'HMAC',
key,
encoder.encode(message)
);
return Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
return Array.from(new Uint8Array(signature))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/**
@@ -143,141 +158,165 @@ async function generateProxySignature(url: string, timestamp: number): Promise<s
* The zgapdfsigner library tries to fetch issuer certificates from URLs embedded in the
* certificate's AIA extension. When those servers don't have CORS enabled (like www.cert.fnmt.es),
* the fetch fails. This wrapper routes such requests through our CORS proxy.
*
*
* If VITE_CORS_PROXY_SECRET is configured, requests include HMAC signatures for anti-spoofing.
*
*/
let fetchWrapRefCount = 0;
let savedOriginalFetch: typeof fetch | null = null;
function createCorsAwareFetch(): {
wrappedFetch: typeof fetch;
restore: () => void;
wrappedFetch: typeof fetch;
restore: () => void;
} {
const originalFetch = window.fetch.bind(window);
if (fetchWrapRefCount === 0) {
savedOriginalFetch = window.fetch.bind(window);
const wrappedFetch: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
const originalFetch = savedOriginalFetch;
const isExternalCertificateUrl = (
url.includes('.crt') ||
url.includes('.cer') ||
url.includes('.pem') ||
url.includes('/certs/') ||
url.includes('/ocsp') ||
url.includes('/crl') ||
url.includes('caIssuers')
) && !url.startsWith(window.location.origin);
window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
if (isExternalCertificateUrl && CORS_PROXY_URL) {
let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`;
const isExternalCertificateUrl =
(url.includes('.crt') ||
url.includes('.cer') ||
url.includes('.pem') ||
url.includes('/certs/') ||
url.includes('/ocsp') ||
url.includes('/crl') ||
url.includes('caIssuers')) &&
!url.startsWith(window.location.origin);
if (CORS_PROXY_SECRET) {
const timestamp = Date.now();
const signature = await generateProxySignature(url, timestamp);
proxyUrl += `&t=${timestamp}&sig=${signature}`;
console.log(`[CORS Proxy] Routing signed certificate request through proxy: ${url}`);
} else {
console.log(`[CORS Proxy] Routing certificate request through proxy: ${url}`);
}
if (isExternalCertificateUrl && CORS_PROXY_URL) {
let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`;
return originalFetch(proxyUrl, init);
if (CORS_PROXY_SECRET) {
const timestamp = Date.now();
const signature = await generateProxySignature(url, timestamp);
proxyUrl += `&t=${timestamp}&sig=${signature}`;
console.log(
`[CORS Proxy] Routing signed certificate request through proxy: ${url}`
);
} else {
console.log(
`[CORS Proxy] Routing certificate request through proxy: ${url}`
);
}
return originalFetch(input, init);
};
return originalFetch(proxyUrl, init);
}
window.fetch = wrappedFetch;
return originalFetch(input, init);
}) as typeof fetch;
}
return {
wrappedFetch,
restore: () => {
window.fetch = originalFetch;
}
};
fetchWrapRefCount++;
return {
wrappedFetch: window.fetch,
restore: () => {
fetchWrapRefCount--;
if (fetchWrapRefCount === 0 && savedOriginalFetch) {
window.fetch = savedOriginalFetch;
savedOriginalFetch = null;
}
},
};
}
export async function signPdf(
pdfBytes: Uint8Array,
certificateData: CertificateData,
options: SignPdfOptions = {}
pdfBytes: Uint8Array,
certificateData: CertificateData,
options: SignPdfOptions = {}
): Promise<Uint8Array> {
const signatureInfo = options.signatureInfo ?? {};
const signatureInfo = options.signatureInfo ?? {};
const signOptions: SignOption = {
p12cert: certificateData.p12Buffer,
pwd: certificateData.password,
const signOptions: SignOption = {
p12cert: certificateData.p12Buffer,
pwd: certificateData.password,
};
if (signatureInfo.reason) {
signOptions.reason = signatureInfo.reason;
}
if (signatureInfo.location) {
signOptions.location = signatureInfo.location;
}
if (signatureInfo.contactInfo) {
signOptions.contact = signatureInfo.contactInfo;
}
if (options.visibleSignature?.enabled) {
const vs = options.visibleSignature;
const drawinf = {
area: {
x: vs.x,
y: vs.y,
w: vs.width,
h: vs.height,
},
pageidx: vs.page,
imgInfo: undefined as
| { imgData: ArrayBuffer; imgType: string }
| undefined,
textInfo: undefined as
| { text: string; size: number; color: string }
| undefined,
};
if (signatureInfo.reason) {
signOptions.reason = signatureInfo.reason;
if (vs.imageData && vs.imageType) {
drawinf.imgInfo = {
imgData: vs.imageData,
imgType: vs.imageType,
};
}
if (signatureInfo.location) {
signOptions.location = signatureInfo.location;
if (vs.text) {
drawinf.textInfo = {
text: vs.text,
size: vs.textSize ?? 12,
color: vs.textColor ?? '#000000',
};
}
if (signatureInfo.contactInfo) {
signOptions.contact = signatureInfo.contactInfo;
}
signOptions.drawinf = drawinf as SignOption['drawinf'];
}
if (options.visibleSignature?.enabled) {
const vs = options.visibleSignature;
const signer = new PdfSigner(signOptions);
const drawinf = {
area: {
x: vs.x,
y: vs.y,
w: vs.width,
h: vs.height,
},
pageidx: vs.page,
imgInfo: undefined as { imgData: ArrayBuffer; imgType: string } | undefined,
textInfo: undefined as { text: string; size: number; color: string } | undefined,
};
const { restore } = createCorsAwareFetch();
if (vs.imageData && vs.imageType) {
drawinf.imgInfo = {
imgData: vs.imageData,
imgType: vs.imageType,
};
}
if (vs.text) {
drawinf.textInfo = {
text: vs.text,
size: vs.textSize ?? 12,
color: vs.textColor ?? '#000000',
};
}
signOptions.drawinf = drawinf as SignOption['drawinf'];
}
const signer = new PdfSigner(signOptions);
const { restore } = createCorsAwareFetch();
try {
const signedPdfBytes = await signer.sign(pdfBytes);
return new Uint8Array(signedPdfBytes);
} finally {
restore();
}
try {
const signedPdfBytes = await signer.sign(pdfBytes);
return new Uint8Array(signedPdfBytes);
} finally {
restore();
}
}
export function getCertificateInfo(certificate: forge.pki.Certificate): {
subject: string;
issuer: string;
validFrom: Date;
validTo: Date;
serialNumber: string;
subject: string;
issuer: string;
validFrom: Date;
validTo: Date;
serialNumber: string;
} {
const subjectCN = certificate.subject.getField('CN');
const issuerCN = certificate.issuer.getField('CN');
const subjectCN = certificate.subject.getField('CN');
const issuerCN = certificate.issuer.getField('CN');
return {
subject: subjectCN?.value as string ?? 'Unknown',
issuer: issuerCN?.value as string ?? 'Unknown',
validFrom: certificate.validity.notBefore,
validTo: certificate.validity.notAfter,
serialNumber: certificate.serialNumber,
};
return {
subject: (subjectCN?.value as string) ?? 'Unknown',
issuer: (issuerCN?.value as string) ?? 'Unknown',
validFrom: certificate.validity.notBefore,
validTo: certificate.validity.notAfter,
serialNumber: certificate.serialNumber,
};
}