mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-10-20 15:21:27 +02:00
feat: update GitHub Actions workflow for PDF engine and viewer
This commit is contained in:
@@ -11,7 +11,6 @@ permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
cla-check:
|
||||
@@ -28,10 +27,9 @@ jobs:
|
||||
)
|
||||
steps:
|
||||
- name: "CLA Assistant"
|
||||
uses: contributor-assistant/github-action@v2.6.1
|
||||
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_ASSISTANT_TOKEN }}
|
||||
with:
|
||||
path-to-signatures: 'signatures/cla.json'
|
||||
path-to-document: 'https://github.com/${{ github.repository }}/blob/main/ICLA.md'
|
||||
|
||||
@@ -6,8 +6,7 @@ on:
|
||||
- cron: '17 4 * * *'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: update-engine
|
||||
@@ -18,12 +17,19 @@ env:
|
||||
HUSKY: 0
|
||||
|
||||
jobs:
|
||||
update:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'alam00000/bentopdf'
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
run: ${{ steps.gate.outputs.run }}
|
||||
version: ${{ steps.gate.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Read current upstream marker
|
||||
id: current
|
||||
@@ -40,13 +46,21 @@ jobs:
|
||||
|
||||
- name: Should update?
|
||||
id: gate
|
||||
env:
|
||||
CURRENT: ${{ steps.current.outputs.version }}
|
||||
UPSTREAM: ${{ steps.upstream.outputs.version }}
|
||||
run: |
|
||||
if [ "${{ steps.upstream.outputs.version }}" = "${{ steps.current.outputs.version }}" ]; then
|
||||
if ! [[ "$UPSTREAM" =~ ^[0-9a-f]{12}$ ]]; then
|
||||
echo "::error::Unexpected fork HEAD: $UPSTREAM"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$UPSTREAM" >> "$GITHUB_OUTPUT"
|
||||
if [ "$UPSTREAM" = "$CURRENT" ]; then
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Already on ${{ steps.current.outputs.version }}."
|
||||
echo "Already on $CURRENT."
|
||||
else
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Updating ${{ steps.current.outputs.version }} -> ${{ steps.upstream.outputs.version }}"
|
||||
echo "Updating $CURRENT -> $UPSTREAM"
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
@@ -73,9 +87,10 @@ jobs:
|
||||
- name: Pack the engine
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
id: engine
|
||||
env:
|
||||
SHA: ${{ steps.gate.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHA=${{ steps.upstream.outputs.version }}
|
||||
VENDOR=./_fork/packages/pdfium/src/vendor
|
||||
STAGE=$(mktemp -d)/package
|
||||
mkdir -p "$STAGE"
|
||||
@@ -107,9 +122,10 @@ jobs:
|
||||
- name: Pack the viewer without a second engine
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
id: viewer
|
||||
env:
|
||||
SHA: ${{ steps.gate.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHA=${{ steps.upstream.outputs.version }}
|
||||
WORK=$(mktemp -d)
|
||||
(cd ./_fork && npm pack ./viewers/snippet --pack-destination "$WORK" >/dev/null)
|
||||
TMP=$(mktemp -d)
|
||||
@@ -137,14 +153,17 @@ jobs:
|
||||
|
||||
- name: Point dependencies at the new tarballs
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
env:
|
||||
ENGINE_TARBALL: ${{ steps.engine.outputs.tarball }}
|
||||
VIEWER_TARBALL: ${{ steps.viewer.outputs.tarball }}
|
||||
run: |
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||
pkg.dependencies['bentopdf-pdfium'] = 'file:' + process.argv[1];
|
||||
pkg.dependencies['bentopdf-viewer'] = 'file:' + process.argv[2];
|
||||
pkg.dependencies['bentopdf-pdfium'] = 'file:' + process.env.ENGINE_TARBALL;
|
||||
pkg.dependencies['bentopdf-viewer'] = 'file:' + process.env.VIEWER_TARBALL;
|
||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
" "${{ steps.engine.outputs.tarball }}" "${{ steps.viewer.outputs.tarball }}"
|
||||
"
|
||||
|
||||
- name: Cleanup upstream clone
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
@@ -174,19 +193,66 @@ jobs:
|
||||
|
||||
- name: Write upstream marker
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
env:
|
||||
SHA: ${{ steps.gate.outputs.version }}
|
||||
run: |
|
||||
echo "${{ steps.upstream.outputs.version }}" > vendor/bentopdf-viewer/.upstream-version
|
||||
echo "${{ steps.upstream.outputs.version }}" > vendor/bentopdf-pdfium/.upstream-version
|
||||
echo "$SHA" > vendor/bentopdf-viewer/.upstream-version
|
||||
echo "$SHA" > vendor/bentopdf-pdfium/.upstream-version
|
||||
|
||||
- name: Upload update
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: engine-update
|
||||
include-hidden-files: true
|
||||
retention-days: 1
|
||||
path: |
|
||||
package.json
|
||||
package-lock.json
|
||||
vendor/bentopdf-pdfium/
|
||||
vendor/bentopdf-viewer/
|
||||
|
||||
pull-request:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: needs.build.outputs.run == 'true'
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download update
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: engine-update
|
||||
path: ${{ runner.temp }}/engine-update
|
||||
|
||||
- name: Apply update
|
||||
id: apply
|
||||
env:
|
||||
UPDATE: ${{ runner.temp }}/engine-update
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf vendor/bentopdf-pdfium vendor/bentopdf-viewer
|
||||
mkdir -p vendor
|
||||
cp -R "$UPDATE/vendor/bentopdf-pdfium" "$UPDATE/vendor/bentopdf-viewer" vendor/
|
||||
cp "$UPDATE/package.json" "$UPDATE/package-lock.json" .
|
||||
{
|
||||
echo 'checksums<<EOF'
|
||||
sha256sum vendor/bentopdf-pdfium/*.tgz vendor/bentopdf-viewer/*.tgz
|
||||
echo 'EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
|
||||
with:
|
||||
commit-message: 'build(deps): bump PDF engine and viewer to ${{ steps.upstream.outputs.version }}'
|
||||
title: 'build(deps): bump PDF engine and viewer to ${{ steps.upstream.outputs.version }}'
|
||||
commit-message: 'build(deps): bump PDF engine and viewer to ${{ needs.build.outputs.version }}'
|
||||
title: 'build(deps): bump PDF engine and viewer to ${{ needs.build.outputs.version }}'
|
||||
body: |
|
||||
Both vendored packages built from one clone of the viewer fork at
|
||||
`${{ steps.upstream.outputs.version }}`, so the text editor and the
|
||||
`${{ needs.build.outputs.version }}`, so the text editor and the
|
||||
PDF editor run the same engine.
|
||||
|
||||
- `bentopdf-pdfium` — the engine wasm, packed from the fork's published `src/vendor`
|
||||
@@ -194,5 +260,11 @@ jobs:
|
||||
|
||||
The viewer loads the shared engine through `EmbedPDF.init({ wasmUrl })`.
|
||||
CI verified a single wasm on disk, typecheck, and the test suite.
|
||||
|
||||
Built in a read-only job. Tarball checksums:
|
||||
|
||||
```
|
||||
${{ steps.apply.outputs.checksums }}
|
||||
```
|
||||
branch: chore/update-pdf-engine
|
||||
delete-branch: true
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ http {
|
||||
if ($request_uri ~ ^/en/?(\?.*)?$) {
|
||||
return 301 /$1;
|
||||
}
|
||||
if ($request_uri ~ ^/en/(.+)$) {
|
||||
if ($request_uri ~ ^/en/([A-Za-z0-9].*)$) {
|
||||
return 301 /$1;
|
||||
}
|
||||
|
||||
|
||||
+24
-25
@@ -5,7 +5,7 @@
|
||||
* Version: 1.1.0
|
||||
*/
|
||||
|
||||
const CACHE_VERSION = 'bentopdf-v12';
|
||||
const CACHE_VERSION = 'bentopdf-v13';
|
||||
const CACHE_NAME = `${CACHE_VERSION}-static`;
|
||||
|
||||
const trustedCdnOrigins = new Set(['https://cdn.jsdelivr.net']);
|
||||
@@ -126,7 +126,7 @@ async function cacheFirstStrategyWithDedup(request, isCDN) {
|
||||
const fileName = url.pathname.split('/').pop();
|
||||
|
||||
try {
|
||||
const cachedResponse = await findCachedFile(fileName, request.url);
|
||||
const cachedResponse = await findCachedFile(request.url);
|
||||
if (cachedResponse) {
|
||||
// console.log('⚡ [Cache HIT] Instant load:', fileName);
|
||||
return cachedResponse;
|
||||
@@ -136,7 +136,11 @@ async function cacheFirstStrategyWithDedup(request, isCDN) {
|
||||
|
||||
const networkResponse = await fetch(request);
|
||||
|
||||
if (networkResponse && networkResponse.status === 200) {
|
||||
if (
|
||||
networkResponse &&
|
||||
networkResponse.status === 200 &&
|
||||
!networkResponse.redirected
|
||||
) {
|
||||
const clone = networkResponse.clone();
|
||||
const buffer = await clone.arrayBuffer();
|
||||
if (buffer.byteLength > 0) {
|
||||
@@ -163,8 +167,18 @@ async function cacheFirstStrategyWithDedup(request, isCDN) {
|
||||
if (localPath) {
|
||||
const localUrl = `${basePath}${localPath}${fileName}`;
|
||||
try {
|
||||
const cachedLocal = await findCachedFile(
|
||||
new URL(localUrl, location.origin).href
|
||||
);
|
||||
if (cachedLocal) {
|
||||
return cachedLocal;
|
||||
}
|
||||
const fallbackResponse = await fetch(localUrl);
|
||||
if (fallbackResponse && fallbackResponse.status === 200) {
|
||||
if (
|
||||
fallbackResponse &&
|
||||
fallbackResponse.status === 200 &&
|
||||
!fallbackResponse.redirected
|
||||
) {
|
||||
const fbClone = fallbackResponse.clone();
|
||||
const fbBuffer = await fbClone.arrayBuffer();
|
||||
if (fbBuffer.byteLength > 0) {
|
||||
@@ -192,7 +206,7 @@ async function cacheFirstStrategyWithDedup(request, isCDN) {
|
||||
}
|
||||
}
|
||||
|
||||
async function findCachedFile(fileName, requestUrl) {
|
||||
async function findCachedFile(requestUrl) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
|
||||
const exactMatch = await cache.match(requestUrl);
|
||||
@@ -204,25 +218,6 @@ async function findCachedFile(fileName, requestUrl) {
|
||||
}
|
||||
await cache.delete(requestUrl);
|
||||
}
|
||||
|
||||
const requests = await cache.keys();
|
||||
for (const req of requests) {
|
||||
const reqUrl = new URL(req.url);
|
||||
if (
|
||||
reqUrl.origin === location.origin &&
|
||||
reqUrl.pathname.split('/').pop() === fileName
|
||||
) {
|
||||
const response = await cache.match(req);
|
||||
if (response) {
|
||||
const clone = response.clone();
|
||||
const buffer = await clone.arrayBuffer();
|
||||
if (buffer.byteLength > 0) {
|
||||
return response;
|
||||
}
|
||||
await cache.delete(req);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -248,7 +243,11 @@ async function networkFirstStrategy(request) {
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
|
||||
if (networkResponse && networkResponse.status === 200) {
|
||||
if (
|
||||
networkResponse &&
|
||||
networkResponse.status === 200 &&
|
||||
!networkResponse.redirected
|
||||
) {
|
||||
const clone = networkResponse.clone();
|
||||
const buffer = await clone.arrayBuffer();
|
||||
if (buffer.byteLength > 0) {
|
||||
|
||||
@@ -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