mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-07 08:31:01 +01:00
feat: update GitHub Actions workflow for PDF engine and viewer
This commit is contained in:
@@ -7,7 +7,10 @@ import { icons, createIcons } from 'lucide';
|
||||
import JSZip from 'jszip';
|
||||
import { deduplicateFileName } from '../utils/deduplicate-filename.js';
|
||||
import { FlattenPdfState } from '@/types';
|
||||
import { loadPdfDocument } from '../utils/load-pdf-document.js';
|
||||
import {
|
||||
loadPdfDocument,
|
||||
removeUnreferencedObjects,
|
||||
} from '../utils/load-pdf-document.js';
|
||||
|
||||
const pageState: FlattenPdfState = {
|
||||
files: [],
|
||||
@@ -134,7 +137,7 @@ async function flattenPdf() {
|
||||
console.warn('Could not flatten annotations:', msg);
|
||||
}
|
||||
|
||||
const newPdfBytes = await pdfDoc.save();
|
||||
const newPdfBytes = await removeUnreferencedObjects(await pdfDoc.save());
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
`flattened_${file.name}`
|
||||
@@ -173,7 +176,9 @@ async function flattenPdf() {
|
||||
console.warn('Could not flatten annotations:', msg);
|
||||
}
|
||||
|
||||
const flattenedBytes = await pdfDoc.save();
|
||||
const flattenedBytes = await removeUnreferencedObjects(
|
||||
await pdfDoc.save()
|
||||
);
|
||||
const zipEntryName = deduplicateFileName(file.name, usedNames);
|
||||
zip.file(zipEntryName, flattenedBytes);
|
||||
processedCount++;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { PDFDocument, PDFName } from 'pdf-lib';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
||||
import { loadPdfDocument } from '../utils/load-pdf-document.js';
|
||||
import {
|
||||
loadPdfDocument,
|
||||
removeUnreferencedObjects,
|
||||
} from '../utils/load-pdf-document.js';
|
||||
import { escapeHtml } from '../utils/helpers.js';
|
||||
|
||||
// State management
|
||||
@@ -142,7 +145,9 @@ async function processRemoveAnnotations() {
|
||||
}
|
||||
}
|
||||
|
||||
const newPdfBytes = await pageState.pdfDoc.save();
|
||||
const newPdfBytes = await removeUnreferencedObjects(
|
||||
await pageState.pdfDoc.save()
|
||||
);
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
pageState.file?.name || 'document.pdf'
|
||||
|
||||
@@ -3,7 +3,10 @@ import { downloadFile, formatBytes } from '../utils/helpers.js';
|
||||
import { PDFDocument, PDFName } from 'pdf-lib';
|
||||
import { icons, createIcons } from 'lucide';
|
||||
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
||||
import { loadPdfDocument } from '../utils/load-pdf-document.js';
|
||||
import {
|
||||
loadPdfDocument,
|
||||
removeUnreferencedObjects,
|
||||
} from '../utils/load-pdf-document.js';
|
||||
|
||||
interface PageState {
|
||||
file: File | null;
|
||||
@@ -155,9 +158,9 @@ async function removeMetadata() {
|
||||
|
||||
removeMetadataFromDoc(pdfDoc);
|
||||
|
||||
const newPdfBytes = await pdfDoc.save();
|
||||
const newPdfBytes = await removeUnreferencedObjects(await pdfDoc.save());
|
||||
downloadFile(
|
||||
new Blob([newPdfBytes as BlobPart], { type: 'application/pdf' }),
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
pageState.file?.name || 'document.pdf'
|
||||
);
|
||||
showAlert('Success', 'Metadata removed successfully!', 'success', () => {
|
||||
|
||||
@@ -113,6 +113,8 @@ export async function validateSignature(
|
||||
authenticatedAttributes?: forge.asn1.Asn1[];
|
||||
signature?: string;
|
||||
signatureAlgorithm?: forge.asn1.Asn1[];
|
||||
issuer?: forge.asn1.Asn1;
|
||||
serial?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -121,7 +123,12 @@ export async function validateSignature(
|
||||
return result;
|
||||
}
|
||||
|
||||
const signerCert = p7.certificates[0] as forge.pki.Certificate;
|
||||
const certificates = p7.certificates as forge.pki.Certificate[];
|
||||
const signerCert = findSignerCertificate(
|
||||
certificates,
|
||||
p7.rawCapture?.issuer,
|
||||
p7.rawCapture?.serial
|
||||
);
|
||||
|
||||
const subjectCN = signerCert.subject.getField('CN');
|
||||
const subjectO = signerCert.subject.getField('O');
|
||||
@@ -148,27 +155,11 @@ export async function validateSignature(
|
||||
// Check trust against provided certificate (cryptographic, not name/serial match)
|
||||
if (trustedCert) {
|
||||
try {
|
||||
const trustedPem = forge.pki.certificateToPem(trustedCert);
|
||||
const isSameCert =
|
||||
forge.pki.certificateToPem(signerCert) === trustedPem;
|
||||
|
||||
let cryptoIssued = false;
|
||||
const chain = [
|
||||
result.isTrusted = chainsToTrustedCert(
|
||||
signerCert,
|
||||
...(p7.certificates as forge.pki.Certificate[]),
|
||||
];
|
||||
for (const cert of chain) {
|
||||
try {
|
||||
if (trustedCert.verify(cert)) {
|
||||
cryptoIssued = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
result.isTrusted = isSameCert || cryptoIssued;
|
||||
certificates,
|
||||
trustedCert
|
||||
);
|
||||
} catch {
|
||||
result.isTrusted = false;
|
||||
}
|
||||
@@ -267,6 +258,70 @@ export async function validateSignature(
|
||||
return result;
|
||||
}
|
||||
|
||||
function findSignerCertificate(
|
||||
certificates: forge.pki.Certificate[],
|
||||
issuer: forge.asn1.Asn1 | undefined,
|
||||
serial: string | undefined
|
||||
): forge.pki.Certificate {
|
||||
if (issuer && serial) {
|
||||
const serialHex = forge.util.createBuffer(serial).toHex();
|
||||
const issuerDer = forge.asn1.toDer(issuer).getBytes();
|
||||
const match = certificates.find(
|
||||
(cert) =>
|
||||
cert.serialNumber === serialHex &&
|
||||
forge.asn1
|
||||
.toDer(forge.pki.distinguishedNameToAsn1(cert.issuer))
|
||||
.getBytes() === issuerDer
|
||||
);
|
||||
if (match) return match;
|
||||
}
|
||||
return certificates[0];
|
||||
}
|
||||
|
||||
function isIssuedBy(
|
||||
issuer: forge.pki.Certificate,
|
||||
child: forge.pki.Certificate
|
||||
): boolean {
|
||||
try {
|
||||
return issuer.verify(child);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isCertificateAuthority(cert: forge.pki.Certificate): boolean {
|
||||
const basicConstraints = cert.getExtension('basicConstraints') as
|
||||
| { cA?: boolean }
|
||||
| undefined;
|
||||
return basicConstraints?.cA === true;
|
||||
}
|
||||
|
||||
function chainsToTrustedCert(
|
||||
signerCert: forge.pki.Certificate,
|
||||
certificates: forge.pki.Certificate[],
|
||||
trustedCert: forge.pki.Certificate
|
||||
): boolean {
|
||||
const trustedPem = forge.pki.certificateToPem(trustedCert);
|
||||
const visited = new Set<string>();
|
||||
let current = signerCert;
|
||||
|
||||
for (let depth = 0; depth < 10; depth++) {
|
||||
const currentPem = forge.pki.certificateToPem(current);
|
||||
if (currentPem === trustedPem || isIssuedBy(trustedCert, current)) {
|
||||
return true;
|
||||
}
|
||||
if (visited.has(currentPem)) return false;
|
||||
visited.add(currentPem);
|
||||
|
||||
const parent = certificates.find(
|
||||
(cert) => isCertificateAuthority(cert) && isIssuedBy(cert, current)
|
||||
);
|
||||
if (!parent) return false;
|
||||
current = parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function validatePdfSignatures(
|
||||
pdfBytes: Uint8Array,
|
||||
trustedCert?: forge.pki.Certificate
|
||||
|
||||
@@ -4,13 +4,16 @@ import { initializeQpdf } from './helpers.js';
|
||||
type LoadOptions = Parameters<typeof PDFDocument.load>[1];
|
||||
type PDFDocumentInstance = Awaited<ReturnType<typeof PDFDocument.load>>;
|
||||
|
||||
async function repairPdfBytes(pdf: Uint8Array): Promise<Uint8Array | null> {
|
||||
async function runQpdf(
|
||||
pdf: Uint8Array,
|
||||
args: string[]
|
||||
): Promise<Uint8Array | null> {
|
||||
try {
|
||||
const qpdf = await initializeQpdf();
|
||||
qpdf.FS.writeFile('/input.pdf', pdf);
|
||||
|
||||
try {
|
||||
qpdf.callMain(['/input.pdf', '--decrypt', '/output.pdf']);
|
||||
qpdf.callMain(['/input.pdf', ...args, '/output.pdf']);
|
||||
} catch (e) {
|
||||
console.warn('[loadPdfDocument] qpdf repair warning:', e);
|
||||
}
|
||||
@@ -40,6 +43,23 @@ async function repairPdfBytes(pdf: Uint8Array): Promise<Uint8Array | null> {
|
||||
}
|
||||
}
|
||||
|
||||
function repairPdfBytes(pdf: Uint8Array): Promise<Uint8Array | null> {
|
||||
return runQpdf(pdf, ['--decrypt']);
|
||||
}
|
||||
|
||||
export async function removeUnreferencedObjects(
|
||||
pdf: Uint8Array | ArrayBuffer
|
||||
): Promise<Uint8Array> {
|
||||
const cleaned = await runQpdf(
|
||||
pdf instanceof Uint8Array ? pdf : new Uint8Array(pdf),
|
||||
[]
|
||||
);
|
||||
if (!cleaned) {
|
||||
throw new Error('Could not remove leftover data from the PDF.');
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export async function loadPdfDocument(
|
||||
pdf: Uint8Array | ArrayBuffer,
|
||||
options?: LoadOptions
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
PDFArray,
|
||||
PDFDict,
|
||||
PDFHexString,
|
||||
PDFName,
|
||||
PDFObject,
|
||||
PDFRawStream,
|
||||
PDFRef,
|
||||
PDFStream,
|
||||
PDFString,
|
||||
decodePDFRawStream,
|
||||
} from 'pdf-lib';
|
||||
import type { PDFDocument } from 'pdf-lib';
|
||||
import {
|
||||
loadPdfDocument,
|
||||
removeUnreferencedObjects,
|
||||
} from './load-pdf-document.js';
|
||||
|
||||
const ANNOTATION_TEXT_KEYS = ['Contents', 'RC', 'T', 'Subj'];
|
||||
|
||||
function decodeText(value: PDFObject | undefined): string | null {
|
||||
if (value instanceof PDFString || value instanceof PDFHexString) {
|
||||
return value.decodeText();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function decodeStream(stream: PDFRawStream): Uint8Array | null {
|
||||
try {
|
||||
return decodePDFRawStream(stream).decode();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function removeTextFromDocument(doc: PDFDocument, term: string): void {
|
||||
const context = doc.context;
|
||||
const needle = term.toLowerCase();
|
||||
const pattern = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||||
const contains = (text: string) => text.toLowerCase().includes(needle);
|
||||
const valueContains = (value: PDFObject | undefined): boolean => {
|
||||
if (value instanceof PDFArray) return value.asArray().some(valueContains);
|
||||
const text = decodeText(value);
|
||||
return text !== null && contains(text);
|
||||
};
|
||||
const resolve = (value: PDFObject | undefined) =>
|
||||
value instanceof PDFRef ? context.lookup(value) : value;
|
||||
|
||||
const matchedFiles = new Set<string>();
|
||||
for (const [ref, object] of context.enumerateIndirectObjects()) {
|
||||
if (
|
||||
!(object instanceof PDFRawStream) ||
|
||||
object.dict.get(PDFName.of('Type')) !== PDFName.of('EmbeddedFile')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const bytes = decodeStream(object);
|
||||
if (!bytes) continue;
|
||||
const latin1 = new TextDecoder('latin1').decode(bytes);
|
||||
const utf16 = new TextDecoder('utf-16le').decode(bytes);
|
||||
if (contains(latin1) || contains(utf16)) matchedFiles.add(ref.toString());
|
||||
}
|
||||
|
||||
const isMatchedFileSpec = (value: PDFObject | undefined): boolean => {
|
||||
const spec = resolve(value);
|
||||
if (!(spec instanceof PDFDict)) return false;
|
||||
const ef = resolve(spec.get(PDFName.of('EF')));
|
||||
if (!(ef instanceof PDFDict)) return false;
|
||||
return ef
|
||||
.values()
|
||||
.some((v) => v instanceof PDFRef && matchedFiles.has(v.toString()));
|
||||
};
|
||||
|
||||
const fieldValueContains = (widget: PDFDict): boolean => {
|
||||
let node: PDFObject | undefined = widget;
|
||||
for (let depth = 0; node instanceof PDFDict && depth < 32; depth++) {
|
||||
if (valueContains(resolve(node.get(PDFName.of('V'))))) return true;
|
||||
node = resolve(node.get(PDFName.of('Parent')));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
let clearedFieldAppearance = false;
|
||||
for (const page of doc.getPages()) {
|
||||
page.node.delete(PDFName.of('Thumb'));
|
||||
const annots = page.node.Annots();
|
||||
if (!annots) continue;
|
||||
|
||||
const removed = new Set<string>();
|
||||
for (const entry of annots.asArray()) {
|
||||
const annot = resolve(entry);
|
||||
if (!(annot instanceof PDFDict)) continue;
|
||||
const subtype = annot.get(PDFName.of('Subtype'));
|
||||
|
||||
if (subtype === PDFName.of('Widget')) {
|
||||
if (fieldValueContains(annot)) {
|
||||
annot.delete(PDFName.of('AP'));
|
||||
clearedFieldAppearance = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasText = ANNOTATION_TEXT_KEYS.some((key) =>
|
||||
valueContains(resolve(annot.get(PDFName.of(key))))
|
||||
);
|
||||
const hasFile =
|
||||
subtype === PDFName.of('FileAttachment') &&
|
||||
isMatchedFileSpec(annot.get(PDFName.of('FS')));
|
||||
if (hasText || hasFile) {
|
||||
removed.add(entry.toString());
|
||||
const popup = annot.get(PDFName.of('Popup'));
|
||||
if (popup) removed.add(popup.toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.size > 0) {
|
||||
page.node.set(
|
||||
PDFName.of('Annots'),
|
||||
context.obj(
|
||||
annots.asArray().filter((entry) => !removed.has(entry.toString()))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (clearedFieldAppearance) {
|
||||
const acroForm = resolve(doc.catalog.get(PDFName.of('AcroForm')));
|
||||
if (acroForm instanceof PDFDict) {
|
||||
acroForm.set(PDFName.of('NeedAppearances'), context.obj(true));
|
||||
}
|
||||
}
|
||||
|
||||
const pruneNameTree = (value: PDFObject | undefined, depth = 0) => {
|
||||
const node = resolve(value);
|
||||
if (!(node instanceof PDFDict) || depth > 32) return;
|
||||
const names = resolve(node.get(PDFName.of('Names')));
|
||||
if (names instanceof PDFArray) {
|
||||
const items = names.asArray();
|
||||
const kept: PDFObject[] = [];
|
||||
for (let i = 0; i + 1 < items.length; i += 2) {
|
||||
if (!isMatchedFileSpec(items[i + 1])) kept.push(items[i], items[i + 1]);
|
||||
}
|
||||
node.set(PDFName.of('Names'), context.obj(kept));
|
||||
}
|
||||
const kids = resolve(node.get(PDFName.of('Kids')));
|
||||
if (kids instanceof PDFArray) {
|
||||
for (const kid of kids.asArray()) pruneNameTree(kid, depth + 1);
|
||||
}
|
||||
};
|
||||
const pruneAssociatedFiles = (dict: PDFDict) => {
|
||||
const list = resolve(dict.get(PDFName.of('AF')));
|
||||
if (!(list instanceof PDFArray)) return;
|
||||
const kept = list.asArray().filter((spec) => !isMatchedFileSpec(spec));
|
||||
if (kept.length !== list.size()) {
|
||||
dict.set(PDFName.of('AF'), context.obj(kept));
|
||||
}
|
||||
};
|
||||
|
||||
if (matchedFiles.size > 0) {
|
||||
const names = resolve(doc.catalog.get(PDFName.of('Names')));
|
||||
if (names instanceof PDFDict) {
|
||||
pruneNameTree(names.get(PDFName.of('EmbeddedFiles')));
|
||||
}
|
||||
for (const [, object] of context.enumerateIndirectObjects()) {
|
||||
if (object instanceof PDFDict) pruneAssociatedFiles(object);
|
||||
else if (object instanceof PDFStream) pruneAssociatedFiles(object.dict);
|
||||
}
|
||||
}
|
||||
|
||||
const scrubValue = (value: PDFObject): PDFObject | null => {
|
||||
const text = decodeText(value);
|
||||
if (text !== null && contains(text)) {
|
||||
return PDFHexString.fromText(text.replace(pattern, ''));
|
||||
}
|
||||
if (value instanceof PDFDict || value instanceof PDFArray) {
|
||||
scrubContainer(value);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const scrubContainer = (container: PDFDict | PDFArray) => {
|
||||
if (container instanceof PDFDict) {
|
||||
for (const [key, value] of container.entries()) {
|
||||
const replacement = scrubValue(value);
|
||||
if (replacement) container.set(key, replacement);
|
||||
}
|
||||
} else {
|
||||
container.asArray().forEach((value, index) => {
|
||||
const replacement = scrubValue(value);
|
||||
if (replacement) container.set(index, replacement);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const [ref, object] of context.enumerateIndirectObjects()) {
|
||||
if (object instanceof PDFStream) {
|
||||
scrubContainer(object.dict);
|
||||
const isMetadata =
|
||||
object.dict.get(PDFName.of('Type')) === PDFName.of('Metadata');
|
||||
if (isMetadata && object instanceof PDFRawStream) {
|
||||
const bytes = decodeStream(object);
|
||||
const xml = bytes ? new TextDecoder('utf-8').decode(bytes) : null;
|
||||
if (xml !== null && contains(xml)) {
|
||||
context.assign(
|
||||
ref,
|
||||
context.flateStream(xml.replace(pattern, ''), {
|
||||
Type: 'Metadata',
|
||||
Subtype: 'XML',
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (object instanceof PDFDict || object instanceof PDFArray) {
|
||||
scrubContainer(object);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function redactDocumentData(
|
||||
pdf: Uint8Array,
|
||||
term?: string
|
||||
): Promise<Uint8Array> {
|
||||
const doc = await loadPdfDocument(pdf);
|
||||
if (term) {
|
||||
removeTextFromDocument(doc, term);
|
||||
} else {
|
||||
for (const page of doc.getPages()) page.node.delete(PDFName.of('Thumb'));
|
||||
}
|
||||
return removeUnreferencedObjects(await doc.save());
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PDFDocument, PDFName, PDFDict, PDFArray } from 'pdf-lib';
|
||||
import { loadPdfDocument } from './load-pdf-document.js';
|
||||
import { PDFDocument, PDFName, PDFDict, PDFArray, PDFStream } from 'pdf-lib';
|
||||
import {
|
||||
loadPdfDocument,
|
||||
removeUnreferencedObjects,
|
||||
} from './load-pdf-document.js';
|
||||
import type { PDFDocumentInternal } from '@/types';
|
||||
|
||||
function getCatalogDict(pdfDoc: PDFDocument): PDFDict {
|
||||
@@ -281,6 +284,14 @@ function removeEmbeddedFilesFromDoc(pdfDoc: PDFDocument) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, object] of pdfDoc.context.enumerateIndirectObjects()) {
|
||||
if (object instanceof PDFDict) {
|
||||
object.delete(PDFName.of('AF'));
|
||||
} else if (object instanceof PDFStream) {
|
||||
object.dict.delete(PDFName.of('AF'));
|
||||
}
|
||||
}
|
||||
|
||||
const pdfDocInternal = pdfDoc as unknown as PDFDocumentInternal;
|
||||
if (pdfDocInternal.embeddedFiles && pdfDocInternal.embeddedFiles.length > 0) {
|
||||
pdfDocInternal.embeddedFiles = [];
|
||||
@@ -641,5 +652,5 @@ export async function sanitizePdf(
|
||||
}
|
||||
|
||||
const savedBytes = await pdfDoc.save();
|
||||
return { pdfDoc, bytes: new Uint8Array(savedBytes) };
|
||||
return { pdfDoc, bytes: await removeUnreferencedObjects(savedBytes) };
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { flattenAnnotations } from '../../utils/flatten-annotations.js';
|
||||
import { loadPdfDocument } from '../../utils/load-pdf-document.js';
|
||||
import {
|
||||
loadPdfDocument,
|
||||
removeUnreferencedObjects,
|
||||
} from '../../utils/load-pdf-document.js';
|
||||
|
||||
export class FlattenNode extends BaseWorkflowNode {
|
||||
readonly category = 'Secure PDF' as const;
|
||||
@@ -39,11 +42,11 @@ export class FlattenNode extends BaseWorkflowNode {
|
||||
console.error('Flatten annotations error:', err);
|
||||
}
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfBytes = await removeUnreferencedObjects(await pdfDoc.save());
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: pdfDoc,
|
||||
bytes: new Uint8Array(pdfBytes),
|
||||
bytes: pdfBytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_flattened.pdf'),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { requirePdfInput, processBatch } from '../types';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
import { hexToRgb } from '../../utils/helpers.js';
|
||||
import { loadPdfDocument } from '../../utils/load-pdf-document.js';
|
||||
import { redactDocumentData } from '../../utils/redact-document-data.js';
|
||||
import { wfError } from '../errors';
|
||||
|
||||
export class RedactNode extends BaseWorkflowNode {
|
||||
@@ -106,9 +107,14 @@ export class RedactNode extends BaseWorkflowNode {
|
||||
}
|
||||
}
|
||||
|
||||
const resultBytes = new Uint8Array(doc.save());
|
||||
const redactedBytes = new Uint8Array(doc.save());
|
||||
doc.close();
|
||||
|
||||
const resultBytes = await redactDocumentData(
|
||||
redactedBytes,
|
||||
mode === 'text' ? searchText : undefined
|
||||
);
|
||||
|
||||
const resultDoc = await loadPdfDocument(resultBytes);
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,10 @@ import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFName } from 'pdf-lib';
|
||||
import { loadPdfDocument } from '../../utils/load-pdf-document.js';
|
||||
import {
|
||||
loadPdfDocument,
|
||||
removeUnreferencedObjects,
|
||||
} from '../../utils/load-pdf-document.js';
|
||||
|
||||
export class RemoveAnnotationsNode extends BaseWorkflowNode {
|
||||
readonly category = 'Edit & Annotate' as const;
|
||||
@@ -34,11 +37,11 @@ export class RemoveAnnotationsNode extends BaseWorkflowNode {
|
||||
}
|
||||
}
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfBytes = await removeUnreferencedObjects(await pdfDoc.save());
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: pdfDoc,
|
||||
bytes: new Uint8Array(pdfBytes),
|
||||
bytes: pdfBytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_clean.pdf'),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import {
|
||||
PDFArray,
|
||||
PDFDict,
|
||||
PDFDocument,
|
||||
PDFHexString,
|
||||
PDFName,
|
||||
PDFObject,
|
||||
PDFRawStream,
|
||||
PDFString,
|
||||
decodePDFRawStream,
|
||||
} from 'pdf-lib';
|
||||
|
||||
vi.mock('../js/utils/helpers.js', async () => {
|
||||
const { default: createModule } = await import('@neslinesli93/qpdf-wasm');
|
||||
const wasmPath = resolve(
|
||||
'node_modules/@neslinesli93/qpdf-wasm/dist/qpdf.wasm'
|
||||
);
|
||||
const options = {
|
||||
locateFile: () => wasmPath,
|
||||
wasmBinary: readFileSync(wasmPath),
|
||||
print: () => {},
|
||||
printErr: () => {},
|
||||
};
|
||||
const qpdf = await createModule(options);
|
||||
return { initializeQpdf: async () => qpdf };
|
||||
});
|
||||
|
||||
const { redactDocumentData } =
|
||||
await import('../js/utils/redact-document-data.js');
|
||||
|
||||
const SECRET = 'SECRET-4471';
|
||||
|
||||
async function buildDocument(): Promise<Uint8Array> {
|
||||
const doc = await PDFDocument.create();
|
||||
const page = doc.addPage([300, 300]);
|
||||
const ctx = doc.context;
|
||||
doc.setTitle(`Report for ${SECRET}`);
|
||||
doc.setAuthor('Keep Author');
|
||||
|
||||
doc.catalog.set(
|
||||
PDFName.of('Metadata'),
|
||||
ctx.register(
|
||||
ctx.flateStream(`<x:xmpmeta><dc:title>${SECRET}</dc:title></x:xmpmeta>`, {
|
||||
Type: 'Metadata',
|
||||
Subtype: 'XML',
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const outlines = ctx.nextRef();
|
||||
const first = ctx.nextRef();
|
||||
const second = ctx.nextRef();
|
||||
ctx.assign(
|
||||
first,
|
||||
ctx.obj({
|
||||
Title: PDFString.of(`${SECRET} chapter`),
|
||||
Parent: outlines,
|
||||
Next: second,
|
||||
})
|
||||
);
|
||||
ctx.assign(
|
||||
second,
|
||||
ctx.obj({
|
||||
Title: PDFString.of('Keep chapter'),
|
||||
Parent: outlines,
|
||||
Prev: first,
|
||||
})
|
||||
);
|
||||
ctx.assign(
|
||||
outlines,
|
||||
ctx.obj({ Type: 'Outlines', First: first, Last: second, Count: 2 })
|
||||
);
|
||||
doc.catalog.set(PDFName.of('Outlines'), outlines);
|
||||
|
||||
doc.catalog.set(
|
||||
PDFName.of('StructTreeRoot'),
|
||||
ctx.register(
|
||||
ctx.obj({
|
||||
Type: 'StructTreeRoot',
|
||||
K: ctx.obj({
|
||||
S: 'Figure',
|
||||
Alt: PDFHexString.fromText(`Photo of ${SECRET}`),
|
||||
}),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const secretNote = ctx.register(
|
||||
ctx.obj({
|
||||
Type: 'Annot',
|
||||
Subtype: 'Text',
|
||||
Rect: [10, 10, 30, 30],
|
||||
Contents: PDFString.of(`note about ${secret()}`),
|
||||
})
|
||||
);
|
||||
const keepNote = ctx.register(
|
||||
ctx.obj({
|
||||
Type: 'Annot',
|
||||
Subtype: 'Text',
|
||||
Rect: [40, 10, 60, 30],
|
||||
Contents: PDFString.of('keep note'),
|
||||
})
|
||||
);
|
||||
const appearance = ctx.register(
|
||||
ctx.flateStream(`BT /Helv 10 Tf (${SECRET}) Tj ET`, {
|
||||
Type: 'XObject',
|
||||
Subtype: 'Form',
|
||||
BBox: [0, 0, 100, 20],
|
||||
})
|
||||
);
|
||||
const widget = ctx.register(
|
||||
ctx.obj({
|
||||
Type: 'Annot',
|
||||
Subtype: 'Widget',
|
||||
FT: 'Tx',
|
||||
T: PDFString.of('ssn'),
|
||||
V: PDFString.of(SECRET),
|
||||
Rect: [10, 100, 110, 120],
|
||||
AP: { N: appearance },
|
||||
})
|
||||
);
|
||||
doc.catalog.set(PDFName.of('AcroForm'), ctx.obj({ Fields: [widget] }));
|
||||
page.node.set(PDFName.of('Annots'), ctx.obj([secretNote, keepNote, widget]));
|
||||
page.node.set(
|
||||
PDFName.of('Thumb'),
|
||||
ctx.register(ctx.flateStream(SECRET, { Width: 1, Height: 1 }))
|
||||
);
|
||||
|
||||
await doc.attach(
|
||||
new Uint8Array(Buffer.from(`payroll ${SECRET}`)),
|
||||
'payroll.txt',
|
||||
{
|
||||
mimeType: 'text/plain',
|
||||
}
|
||||
);
|
||||
await doc.attach(new Uint8Array(Buffer.from('harmless')), 'readme.txt', {
|
||||
mimeType: 'text/plain',
|
||||
});
|
||||
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
function secret() {
|
||||
return SECRET.toLowerCase();
|
||||
}
|
||||
|
||||
function collectText(value: PDFObject, out: string[]) {
|
||||
if (value instanceof PDFString || value instanceof PDFHexString) {
|
||||
out.push(value.decodeText());
|
||||
} else if (value instanceof PDFDict) {
|
||||
for (const [, v] of value.entries()) collectText(v, out);
|
||||
} else if (value instanceof PDFArray) {
|
||||
for (const v of value.asArray()) collectText(v, out);
|
||||
}
|
||||
}
|
||||
|
||||
async function allText(bytes: Uint8Array): Promise<string> {
|
||||
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
|
||||
const out: string[] = [new TextDecoder('latin1').decode(bytes)];
|
||||
for (const [, object] of doc.context.enumerateIndirectObjects()) {
|
||||
if (object instanceof PDFRawStream) {
|
||||
collectText(object.dict, out);
|
||||
try {
|
||||
out.push(
|
||||
new TextDecoder('latin1').decode(decodePDFRawStream(object).decode())
|
||||
);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
collectText(object, out);
|
||||
}
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
describe('redactDocumentData', () => {
|
||||
it('removes the redacted text from every place outside the page content', async () => {
|
||||
const input = await buildDocument();
|
||||
expect((await allText(input)).toLowerCase()).toContain(
|
||||
SECRET.toLowerCase()
|
||||
);
|
||||
|
||||
const output = await redactDocumentData(input, secret());
|
||||
const text = await allText(output);
|
||||
|
||||
expect(text.toLowerCase()).not.toContain(SECRET.toLowerCase());
|
||||
expect(text).toContain('Keep chapter');
|
||||
expect(text).toContain('keep note');
|
||||
expect(text).toContain('Keep Author');
|
||||
expect(text).toContain('harmless');
|
||||
expect(text).toContain('readme.txt');
|
||||
|
||||
const result = await PDFDocument.load(output, { updateMetadata: false });
|
||||
const annots = result.getPages()[0].node.Annots();
|
||||
expect(annots?.size()).toBe(2);
|
||||
expect(result.getPages()[0].node.get(PDFName.of('Thumb'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('only drops thumbnails and leftovers when no search text is given', async () => {
|
||||
const input = await buildDocument();
|
||||
const output = await redactDocumentData(input);
|
||||
const result = await PDFDocument.load(output, { updateMetadata: false });
|
||||
expect(result.getPages()[0].node.get(PDFName.of('Thumb'))).toBeUndefined();
|
||||
expect(result.getTitle()).toBe(`Report for ${SECRET}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import forge from 'node-forge';
|
||||
import { validateSignature } from '@/js/logic/validate-signature-pdf';
|
||||
|
||||
interface Identity {
|
||||
cert: forge.pki.Certificate;
|
||||
key: forge.pki.rsa.PrivateKey;
|
||||
}
|
||||
|
||||
let serial = 1;
|
||||
|
||||
function makeIdentity(
|
||||
commonName: string,
|
||||
isCa: boolean,
|
||||
issuer?: Identity
|
||||
): Identity {
|
||||
const keys = forge.pki.rsa.generateKeyPair(1024);
|
||||
const cert = forge.pki.createCertificate();
|
||||
cert.publicKey = keys.publicKey;
|
||||
cert.serialNumber = (serial++).toString(16).padStart(2, '0');
|
||||
cert.validity.notBefore = new Date(Date.now() - 86400000);
|
||||
cert.validity.notAfter = new Date(Date.now() + 86400000 * 365);
|
||||
const subject = [{ name: 'commonName', value: commonName }];
|
||||
cert.setSubject(subject);
|
||||
cert.setIssuer(issuer ? issuer.cert.subject.attributes : subject);
|
||||
cert.setExtensions([{ name: 'basicConstraints', cA: isCa }]);
|
||||
cert.sign(issuer ? issuer.key : keys.privateKey, forge.md.sha256.create());
|
||||
return { cert, key: keys.privateKey };
|
||||
}
|
||||
|
||||
function signContent(
|
||||
content: Uint8Array,
|
||||
signer: Identity,
|
||||
bundle: forge.pki.Certificate[]
|
||||
): Uint8Array {
|
||||
const p7 = forge.pkcs7.createSignedData();
|
||||
p7.content = forge.util.createBuffer(
|
||||
String.fromCharCode(...Array.from(content))
|
||||
);
|
||||
for (const cert of bundle) p7.addCertificate(cert);
|
||||
p7.addSigner({
|
||||
key: signer.key,
|
||||
certificate: signer.cert,
|
||||
digestAlgorithm: forge.pki.oids.sha256,
|
||||
authenticatedAttributes: [
|
||||
{ type: forge.pki.oids.contentType, value: forge.pki.oids.data },
|
||||
{ type: forge.pki.oids.messageDigest },
|
||||
],
|
||||
});
|
||||
p7.sign({ detached: true });
|
||||
const der = forge.asn1.toDer(p7.toAsn1()).getBytes();
|
||||
return Uint8Array.from(der, (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function check(
|
||||
signer: Identity,
|
||||
bundle: forge.pki.Certificate[],
|
||||
trusted: forge.pki.Certificate
|
||||
) {
|
||||
const pdfBytes = new TextEncoder().encode('%PDF-1.7 signed content');
|
||||
const contents = signContent(pdfBytes, signer, bundle);
|
||||
return validateSignature(
|
||||
{
|
||||
index: 0,
|
||||
contents,
|
||||
byteRange: [0, pdfBytes.length, pdfBytes.length, 0],
|
||||
},
|
||||
pdfBytes,
|
||||
trusted
|
||||
);
|
||||
}
|
||||
|
||||
describe('validateSignature trust chain', () => {
|
||||
const root = makeIdentity('Company Root CA', true);
|
||||
const intermediate = makeIdentity('Company Issuing CA', true, root);
|
||||
const employee = makeIdentity('Real Employee', false, root);
|
||||
const chainedEmployee = makeIdentity('Chained Employee', false, intermediate);
|
||||
const attacker = makeIdentity('John Smith', false);
|
||||
|
||||
it('does not trust a self-made signer bundled with an unrelated company certificate', async () => {
|
||||
const result = await check(
|
||||
attacker,
|
||||
[attacker.cert, employee.cert],
|
||||
root.cert
|
||||
);
|
||||
expect(result.signerName).toBe('John Smith');
|
||||
expect(result.cryptoVerified).toBe(true);
|
||||
expect(result.isTrusted).toBe(false);
|
||||
});
|
||||
|
||||
it('identifies the real signer even when another certificate is listed first', async () => {
|
||||
const result = await check(
|
||||
attacker,
|
||||
[employee.cert, attacker.cert],
|
||||
root.cert
|
||||
);
|
||||
expect(result.signerName).toBe('John Smith');
|
||||
expect(result.cryptoVerified).toBe(true);
|
||||
expect(result.isTrusted).toBe(false);
|
||||
});
|
||||
|
||||
it('trusts a signer issued directly by the trusted certificate', async () => {
|
||||
const result = await check(employee, [employee.cert], root.cert);
|
||||
expect(result.cryptoVerified).toBe(true);
|
||||
expect(result.isTrusted).toBe(true);
|
||||
});
|
||||
|
||||
it('trusts a signer that chains to the trusted certificate through an intermediate CA', async () => {
|
||||
const result = await check(
|
||||
chainedEmployee,
|
||||
[chainedEmployee.cert, intermediate.cert],
|
||||
root.cert
|
||||
);
|
||||
expect(result.cryptoVerified).toBe(true);
|
||||
expect(result.isTrusted).toBe(true);
|
||||
});
|
||||
|
||||
it('does not accept a non-CA certificate as an intermediate', async () => {
|
||||
const issuedByLeaf = makeIdentity('Issued By Leaf', false, employee);
|
||||
const result = await check(
|
||||
issuedByLeaf,
|
||||
[issuedByLeaf.cert, employee.cert],
|
||||
root.cert
|
||||
);
|
||||
expect(result.isTrusted).toBe(false);
|
||||
});
|
||||
|
||||
it('trusts a signer whose own certificate is the trusted certificate', async () => {
|
||||
const result = await check(attacker, [attacker.cert], attacker.cert);
|
||||
expect(result.isTrusted).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user