From 7274bcc70102d9d505f61876bdbe79b36b119f76 Mon Sep 17 00:00:00 2001 From: silverstein Date: Tue, 20 Oct 2026 21:32:43 +0000 Subject: [PATCH 01/13] Add private local PDF password entry service --- server/private-pdf-credentials.js | 375 +++++++++++++++++++++++++++ test/private-pdf-credentials.test.js | 304 ++++++++++++++++++++++ 2 files changed, 679 insertions(+) create mode 100644 server/private-pdf-credentials.js create mode 100644 test/private-pdf-credentials.test.js diff --git a/server/private-pdf-credentials.js b/server/private-pdf-credentials.js new file mode 100644 index 0000000..9389d3f --- /dev/null +++ b/server/private-pdf-credentials.js @@ -0,0 +1,375 @@ +import http from "node:http"; +import path from "node:path"; +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +const CREDENTIAL_TTL_MS = 10 * 60_000; +const WINDOW_TTL_MS = 5 * 60_000; +const MAX_DOCUMENTS = 8; +const MAX_ATTEMPTS = 5; +const MAX_BODY_BYTES = 4096; +const MAX_PASSWORD_BYTES = 1024; +const VALIDATION_TIMEOUT_MS = 30_000; + +const PAGE_SCRIPT = ` +(() => { + let token = location.hash.slice(1); + history.replaceState(null, "", location.pathname); + const message = document.getElementById("message"); + const password = document.getElementById("password"); + const unlock = document.getElementById("unlock"); + let pending = false; + async function send(endpoint, values = {}) { + const response = await fetch(endpoint, { method: "POST", credentials: "omit", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, ...values }) }); + return { ok: response.ok, data: await response.json() }; + } + async function refresh() { + try { + const result = await send("/session"); + if (!result.ok) { message.textContent = "This unlock window has expired. Request a new window in PDF Tools."; unlock.disabled = true; password.value = ""; return; } + document.getElementById("document").textContent = result.data.filename; + document.getElementById("source").textContent = "Document fingerprint: " + result.data.source_sha256; + message.textContent = result.data.status === "unlocked" + ? "Unlocked for this PDF Tools session. Access expires automatically after 10 minutes. You may close this window or lock the document now." + : "Enter this document's password. It stays in PDF Tools on this computer and is not sent to chat."; + unlock.disabled = result.data.status === "unlocked"; + } catch { message.textContent = "PDF Tools is no longer available. You can close this window."; unlock.disabled = true; } + } + document.getElementById("form").addEventListener("submit", async event => { + event.preventDefault(); + if (pending || unlock.disabled) return; + pending = true; unlock.disabled = true; + let value = password.value; password.value = ""; + try { + const promise = send("/unlock", { password: value }); value = ""; + const result = await promise; + message.textContent = result.ok ? "Document unlocked. You can return to PDF Tools." + : "The document could not be unlocked. Check the password or request a new unlock window in PDF Tools."; + unlock.disabled = result.ok; + } catch { message.textContent = "PDF Tools is no longer available. You can close this window."; } + finally { value = ""; pending = false; } + }); + for (const action of ["lock", "cancel"]) document.getElementById(action).addEventListener("click", async () => { + password.value = ""; unlock.disabled = true; + try { await send("/" + action); } catch {} + token = ""; + message.textContent = "Document locked. You can close this window."; + }); + addEventListener("pagehide", () => { password.value = ""; token = ""; }); + refresh(); +})();`; + +const PAGE = `Unlock PDF in PDF Tools

Unlock your PDF

PDF Tools

Opening private password entry...

Keep this window private. Access is limited to this document and ends when PDF Tools closes.

`; +const SCRIPT_SHA = createHash("sha256").update(PAGE_SCRIPT).digest("base64"); + +function fixedError(code) { + const error = new Error("Private PDF access is unavailable. Request a new unlock window in PDF Tools."); + error.code = code; + return error; +} + +function sourceIdentity(source) { + if (!source || typeof source.canonical_path !== "string" || !path.isAbsolute(source.canonical_path) || + !/^[a-f0-9]{64}$/.test(source.sha256) || !Number.isSafeInteger(source.size_bytes) || source.size_bytes < 0 || + !source.file_identity || !["number", "string", "bigint"].includes(typeof source.file_identity.device) || + !["number", "string", "bigint"].includes(typeof source.file_identity.inode)) { + throw fixedError("PRIVATE_PDF_SOURCE_INVALID"); + } + return JSON.stringify([source.canonical_path, String(source.file_identity.device), + String(source.file_identity.inode), source.sha256, source.size_bytes]); +} + +function tokenMatches(expected, actual) { + return typeof actual === "string" && /^[a-f0-9]{64}$/.test(actual) && + timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(actual, "hex")); +} + +function exactKeys(value, names) { + return value && typeof value === "object" && !Array.isArray(value) && + Object.keys(value).sort().join(",") === names.sort().join(","); +} + +// The injected callbacks are local trusted code. The service does not establish +// authenticity against another process running as the same OS user or root. +// Passwords stay in JavaScript memory; GC and OS swap do not guarantee erasure. +export function createPrivatePdfCredentials({ bindSource, validatePassword, openWindow, now = Date.now }) { + if (![bindSource, validatePassword, openWindow, now].every(value => typeof value === "function")) { + throw fixedError("PRIVATE_PDF_CONFIGURATION_INVALID"); + } + const documents = new Map(); + const windows = new Map(); + let server; + let starting; + let origin; + let closed = false; + let pendingBindings = 0; + let validating = false; + let attemptWindowStart = now(); + let globalAttempts = 0; + let cancelValidation; + + function clearDocument(entry, status = "locked") { + entry.password = undefined; + entry.expiresAt = 0; + entry.status = status; + entry.generation += 1; + if (cancelValidation?.entry === entry) cancelValidation.clear(); + for (const [token, session] of windows) if (session.entry === entry) windows.delete(token); + } + + function prune() { + const time = now(); + for (const entry of documents.values()) { + if (entry.password !== undefined && entry.expiresAt <= time) clearDocument(entry, "expired"); + } + for (const [token, session] of windows) { + if (session.expiresAt <= time) { + windows.delete(token); + if (session.entry.status === "awaiting_password") clearDocument(session.entry, "expired"); + } + } + } + + function result(entry) { + return entry?.password !== undefined + ? { status: "unlocked", expires_at: new Date(entry.expiresAt).toISOString() } + : { status: entry?.status ?? "locked" }; + } + + async function bind(pdfPath) { + if (closed || pendingBindings >= MAX_DOCUMENTS) throw fixedError("PRIVATE_PDF_UNAVAILABLE"); + pendingBindings += 1; + try { + const source = await bindSource(pdfPath); + if (closed) throw fixedError("PRIVATE_PDF_UNAVAILABLE"); + sourceIdentity(source); + return { ...source, file_identity: { ...source.file_identity } }; + } catch { + throw fixedError("PRIVATE_PDF_SOURCE_UNAVAILABLE"); + } finally { pendingBindings -= 1; } + } + + function lookup(source) { + prune(); + const key = sourceIdentity(source); + const entry = documents.get(source.canonical_path); + if (entry && entry.key !== key) { + clearDocument(entry); + documents.delete(source.canonical_path); + return undefined; + } + return entry; + } + + function respond(response, statusCode, value) { + response.writeHead(statusCode, { + "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", + "Pragma": "no-cache", "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'", + "Cross-Origin-Resource-Policy": "same-origin", "Connection": "close", + }); + response.end(JSON.stringify(value)); + } + + async function readBody(request) { + return new Promise((resolve, reject) => { + let bytes = 0; + const chunks = []; + const timer = setTimeout(() => { chunks.length = 0; reject(fixedError("PRIVATE_PDF_REQUEST_INVALID")); }, 5000); + const cleanup = () => clearTimeout(timer); + request.on("data", chunk => { + bytes += chunk.length; + if (bytes > MAX_BODY_BYTES) { chunks.length = 0; cleanup(); reject(fixedError("PRIVATE_PDF_REQUEST_INVALID")); } + else chunks.push(chunk); + }); + request.on("end", () => { + cleanup(); + try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } + catch { reject(fixedError("PRIVATE_PDF_REQUEST_INVALID")); } + finally { chunks.length = 0; } + }); + request.on("error", () => { cleanup(); chunks.length = 0; reject(fixedError("PRIVATE_PDF_REQUEST_INVALID")); }); + request.on("aborted", () => { cleanup(); chunks.length = 0; reject(fixedError("PRIVATE_PDF_REQUEST_INVALID")); }); + }); + } + + async function handle(request, response) { + const denied = (code = 403) => respond(response, code, { error: "Private PDF request rejected." }); + if (closed || request.socket.remoteAddress !== "127.0.0.1" || request.headers.host !== new URL(origin).host || + request.rawHeaders.filter((value, index) => index % 2 === 0 && value.toLowerCase() === "host").length !== 1) return denied(); + if (request.method === "GET" && request.url === "/") { + if (request.headers["sec-fetch-dest"] === "iframe") return denied(); + response.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "Pragma": "no-cache", + "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", + "Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Resource-Policy": "same-origin", + "Content-Security-Policy": `default-src 'none'; script-src 'sha256-${SCRIPT_SHA}'; style-src 'unsafe-inline'; connect-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'`, + }); + response.end(PAGE); + return; + } + if (request.method !== "POST" || !["/session", "/unlock", "/lock", "/cancel"].includes(request.url) || + request.headers.origin !== origin || !/^application\/json(?:;\s*charset=utf-8)?$/i.test(request.headers["content-type"] ?? "") || + Number(request.headers["content-length"] ?? 0) > MAX_BODY_BYTES) return denied(); + let body; + try { body = await readBody(request); } catch { return denied(400); } + prune(); + if (!exactKeys(body, request.url === "/unlock" ? ["token", "password"] : ["token"])) return denied(400); + const session = [...windows.values()].find(item => tokenMatches(item.token, body.token)); + if (!session || closed) return denied(); + const entry = session.entry; + if (request.url === "/lock" || request.url === "/cancel") { + clearDocument(entry); + return respond(response, 200, { status: "locked" }); + } + if (request.url === "/session") { + try { + if (lookup(await bind(entry.source.canonical_path)) !== entry) return denied(); + } catch { clearDocument(entry); return denied(); } + return respond(response, 200, { ...result(entry), filename: path.basename(entry.source.canonical_path), source_sha256: entry.source.sha256 }); + } + if (typeof body.password !== "string" || body.password.length === 0 || Buffer.byteLength(body.password, "utf8") > MAX_PASSWORD_BYTES) return denied(400); + if (entry.password !== undefined || validating || session.validating || session.attempts >= MAX_ATTEMPTS) return denied(409); + if (now() - attemptWindowStart >= 60_000) { attemptWindowStart = now(); globalAttempts = 0; } + if (globalAttempts >= 20) return denied(429); + session.attempts += 1; + globalAttempts += 1; + validating = true; + session.validating = true; + const generation = entry.generation; + let password = body.password; + body.password = undefined; + const cancellation = { entry, clear: () => { password = undefined; } }; + cancelValidation = cancellation; + let timer; + let timedOut = false; + // Retain the global validation lock until the actual callback ends, even if + // its HTTP timeout wins. A hung callback cannot create unbounded workers. + const work = (async () => { + const before = await bind(entry.source.canonical_path); + if (sourceIdentity(before) !== entry.key) { clearDocument(entry); return false; } + if (closed || generation !== entry.generation || windows.get(session.token) !== session || password === undefined) return false; + const valid = await validatePassword(before, password); + const after = await bind(entry.source.canonical_path); + if (sourceIdentity(after) !== entry.key) { clearDocument(entry); return false; } + return valid === true; + })(); + work.catch(() => {}).finally(() => { validating = false; session.validating = false; }); + try { + const valid = await Promise.race([work, new Promise(resolve => { + timer = setTimeout(() => { timedOut = true; resolve(false); }, VALIDATION_TIMEOUT_MS); + })]); + prune(); + if (!valid || timedOut || closed || generation !== entry.generation || windows.get(session.token) !== session || + documents.get(entry.source.canonical_path) !== entry || session.expiresAt <= now()) return denied(); + entry.password = password; + entry.expiresAt = now() + CREDENTIAL_TTL_MS; + entry.status = "unlocked"; + return respond(response, 200, result(entry)); + } catch { return denied(); } + finally { + clearTimeout(timer); + password = undefined; + if (cancelValidation === cancellation) cancelValidation = undefined; + } + } + + async function ensureServer() { + if (closed) throw fixedError("PRIVATE_PDF_UNAVAILABLE"); + if (starting) return starting; + starting = new Promise((resolve, reject) => { + server = http.createServer((request, response) => { + handle(request, response).catch(() => { + if (!response.headersSent) respond(response, 500, { error: "Private PDF request rejected." }); + else response.destroy(); + }); + }); + server.maxConnections = 16; + server.requestTimeout = 10_000; + server.headersTimeout = 5000; + server.keepAliveTimeout = 1000; + server.on("clientError", (_error, socket) => socket.destroy()); + server.once("error", () => reject(fixedError("PRIVATE_PDF_UNAVAILABLE"))); + server.listen(0, "127.0.0.1", () => { + if (closed) { server.close(); reject(fixedError("PRIVATE_PDF_UNAVAILABLE")); return; } + origin = `http://127.0.0.1:${server.address().port}`; + server.unref(); + resolve(); + }); + }); + try { await starting; } catch { starting = undefined; throw fixedError("PRIVATE_PDF_UNAVAILABLE"); } + } + + const expiryTimer = setInterval(prune, 1000); + expiryTimer.unref(); + + function passwordForSource(source) { + if (closed) return undefined; + return lookup(source)?.password; + } + + return { + async requestUnlock(pdfPath) { + const source = await bind(pdfPath); + let entry = lookup(source); + if (entry?.password !== undefined) return result(entry); + if (!entry) { + // Locked/expired records carry no secret and can be evicted to admit a + // new document. Active credentials and windows count against the cap. + for (const [name, item] of documents) { + if (documents.size < MAX_DOCUMENTS) break; + if (!item.password && item.status !== "awaiting_password") documents.delete(name); + } + if (documents.size >= MAX_DOCUMENTS) throw fixedError("PRIVATE_PDF_CAPACITY_REACHED"); + entry = { source, key: sourceIdentity(source), password: undefined, expiresAt: 0, generation: 0, status: "locked" }; + documents.set(source.canonical_path, entry); + } + clearDocument(entry); + const generation = entry.generation; + await ensureServer(); + if (closed || entry.generation !== generation || documents.get(source.canonical_path) !== entry) throw fixedError("PRIVATE_PDF_UNAVAILABLE"); + const token = randomBytes(32).toString("hex"); + const session = { token, entry, expiresAt: now() + WINDOW_TTL_MS, attempts: 0, validating: false }; + windows.set(token, session); + entry.status = "awaiting_password"; + try { await openWindow(`${origin}/#${token}`); } + catch { + if (windows.get(token) === session) clearDocument(entry); + throw fixedError("PRIVATE_PDF_WINDOW_UNAVAILABLE"); + } + if (closed || windows.get(token) !== session) throw fixedError("PRIVATE_PDF_UNAVAILABLE"); + return result(entry); + }, + async status(pdfPath) { + const source = await bind(pdfPath); + return result(lookup(source)); + }, + async lock(pdfPath) { + // Invalidate before any asynchronous source operation. A concurrent + // validation cannot restore a credential after the lock starts. + const direct = documents.get(pdfPath); + if (direct) clearDocument(direct); + const source = await bind(pdfPath); + const entry = documents.get(source.canonical_path); + if (entry) clearDocument(entry); + return { status: "locked" }; + }, + passwordForSource, + passwordForSources(sources) { + if (!Array.isArray(sources) || sources.length === 0) return undefined; + const passwords = sources.map(source => passwordForSource(source)); + if (passwords.every(value => value === undefined)) return undefined; + if (passwords.some(value => value === undefined || value !== passwords[0])) throw fixedError("PRIVATE_PDF_MULTIPLE_DOCUMENTS_LOCKED"); + return passwords[0]; + }, + async close() { + closed = true; + clearInterval(expiryTimer); + for (const entry of documents.values()) clearDocument(entry); + documents.clear(); windows.clear(); + if (server) await new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }); + }, + }; +} diff --git a/test/private-pdf-credentials.test.js b/test/private-pdf-credentials.test.js new file mode 100644 index 0000000..1120362 --- /dev/null +++ b/test/private-pdf-credentials.test.js @@ -0,0 +1,304 @@ +import http from "node:http"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createPrivatePdfCredentials } from "../server/private-pdf-credentials.js"; + +const activeServices = new Set(); +const SECRET = "synthetic-test-password"; + +function source(name = "one", change = {}) { + return { canonical_path: `/synthetic/${name}.pdf`, file_identity: { device: 1, inode: name }, + sha256: "a".repeat(64), size_bytes: 100, ...change }; +} + +function deferred() { + let resolve; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +function fixture(options = {}) { + let clock = 100_000; + const sources = new Map([["/synthetic/one.pdf", source()]]); + const urls = []; + const validator = vi.fn(async (_source, password) => password === SECRET); + const service = createPrivatePdfCredentials({ + bindSource: async name => { + const value = sources.get(name); + if (!value) throw new Error("sensitive source details"); + return structuredClone(value); + }, + validatePassword: validator, + openWindow: async url => { urls.push(new URL(url)); }, + now: () => clock, + ...options, + }); + activeServices.add(service); + return { service, sources, urls, validator, advance: milliseconds => { clock += milliseconds; }, + async open(name = "/synthetic/one.pdf") { await service.requestUnlock(name); return urls.at(-1); } }; +} + +async function post(url, route = "/unlock", values = { password: SECRET }, options = {}) { + const response = await fetch(`${url.origin}${route}`, { + method: "POST", headers: { Origin: url.origin, "Content-Type": "application/json", ...options.headers }, + body: JSON.stringify({ token: url.hash.slice(1), ...values }), ...options, + }); + return { status: response.status, body: await response.json() }; +} + +afterEach(async () => { + await Promise.all([...activeServices].map(service => service.close())); + activeServices.clear(); +}); + +describe("private PDF password entry", () => { + it("opens only a loopback capability window and returns password-free public status", async () => { + const item = fixture(); + const pending = await item.service.requestUnlock("/synthetic/one.pdf"); + const url = item.urls[0]; + expect(url.hostname).toBe("127.0.0.1"); + expect(url.hash).toMatch(/^#[a-f0-9]{64}$/); + expect(pending).toEqual({ status: "awaiting_password" }); + const shell = await fetch(url.origin); + const html = await shell.text(); + expect(shell.headers.get("content-security-policy")).toContain("frame-ancestors 'none'"); + expect(shell.headers.get("cache-control")).toBe("no-store"); + expect(html).toContain('history.replaceState(null, "", location.pathname)'); + expect(html).toContain('type="password"'); + expect(html).not.toContain(url.hash.slice(1)); + expect(html).not.toContain("one.pdf"); + const unlocked = await post(url); + expect(unlocked.status).toBe(200); + expect(unlocked.body).toEqual({ status: "unlocked", expires_at: new Date(700_000).toISOString() }); + expect(await item.service.status("/synthetic/one.pdf")).toEqual(unlocked.body); + expect(item.service.passwordForSource(source())).toBe(SECRET); + expect(item.validator).toHaveBeenCalledWith(source(), SECRET); + expect(JSON.stringify([pending, unlocked, html])).not.toContain(SECRET); + }); + + it("exposes source identity only after capability authentication", async () => { + const item = fixture(); + const url = await item.open(); + expect(await post(url, "/session", {})).toEqual({ status: 200, body: { + status: "awaiting_password", filename: "one.pdf", source_sha256: "a".repeat(64), + } }); + expect((await post(url, "/session", { token: "f".repeat(64) })).status).toBe(403); + expect((await fetch(`${url.origin}/session`)).status).toBe(403); + }); + + it.each([ + ["foreign Origin", { Origin: "https://example.invalid", "Content-Type": "application/json" }], + ["missing Origin", { "Content-Type": "application/json" }], + ["form content type", { Origin: "bad", "Content-Type": "application/x-www-form-urlencoded" }], + ])("rejects %s before validator execution", async (_name, headers) => { + const item = fixture(); + const url = await item.open(); + expect((await post(url, "/unlock", { password: SECRET }, { headers })).status).toBe(403); + expect(item.validator).not.toHaveBeenCalled(); + }); + + it("rejects forged Host even with a correct capability and Origin", async () => { + const item = fixture(); + const url = await item.open(); + const status = await new Promise((resolve, reject) => { + const request = http.request({ hostname: "127.0.0.1", port: url.port, path: "/unlock", method: "POST", + headers: { Host: `localhost:${url.port}`, Origin: url.origin, "Content-Type": "application/json" } }, response => { + response.resume(); response.on("end", () => resolve(response.statusCode)); + }); + request.on("error", reject); + request.end(JSON.stringify({ token: url.hash.slice(1), password: SECRET })); + }); + expect(status).toBe(403); + expect(item.validator).not.toHaveBeenCalled(); + }); + + it("rejects bad capabilities, extra fields, password limits and oversized bodies", async () => { + const item = fixture(); + const url = await item.open(); + expect((await post(url, "/unlock", { password: SECRET, token: "f".repeat(64) })).status).toBe(403); + expect((await post(url, "/unlock", { password: SECRET, source: "other.pdf" })).status).toBe(400); + expect((await post(url, "/unlock", { password: "" })).status).toBe(400); + expect((await post(url, "/unlock", { password: "a".repeat(1025) })).status).toBe(400); + expect((await post(url, "/unlock", { password: "๐Ÿงช".repeat(300) })).status).toBe(400); + expect((await post(url, "/unlock", { password: "a".repeat(5000) })).status).toBe(403); + expect(item.validator).not.toHaveBeenCalled(); + }); + + it("limits wrong-password attempts and never echoes callback errors", async () => { + const item = fixture({ validatePassword: async () => { throw new Error(SECRET); } }); + const url = await item.open(); + for (let count = 0; count < 5; count += 1) { + const response = await post(url); + expect(response.status).toBe(403); + expect(JSON.stringify(response)).not.toContain(SECRET); + } + expect((await post(url)).status).toBe(409); + expect(item.service.passwordForSource(source())).toBeUndefined(); + }); + + it("allows a corrected password after an ordinary wrong password", async () => { + const item = fixture(); + const url = await item.open(); + expect((await post(url, "/unlock", { password: "wrong" })).status).toBe(403); + expect((await post(url)).status).toBe(200); + expect((await post(url)).status).toBe(409); + expect(item.validator).toHaveBeenCalledTimes(2); + }); + + it.each(["sha256", "inode", "size_bytes"])("invalidates changed source %s before use", async field => { + const item = fixture(); + const url = await item.open(); + await post(url); + const changed = source(); + if (field === "inode") changed.file_identity.inode = "replacement"; + else changed[field] = field === "sha256" ? "b".repeat(64) : 101; + item.sources.set(changed.canonical_path, changed); + expect(item.service.passwordForSource(changed)).toBeUndefined(); + expect(item.service.passwordForSource(source())).toBeUndefined(); + expect((await post(url, "/session", {})).status).toBe(403); + }); + + it("rechecks exact source after validation before installing any password", async () => { + let item; + item = fixture({ validatePassword: async () => { + item.sources.set("/synthetic/one.pdf", source("one", { sha256: "b".repeat(64) })); + return true; + } }); + const url = await item.open(); + expect((await post(url)).status).toBe(403); + expect(item.service.passwordForSource(source())).toBeUndefined(); + }); + + it("rejects source replacement before calling the password validator", async () => { + const item = fixture(); + const url = await item.open(); + item.sources.set("/synthetic/one.pdf", source("one", { sha256: "b".repeat(64) })); + expect((await post(url)).status).toBe(403); + expect(item.validator).not.toHaveBeenCalled(); + expect(item.service.passwordForSource(source())).toBeUndefined(); + }); + + it("expires the window and the credential at their separate fixed deadlines", async () => { + const item = fixture(); + const expiredUrl = await item.open(); + item.advance(5 * 60_000); + expect((await post(expiredUrl)).status).toBe(403); + expect(await item.service.status("/synthetic/one.pdf")).toEqual({ status: "expired" }); + const url = await item.open(); + await post(url); + item.advance(5 * 60_000); + expect(item.service.passwordForSource(source())).toBe(SECRET); + expect((await post(url, "/session", {})).status).toBe(403); + item.advance(5 * 60_000); + expect(item.service.passwordForSource(source())).toBeUndefined(); + expect(await item.service.status("/synthetic/one.pdf")).toEqual({ status: "expired" }); + }); + + it("never uses one source's password for a second source, even with equal bytes", async () => { + const item = fixture(); + item.sources.set("/synthetic/two.pdf", source("two")); + await post(await item.open()); + expect(item.service.passwordForSource(source("two"))).toBeUndefined(); + expect(() => item.service.passwordForSources([source(), source("two")])).toThrow(); + await post(await item.open("/synthetic/two.pdf")); + expect(item.service.passwordForSources([source(), source("two")])).toBe(SECRET); + await item.service.lock("/synthetic/one.pdf"); + expect(item.service.passwordForSource(source("two"))).toBe(SECRET); + expect(item.service.passwordForSource(source())).toBeUndefined(); + }); + + it("refuses different unlocked passwords for a shared-password operation", async () => { + const item = fixture({ validatePassword: async () => true }); + item.sources.set("/synthetic/two.pdf", source("two")); + await post(await item.open()); + await post(await item.open("/synthetic/two.pdf"), "/unlock", { password: "other-password" }); + expect(() => item.service.passwordForSources([source(), source("two")])).toThrow(); + }); + + it("does not accept another service's capability or transfer its password", async () => { + const first = fixture(); + const second = fixture(); + const a = await first.open(); + const b = await second.open(); + expect((await post(b, "/unlock", { token: a.hash.slice(1), password: SECRET })).status).toBe(403); + await post(a); + expect(second.service.passwordForSource(source())).toBeUndefined(); + }); + + it("serializes validation and lock prevents a late credential install", async () => { + const entered = deferred(); + const finish = deferred(); + const item = fixture({ validatePassword: async () => { entered.resolve(); await finish.promise; return true; } }); + const url = await item.open(); + const first = post(url); + await entered.promise; + expect((await post(url)).status).toBe(409); + expect(await post(url, "/lock", {})).toEqual({ status: 200, body: { status: "locked" } }); + finish.resolve(); + expect((await first).status).toBe(403); + expect(item.service.passwordForSource(source())).toBeUndefined(); + }); + + it("source lock during validation invalidates the pending install", async () => { + const entered = deferred(); + const finish = deferred(); + const item = fixture({ validatePassword: async () => { entered.resolve(); await finish.promise; return true; } }); + const url = await item.open(); + const first = post(url); + await entered.promise; + await item.service.lock("/synthetic/one.pdf"); + finish.resolve(); + expect((await first).status).toBe(403); + expect(item.service.passwordForSource(source())).toBeUndefined(); + }); + + it("caps active documents and reuses capacity after explicit cancel", async () => { + const item = fixture(); + for (let index = 0; index < 8; index += 1) { + const current = source(String(index)); + item.sources.set(current.canonical_path, current); + await item.open(current.canonical_path); + } + await expect(item.service.requestUnlock("/synthetic/one.pdf")).rejects.toMatchObject({ code: "PRIVATE_PDF_CAPACITY_REACHED" }); + await post(item.urls[0], "/cancel", {}); + await expect(item.service.requestUnlock("/synthetic/one.pdf")).resolves.toEqual({ status: "awaiting_password" }); + }); + + it("invalidates the old window when the same document is requested again", async () => { + const item = fixture(); + const first = await item.open(); + const second = await item.open(); + expect(first.hash).not.toBe(second.hash); + expect((await post(first)).status).toBe(403); + expect((await post(second)).status).toBe(200); + }); + + it("bounds total attempts even when a caller repeatedly opens new windows", async () => { + const item = fixture(); + for (let index = 0; index < 20; index += 1) { + expect((await post(await item.open(), "/unlock", { password: "wrong" })).status).toBe(403); + } + expect((await post(await item.open())).status).toBe(429); + expect(item.validator).toHaveBeenCalledTimes(20); + item.advance(60_000); + expect((await post(await item.open())).status).toBe(200); + }); + + it("rejects source/open failures with fixed errors", async () => { + const item = fixture(); + await expect(item.service.status("/missing.pdf")).rejects.toMatchObject({ code: "PRIVATE_PDF_SOURCE_UNAVAILABLE" }); + const failed = fixture({ openWindow: async () => { throw new Error(SECRET); } }); + await expect(failed.open()).rejects.not.toThrow(SECRET); + expect(await failed.service.status("/synthetic/one.pdf")).toEqual({ status: "locked" }); + }); + + it("clears credentials immediately on close and closes the socket", async () => { + const item = fixture(); + const url = await item.open(); + await post(url); + const closing = item.service.close(); + expect(item.service.passwordForSource(source())).toBeUndefined(); + await closing; + await expect(fetch(url.origin)).rejects.toThrow(); + await expect(item.service.requestUnlock("/synthetic/one.pdf")).rejects.toThrow(); + }); +}); From d2f8e8f3118fd6d49bcaa95fe2e681fe71112d16 Mon Sep 17 00:00:00 2001 From: silverstein Date: Tue, 20 Oct 2026 21:32:35 +0000 Subject: [PATCH 02/13] Exercise encrypted PDF tools through private local password entry --- test/encrypted-pdf-password-truth.test.js | 205 ++++++++++--------- test/helpers/private-pdf-unlock.js | 75 +++++++ test/pdf-read-permission-consistency.test.js | 44 ++-- test/qpdf-decrypt-read-only.test.js | 41 ++-- 4 files changed, 230 insertions(+), 135 deletions(-) create mode 100644 test/helpers/private-pdf-unlock.js diff --git a/test/encrypted-pdf-password-truth.test.js b/test/encrypted-pdf-password-truth.test.js index 5ee4f56..477540b 100644 --- a/test/encrypted-pdf-password-truth.test.js +++ b/test/encrypted-pdf-password-truth.test.js @@ -14,8 +14,8 @@ // 3. a tool that can decrypt does not carry the "cannot decrypt" text, and // says what it actually does โ€” including, on a write path, that the // document's protection is put back rather than removed; -// 4. every tool the honest message points at genuinely opens the same -// document with the same password. +// 4. private entry unlocks the exact document for supported tools without +// exposing a password or browser capability in MCP messages. // // (4) is what stops the message from drifting into naming a tool that does not // work, (2) is what stops the old "please provide the correct password" advice @@ -32,38 +32,22 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { PDFDocument } from "pdf-lib"; import { PDF_LIB_ENCRYPTED_MESSAGE } from "../server/helpers.js"; import { createTestTempDirectory, removeTestTempDirectory } from "./helpers/temp-directory.js"; +import { createPrivatePdfUnlockHarness } from "./helpers/private-pdf-unlock.js"; const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); const PLAINTEXT_PDF = path.join(REPO_ROOT, "example-fw9.pdf"); const USER_PASSWORD = "encrypted-truth-user-2026"; const WRONG_PASSWORD = "encrypted-truth-wrong-2026"; -// Every tool that advertises a `password` argument and reads only through -// PDF.js. The honest pdf-lib message is allowed to name these and nothing else. +// Supported PDF.js readers named by encrypted-operation recovery messages. const PDFJS_PASSWORD_TOOLS = Object.freeze(["convert_pdf_to_markdown", "get_pdf_info", "read_pdf_layout"]); -// PDF.js-backed password consumers that are truthful but are not suitable -// destinations for the general pdf-lib refusal message. The verifier requires -// a proposal packet as well as a password, so naming it as generic recovery -// advice would send callers to a tool they cannot use from the refusal alone. -const PDFJS_SPECIALIZED_PASSWORD_TOOLS = Object.freeze(["verify_table_proposal"]); // Tools that read through pdf-lib but decrypt first, via the vendored qpdf // runtime in server/qpdf-decrypt.js. They are read-only โ€” none writes a PDF -// back โ€” which is why they are allowed to decrypt at all. Their password -// argument is genuinely usable, so the "cannot decrypt" text would be a lie on +// back. Their privately entered credential is usable, so "cannot decrypt" is false on // them and this suite must not require it. const DECRYPTING_PASSWORD_TOOLS = Object.freeze(["read_pdf_fields", "validate_pdf"]); -// Tools that decrypt, change the document, and then restore the document's own -// encryption before saving. Their password argument is usable too, so the -// "cannot decrypt" text would be a lie on them as well. What they must say -// instead is what they actually do to the protection, because a caller's real -// question on a write path is whether the file comes back protected. -const REPROTECTING_PASSWORD_TOOLS = Object.freeze([ - "add_signature_field", "apply_page_plan", "apply_signature", "apply_text", - "bulk_fill_from_csv", "fill_pdf", "fill_with_profile", "merge_pdfs", - "prepare_signing_packet", "reorder_pdf_pages", "rotate_pdf_pages", "split_pdf", -]); function probeQpdf() { try { @@ -101,8 +85,9 @@ describe.skipIf(!QPDF.available)("encrypted PDF password truthfulness (qpdf pres let tempDirectory; let encryptedPath; let encryptedBytes; + let unlock; - const callTool = async (name, args) => await client.callTool({ name, arguments: args }); + const callTool = async (name, args) => await unlock.call(client, name, args); const toolText = result => result.content?.map(part => part.text ?? "").join("\n") ?? ""; beforeAll(async () => { @@ -121,13 +106,14 @@ describe.skipIf(!QPDF.available)("encrypted PDF password truthfulness (qpdf pres throw new Error(`qpdf encryption failed (${encrypt.status}): ${encrypt.stderr}`); } encryptedBytes = await fs.readFile(encryptedPath); + unlock = await createPrivatePdfUnlockHarness(tempDirectory); client = new Client({ name: "encrypted-password-truth-test", version: "1.0.0" }); transport = new StdioClientTransport({ command: process.execPath, args: [path.join(REPO_ROOT, "server/index.js")], cwd: REPO_ROOT, - env: { ALLOWED_DIRECTORIES: [REPO_ROOT, tempDirectory].join(path.delimiter) }, + env: { ...unlock.env, ALLOWED_DIRECTORIES: [REPO_ROOT, tempDirectory].join(path.delimiter) }, stderr: "ignore", }); await client.connect(transport); @@ -195,20 +181,21 @@ describe.skipIf(!QPDF.available)("encrypted PDF password truthfulness (qpdf pres // wrong password must say which of the two it was, produce no file, and // never quote the password back. const filled = path.join(tempDirectory, "filled.pdf"); - const attempt = async password => callTool("fill_pdf", { + const attempt = async () => callTool("fill_pdf", { pdf_path: encryptedPath, field_data: {}, output_path: filled, - ...(password === undefined ? {} : { password }), }); - const omitted = await attempt(undefined); + await unlock.lock(client, encryptedPath); + const omitted = await attempt(); expect(omitted.isError, "fill_pdf with no password").toBe(true); - expect(toolText(omitted)).toMatch(/cannot be opened without a password/i); + expect(toolText(omitted)).toContain("unlock_pdf"); - const wrong = await attempt(WRONG_PASSWORD); + await unlock.enter(client, encryptedPath, WRONG_PASSWORD, { accepted: false }); + const wrong = await attempt(); expect(wrong.isError, "fill_pdf with a wrong password").toBe(true); - expect(toolText(wrong)).toMatch(/password was not accepted/i); + expect(toolText(wrong)).toContain("unlock_pdf"); expect(toolText(wrong), "must not echo the password").not.toContain(WRONG_PASSWORD); // The pdf-lib limit is no longer the truth on this path, so citing it // would be the new lie. @@ -217,7 +204,8 @@ describe.skipIf(!QPDF.available)("encrypted PDF password truthfulness (qpdf pres // Neither failure may have produced a file. await expect(fs.access(filled)).rejects.toThrow(); - const accepted = await attempt(USER_PASSWORD); + await unlock.enter(client, encryptedPath, USER_PASSWORD); + const accepted = await attempt(); expect(accepted.isError, "fill_pdf with the correct password").not.toBe(true); expect(toolText(accepted), "must not echo the password").not.toContain(USER_PASSWORD); // And what it wrote is still an encrypted document: a write path that @@ -233,20 +221,24 @@ describe.skipIf(!QPDF.available)("encrypted PDF password truthfulness (qpdf pres // rather than disappears: each outcome must describe what actually // happened, and no outcome may contain the password. for (const tool of DECRYPTING_PASSWORD_TOOLS) { - const accepted = await callTool(tool, { pdf_path: encryptedPath, password: USER_PASSWORD }); + await unlock.enter(client, encryptedPath, USER_PASSWORD); + const accepted = await callTool(tool, { pdf_path: encryptedPath }); expect(accepted.isError, `${tool} with the correct password`).not.toBe(true); expect(toolText(accepted), `${tool} must not echo the password`).not.toContain(USER_PASSWORD); - const rejected = await callTool(tool, { pdf_path: encryptedPath, password: WRONG_PASSWORD }); - expect(toolText(rejected), `${tool} with a wrong password`).toMatch(/password was not accepted/i); + await unlock.enter(client, encryptedPath, WRONG_PASSWORD, { accepted: false }); + const rejected = await callTool(tool, { pdf_path: encryptedPath }); + expect(rejected.isError).toBe(true); + expect(toolText(rejected), `${tool} with a wrong local password`).toContain("unlock_pdf"); expect(toolText(rejected), `${tool} must not echo the password`).not.toContain(WRONG_PASSWORD); // The old "supply the correct password" advice must not come back in the // one case where it is still wrong to imply pdf-lib could use it. expect(toolText(rejected), `${tool} must not cite the pdf-lib limit`) .not.toContain(PDF_LIB_ENCRYPTED_MESSAGE); + await unlock.lock(client, encryptedPath); const omitted = await callTool(tool, { pdf_path: encryptedPath }); - expect(toolText(omitted), `${tool} with no password`).toMatch(/cannot be opened without a password/i); + expect(toolText(omitted), `${tool} with no password`).toContain("unlock_pdf"); } }, 120_000); @@ -254,11 +246,12 @@ describe.skipIf(!QPDF.available)("encrypted PDF password truthfulness (qpdf pres // Binds the advice to behavior: if one of these stops working, the message // is no longer true and this test fails. const invocations = { - convert_pdf_to_markdown: { pdf_path: encryptedPath, password: USER_PASSWORD, start_page: 1, end_page: 1 }, - get_pdf_info: { pdf_path: encryptedPath, password: USER_PASSWORD }, - read_pdf_layout: { pdf_path: encryptedPath, password: USER_PASSWORD, start_page: 1, end_page: 1 }, + convert_pdf_to_markdown: { pdf_path: encryptedPath, start_page: 1, end_page: 1 }, + get_pdf_info: { pdf_path: encryptedPath }, + read_pdf_layout: { pdf_path: encryptedPath, start_page: 1, end_page: 1 }, }; expect(Object.keys(invocations).sort()).toEqual([...PDFJS_PASSWORD_TOOLS].sort()); + await unlock.enter(client, encryptedPath, USER_PASSWORD); for (const tool of PDFJS_PASSWORD_TOOLS) { const result = await callTool(tool, invocations[tool]); expect(result.isError, `${tool} with the correct password`).not.toBe(true); @@ -269,6 +262,7 @@ describe.skipIf(!QPDF.available)("encrypted PDF password truthfulness (qpdf pres it("does not point a password-less read tool at a password parameter it lacks", async () => { // read_pdf_content and its siblings decrypt through PDF.js but expose no // password argument, so the advice must name tools that do accept one. + await unlock.lock(client, encryptedPath); const { tools } = await client.listTools(); const byName = new Map(tools.map(tool => [tool.name, tool])); for (const tool of ["read_pdf_content", "read_pdf_pages", "search_pdf_text"]) { @@ -285,8 +279,7 @@ describe.skipIf(!QPDF.available)("encrypted PDF password truthfulness (qpdf pres const text = toolText(result); expect(result.isError, tool).toBe(true); expect(text, tool).not.toMatch(/provide it with the password parameter/i); - expect(PDFJS_PASSWORD_TOOLS.filter(named => text.includes(named)), tool) - .toEqual([...PDFJS_PASSWORD_TOOLS]); + expect(text, tool).toContain("unlock_pdf"); } }, 90_000); @@ -298,12 +291,11 @@ describe.skipIf(!QPDF.available)("encrypted PDF password truthfulness (qpdf pres // observation's RAW_PAGE_GEOMETRY_UNAVAILABLE reached its structure // channel. Both argument shapes are checked, because that is exactly the // difference that used to change the answer. + await unlock.enter(client, encryptedPath, USER_PASSWORD); for (const includeVisual of [true, false]) { const result = await callTool("compare_pdfs", { before_pdf_path: encryptedPath, after_pdf_path: encryptedPath, - before_password: USER_PASSWORD, - after_password: USER_PASSWORD, include_visual: includeVisual, }); const text = toolText(result); @@ -317,7 +309,7 @@ describe.skipIf(!QPDF.available)("encrypted PDF password truthfulness (qpdf pres // can actually be read. expect(text, label).toContain("Both comparison inputs are encrypted"); expect(text, label).toMatch(/pdf-lib, which has no decryption support/); - expect(text, label).toMatch(/supplying a password here will not help/); + expect(text, label).not.toMatch(/provide.*password.*argument/i); for (const named of PDFJS_PASSWORD_TOOLS) expect(text, `${label} ${named}`).toContain(named); expect(text, label).not.toContain(USER_PASSWORD); // The other way this refusal has been lost: re-reported as a generic @@ -327,22 +319,19 @@ describe.skipIf(!QPDF.available)("encrypted PDF password truthfulness (qpdf pres } }, 180_000); - it("tells a caller who used 'password' that this tool names two", async () => { - // compare_pdfs is the only read tool that takes two documents and therefore - // two password arguments. Every other tool here takes `password`, so that - // is what a caller reaches for โ€” and an unknown argument is refused with a - // message that used to name nothing at all, making a perfectly good call - // look like a broken document. - const result = await callTool("compare_pdfs", { - before_pdf_path: encryptedPath, - after_pdf_path: encryptedPath, - password: USER_PASSWORD, - }); - const text = toolText(result); - expect(result.isError).toBe(true); - expect(text).toContain("before_password"); - expect(text).toContain("after_password"); - expect(text).not.toContain(USER_PASSWORD); + it("rejects all legacy password arguments before work, including empty and null values", async () => { + for (const field of ["password", "before_password", "after_password"]) { + for (const value of [USER_PASSWORD, "", null]) { + const output = path.join(tempDirectory, `legacy-${field}-${value === null ? "null" : value.length}.pdf`); + const result = await callTool("fill_pdf", { + pdf_path: PLAINTEXT_PDF, field_data: {}, output_path: output, [field]: value, + }); + expect(result.isError, `${field}=${value === null ? "null" : "string"}`).toBe(true); + expect(toolText(result)).toContain("unlock_pdf"); + expect(JSON.stringify(result)).not.toContain(USER_PASSWORD); + await expect(fs.access(output)).rejects.toThrow(); + } + } }, 60_000); it("refuses a mixed-encryption merge with the two routes that would work", async () => { @@ -356,11 +345,12 @@ describe.skipIf(!QPDF.available)("encrypted PDF password truthfulness (qpdf pres "--bits=256", "--", PLAINTEXT_PDF, second, ], { encoding: "utf8" }); expect(encrypt.status, encrypt.stderr).toBe(0); + await unlock.enter(client, encryptedPath, USER_PASSWORD); + await unlock.enter(client, second, USER_PASSWORD); const refused = await callTool("merge_pdfs", { input_paths: [encryptedPath, second], output_path: path.join(tempDirectory, "merged-mixed.pdf"), - password: USER_PASSWORD, }); const text = toolText(refused); expect(refused.isError).toBe(true); @@ -379,55 +369,76 @@ describe.skipIf(!QPDF.available)("encrypted PDF password truthfulness (qpdf pres const copy = path.join(tempDirectory, "aes256-encrypted-copy.pdf"); await fs.writeFile(copy, encryptedBytes); const merged = path.join(tempDirectory, "merged-identical.pdf"); + const lockedCopy = await callTool("merge_pdfs", { + input_paths: [encryptedPath, copy], output_path: merged, + }); + expect(lockedCopy.isError, "credentials must not transfer to another path with identical bytes").toBe(true); + await expect(fs.access(merged)).rejects.toThrow(); + await unlock.enter(client, copy, USER_PASSWORD); const accepted = await callTool("merge_pdfs", { input_paths: [encryptedPath, copy], output_path: merged, - password: USER_PASSWORD, }); expect(accepted.isError, toolText(accepted)).not.toBe(true); expect((await fs.readFile(merged)).toString("latin1")).toContain("/Encrypt"); }, 180_000); - it("never advertises a usable password on a pdf-lib-backed tool", async () => { - // The schema text is the other place the claim can drift. + it("never advertises public password inputs and exposes only document-scoped entry controls", async () => { const { tools } = await client.listTools(); for (const tool of tools) { - const description = tool.inputSchema?.properties?.password?.description; - if (typeof description !== "string") continue; - if (PDFJS_PASSWORD_TOOLS.includes(tool.name) - || PDFJS_SPECIALIZED_PASSWORD_TOOLS.includes(tool.name)) { - expect(description, `${tool.name} password description`).not.toMatch(/pdf-lib/); - continue; - } - if (REPROTECTING_PASSWORD_TOOLS.includes(tool.name)) { - expect(description, `${tool.name} password description`).not.toMatch(/cannot decrypt/i); - expect(description, `${tool.name} password description`).not.toMatch(/never used/i); - // It must state the one thing a write path has to promise. - expect(description, `${tool.name} password description`) - .toMatch(/never removed, changed, or added/i); - continue; + for (const field of ["password", "before_password", "after_password"]) { + expect(tool.inputSchema?.properties ?? {}, tool.name).not.toHaveProperty(field); } - if (DECRYPTING_PASSWORD_TOOLS.includes(tool.name)) { - // These decrypt, so they must NOT carry the "cannot decrypt" text โ€” - // the same drift guard, pointing the other way. - expect(description, `${tool.name} password description`).not.toMatch(/cannot decrypt/i); - expect(description, `${tool.name} password description`).not.toMatch(/never used/i); - // This used to require the text "permissions allow", back when these - // three refused an owner-locked document whose `/P` denied extraction. - // They no longer do โ€” `/P` governs writes only โ€” so advertising that - // caveat would now be the drift, and the assertion is inverted to catch - // it coming back. - expect(description, `${tool.name} password description`) - .not.toMatch(/permissions allow|permissions deny/i); - // What has to stay true is the size bound and the promise that this is - // not a way to take a document's protection off. - expect(description, `${tool.name} password description`).toMatch(/16 MiB/); - expect(description, `${tool.name} password description`) - .toMatch(/never removes or weakens/i); - continue; - } - expect(description, `${tool.name} password description`).toMatch(/pdf-lib/); - expect(description, `${tool.name} password description`).toMatch(/cannot decrypt/i); + } + for (const name of ["unlock_pdf", "get_pdf_unlock_status", "lock_pdf"]) { + const tool = tools.find(value => value.name === name); + expect(tool, name).toBeDefined(); + expect(Object.keys(tool.inputSchema.properties), name).toEqual(["pdf_path"]); + } + for (const response of unlock.responses) { + expect(response).not.toContain(USER_PASSWORD); + expect(response).not.toContain(WRONG_PASSWORD); } }, 30_000); + + it("serves the same private-entry contract from the share bundle with isolated server sessions", async () => { + await unlock.enter(client, encryptedPath, USER_PASSWORD); + const shareClient = new Client({ name: "encrypted-password-share-test", version: "1.0.0" }); + const shareTransport = new StdioClientTransport({ + command: process.execPath, + args: [path.join(REPO_ROOT, "pdf-toolkit-mcp-share/server/index.js")], + cwd: REPO_ROOT, + env: { ...unlock.env, ALLOWED_DIRECTORIES: [REPO_ROOT, tempDirectory].join(path.delimiter) }, + stderr: "ignore", + }); + try { + await shareClient.connect(shareTransport); + const [sourceTools, shareTools] = await Promise.all([client.listTools(), shareClient.listTools()]); + for (const name of ["unlock_pdf", "get_pdf_unlock_status", "lock_pdf", "read_pdf_fields", "fill_pdf"]) { + expect(shareTools.tools.find(tool => tool.name === name)?.inputSchema, name) + .toEqual(sourceTools.tools.find(tool => tool.name === name)?.inputSchema); + } + const stillLocked = await unlock.call(shareClient, "read_pdf_fields", { pdf_path: encryptedPath }); + expect(stillLocked.isError, "one server's credential must not unlock another server").toBe(true); + expect(toolText(stillLocked)).toContain("unlock_pdf"); + const legacy = await unlock.call(shareClient, "read_pdf_fields", { + pdf_path: encryptedPath, password: USER_PASSWORD, + }); + expect(legacy.isError).toBe(true); + expect(JSON.stringify(legacy)).not.toContain(USER_PASSWORD); + await unlock.enter(shareClient, encryptedPath, USER_PASSWORD); + const opened = await unlock.call(shareClient, "read_pdf_fields", { pdf_path: encryptedPath }); + expect(opened.isError, toolText(opened)).not.toBe(true); + expect(opened.structuredContent.fieldCount).toBeGreaterThan(0); + const output = path.join(tempDirectory, "share-filled.pdf"); + const written = await unlock.call(shareClient, "fill_pdf", { + pdf_path: encryptedPath, field_data: {}, output_path: output, + }); + expect(written.isError, toolText(written)).not.toBe(true); + expect((await fs.readFile(output)).toString("latin1")).toContain("/Encrypt"); + } finally { + await shareClient.close(); + await shareTransport.close(); + } + }, 120_000); }); diff --git a/test/helpers/private-pdf-unlock.js b/test/helpers/private-pdf-unlock.js new file mode 100644 index 0000000..36796a1 --- /dev/null +++ b/test/helpers/private-pdf-unlock.js @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +// Only the test opener sees this environment variable. Production opens the +// user's browser normally and has no test hook or credential-bearing MCP call. +export async function createPrivatePdfUnlockHarness(directory) { + const root = path.join(directory, "private-unlock-test"); + const bin = path.join(root, "bin"); + const capture = path.join(root, "opened-url"); + await fs.mkdir(bin, { recursive: true, mode: 0o700 }); + const opener = `#!${process.execPath}\n` + + 'const fs = require("node:fs");\n' + + 'const url = process.argv.find(value => /^http:\\/\\/127\\.0\\.0\\.1:\\d+\\/#/.test(value));\n' + + 'if (!url) process.exit(2);\n' + + 'fs.writeFileSync(process.env.PDF_TOOLS_TEST_OPENER_CAPTURE, url, {mode: 0o600, flag: "wx"});\n'; + for (const name of ["open", "xdg-open", "rundll32.exe"]) { + await fs.writeFile(path.join(bin, name), opener, { mode: 0o700 }); + } + const responses = []; + const record = result => { + responses.push(JSON.stringify(result)); + return result; + }; + const call = async (client, name, args) => record(await client.callTool({ name, arguments: args })); + return { + env: { + PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, + PDF_TOOLS_TEST_OPENER_CAPTURE: capture, + }, + responses, + call, + async lock(client, pdfPath) { + const result = await call(client, "lock_pdf", { pdf_path: pdfPath }); + assert.notEqual(result.isError, true, "lock_pdf must succeed"); + return result; + }, + async enter(client, pdfPath, password, { accepted = true } = {}) { + await this.lock(client, pdfPath); + await fs.rm(capture, { force: true }); + const opened = await call(client, "unlock_pdf", { pdf_path: pdfPath }); + assert.notEqual(opened.isError, true, "unlock_pdf must open the private entry surface"); + let captured; + for (let count = 0; count < 100; count += 1) { + try { captured = await fs.readFile(capture, "utf8"); break; } + catch (error) { if (error.code !== "ENOENT") throw error; } + await delay(20); + } + assert.ok(captured, "private browser opener must be invoked"); + const url = new URL(captured); + assert.equal(url.hostname, "127.0.0.1"); + assert.match(url.hash, /^#[0-9a-f]{64}$/); + assert.equal((await fs.stat(capture)).mode & 0o777, 0o600); + assert.ok(!JSON.stringify(opened).includes(url.hash.slice(1)), "MCP must not expose the capability"); + const response = await fetch(new URL("/unlock", url), { + method: "POST", + headers: { Origin: url.origin, "Content-Type": "application/json" }, + body: JSON.stringify({ token: url.hash.slice(1), password }), + }); + const body = await response.json(); + assert.ok(!JSON.stringify(body).includes(password), "private response must not echo credentials"); + if (accepted) { + assert.equal(response.status, 200, "the synthetic password must unlock the exact document"); + assert.equal(body.status, "unlocked"); + } else { + assert.ok(response.status >= 400, "a wrong local password must fail"); + } + const status = await call(client, "get_pdf_unlock_status", { pdf_path: pdfPath }); + assert.notEqual(status.isError, true); + for (const value of responses) assert.ok(!value.includes(password), "MCP response leaked a synthetic password"); + return { response, body, status }; + }, + }; +} diff --git a/test/pdf-read-permission-consistency.test.js b/test/pdf-read-permission-consistency.test.js index bd915f6..0c1a297 100644 --- a/test/pdf-read-permission-consistency.test.js +++ b/test/pdf-read-permission-consistency.test.js @@ -35,6 +35,7 @@ import { fileURLToPath } from "node:url"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createPrivatePdfUnlockHarness } from "./helpers/private-pdf-unlock.js"; import { ENCRYPTED_READ_OPERATIONS, ENCRYPTED_WRITE_OPERATIONS, @@ -84,6 +85,7 @@ async function encryptFixture(sourceBytes, encryptArgs) { let temporaryRoot; let client; let transport; +let unlock; const fixturePaths = {}; let workspaceSequence = 0; @@ -109,11 +111,13 @@ beforeAll(async () => { await fs.writeFile(target, await encryptFixture(extractionBytes, args)); fixturePaths[`${name}Extraction`] = target; } + unlock = await createPrivatePdfUnlockHarness(temporaryRoot); transport = new StdioClientTransport({ command: process.execPath, args: [path.join(REPO_ROOT, "server/index.js")], cwd: REPO_ROOT, env: { + ...unlock.env, ALLOWED_DIRECTORIES: [REPO_ROOT, temporaryRoot].join(path.delimiter), DEFAULT_PROFILES_DIR: path.join(temporaryRoot, "profiles"), }, @@ -138,7 +142,7 @@ afterAll(async () => { * says so. That is deliberate: the alternative is a new tool silently escaping * the rule, which is the failure this whole file exists to prevent. */ -async function extractionWorkspace(document, tag, { leafNames = ["missing"], password } = {}) { +async function extractionWorkspace(document, tag, { leafNames = ["missing"] } = {}) { const sourceDocument = document === fixturePaths.extractDenied ? fixturePaths.extractDeniedExtraction : fixturePaths.extractAllowedExtraction; @@ -149,7 +153,6 @@ async function extractionWorkspace(document, tag, { leafNames = ["missing"], pas arguments: { pdf_path: sourceDocument, workspace_id: workspaceId, - ...(password ? { password } : {}), schema: { type: "object", additionalProperties: false, @@ -185,7 +188,6 @@ const READ_CALL_RECIPES = Object.freeze({ before_pdf_path: doc, after_pdf_path: fixturePaths.plain, include_visual: false, - ...(extras.password ? { before_password: extras.password } : {}), }), convert_pdf_to_markdown: (doc, extras) => ({ pdf_path: doc, start_page: 1, end_page: 1, ...extras }), create_extraction_workspace: (doc, extras) => ({ @@ -194,7 +196,6 @@ const READ_CALL_RECIPES = Object.freeze({ : fixturePaths.extractAllowedExtraction, workspace_id: `permission-create-${path.basename(doc, ".pdf")}${extras.tag}` .replace(/[^a-z0-9_-]/gu, "-"), - ...(extras.password ? { password: extras.password } : {}), schema: { type: "object", properties: { value: { type: "string" } } }, }), detect_signature_zones: (doc, extras) => ({ pdf_path: doc, ...extras }), @@ -210,10 +211,9 @@ const READ_CALL_RECIPES = Object.freeze({ inspect_pdf_accessibility: doc => ({ pdf_path: doc }), prepare_lumin_request: doc => ({ pdf_path: doc }), read_extraction_chunk: async (doc, extras) => { - const prepared = await extractionWorkspace(doc, `chunk${extras.tag}`, { password: extras.password }); + const prepared = await extractionWorkspace(doc, `chunk${extras.tag}`); return { pdf_path: prepared.sourceDocument, - ...(extras.password ? { password: extras.password } : {}), workspace_id: prepared.workspaceId, workspace_identity_sha256: prepared.created.workspace_identity_sha256, chunk_id: prepared.chunks[0].chunk_id, @@ -230,8 +230,8 @@ const READ_CALL_RECIPES = Object.freeze({ }), review_extraction_workspace: async (doc, extras) => { // Review hashes the original bytes and reads retained evidence; it has no - // password argument. Exercise the supplied password during workspace creation. - const prepared = await extractionWorkspace(doc, `review${extras.tag}`, { password: extras.password }); + // password argument. Private entry precedes creation in the test context. + const prepared = await extractionWorkspace(doc, `review${extras.tag}`); return { pdf_path: prepared.sourceDocument, workspace_id: prepared.workspaceId, @@ -247,7 +247,7 @@ const READ_CALL_RECIPES = Object.freeze({ verify_extraction_batch: async (doc, extras) => { const leafNames = ["first", "second"]; const prepared = await extractionWorkspace(doc, `batch${extras.tag}`, { - leafNames, password: extras.password, + leafNames, }); let generation = prepared.created.generation_sha256; const items = []; @@ -273,7 +273,6 @@ const READ_CALL_RECIPES = Object.freeze({ } return { pdf_path: prepared.sourceDocument, - ...(extras.password ? { password: extras.password } : {}), workspace_id: prepared.workspaceId, workspace_identity_sha256: prepared.created.workspace_identity_sha256, parent_generation_sha256: generation, @@ -281,7 +280,7 @@ const READ_CALL_RECIPES = Object.freeze({ }; }, verify_extraction_proposal: async (doc, extras) => { - const prepared = await extractionWorkspace(doc, `verify${extras.tag}`, { password: extras.password }); + const prepared = await extractionWorkspace(doc, `verify${extras.tag}`); const proposal = await client.callTool({ name: "submit_extraction_proposal", arguments: { @@ -296,7 +295,6 @@ const READ_CALL_RECIPES = Object.freeze({ expect(proposal.isError, responseText(proposal)).not.toBe(true); return { pdf_path: prepared.sourceDocument, - ...(extras.password ? { password: extras.password } : {}), workspace_id: prepared.workspaceId, workspace_identity_sha256: prepared.created.workspace_identity_sha256, proposal_generation_sha256: proposal.structuredContent.generation_sha256, @@ -307,7 +305,6 @@ const READ_CALL_RECIPES = Object.freeze({ }, verify_table_proposal: (doc, extras) => ({ pdf_path: doc, - password: extras.password, region_id: "p1-t1", proposal_token: "0".repeat(64), cells: [{ row: 0, column: 0, rowspan: 1, colspan: 1, item_ids: [] }], @@ -325,6 +322,8 @@ async function readFamily() { .filter(tool => Object.keys(tool.inputSchema?.properties ?? {}) .some(property => DOCUMENT_PARAMETERS.includes(property))) .filter(tool => !writes.has(tool.name)) + // These manage a credential session; they do not read PDF content. + .filter(tool => !["unlock_pdf", "get_pdf_unlock_status", "lock_pdf"].includes(tool.name)) .map(tool => tool.name) .sort(); } @@ -339,10 +338,20 @@ const responseText = result => (result.content ?? []) * files. Names, sizes and digests are erased; whether the call worked, and what * it refused for if it did not, survives. */ -async function observe(name, document, extras = { tag: "" }) { +async function preparePrivateReadContext(document, privatePassword) { + const extraction = document === fixturePaths.extractDenied + ? fixturePaths.extractDeniedExtraction : fixturePaths.extractAllowedExtraction; + for (const source of [document, extraction]) { + if (privatePassword === undefined) await unlock.lock(client, source); + else await unlock.enter(client, source, privatePassword); + } +} + +async function observe(name, document, { privatePassword, ...extras } = { tag: "" }) { const build = READ_CALL_RECIPES[name]; let result; try { + await preparePrivateReadContext(document, privatePassword); result = await client.callTool({ name, arguments: await build(document, extras) }); } catch (error) { return { outcome: "transport-error", detail: String(error?.message ?? error).slice(0, 80) }; @@ -458,8 +467,9 @@ describe("every tool that reads a document treats /P the same way", () => { it("really reviews and batch-verifies both permission variants with or without the owner password", async () => { for (const document of [fixturePaths.extractDenied, fixturePaths.extractAllowed]) { for (const password of [undefined, OWNER_PASSWORD]) { + await preparePrivateReadContext(document, password); for (const name of ["review_extraction_workspace", "verify_extraction_batch"]) { - const args = await READ_CALL_RECIPES[name](document, { tag: "-nonvacuous", password }); + const args = await READ_CALL_RECIPES[name](document, { tag: "-nonvacuous" }); const result = await client.callTool({ name, arguments: args }); expect(result.isError, `${name}: ${responseText(result)}`).not.toBe(true); if (name === "review_extraction_workspace") { @@ -551,7 +561,7 @@ describe("every tool that reads a document treats /P the same way", () => { for (const name of family) { const without = await observe(name, fixturePaths.extractDenied, { tag: "-nopw" }); const withOwner = await observe( - name, fixturePaths.extractDenied, { tag: "-ownerpw", password: OWNER_PASSWORD }, + name, fixturePaths.extractDenied, { tag: "-ownerpw", privatePassword: OWNER_PASSWORD }, ); if (without.outcome === "allowed" && withOwner.outcome !== "allowed") { downgraded.push({ name, without, withOwner }); @@ -583,13 +593,13 @@ describe("the write gate is the one that survived, and it still bites", () => { .rejects.toMatchObject({ code: "ENOENT" }); const written = path.join(temporaryRoot, "owner-authorised.pdf"); + await unlock.enter(client, fixturePaths.extractDenied, OWNER_PASSWORD); const allowed = await client.callTool({ name: "fill_pdf", arguments: { pdf_path: fixturePaths.extractDenied, output_path: written, field_data: { "topmostSubform[0].Page1[0].f1_1[0]": "authorised" }, - password: OWNER_PASSWORD, }, }); expect(allowed.isError).not.toBe(true); diff --git a/test/qpdf-decrypt-read-only.test.js b/test/qpdf-decrypt-read-only.test.js index 3c260d9..d168fa3 100644 --- a/test/qpdf-decrypt-read-only.test.js +++ b/test/qpdf-decrypt-read-only.test.js @@ -22,10 +22,9 @@ import { fileURLToPath } from "node:url"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createPrivatePdfUnlockHarness } from "./helpers/private-pdf-unlock.js"; import { ENCRYPTED_READ_OPERATIONS, - PDF_DECRYPTABLE_PASSWORD_DESCRIPTION, - PDF_REPROTECTING_PASSWORD_DESCRIPTION, PDF_ENCRYPTED_MAX_FILE_BYTES, PdfDecryptionError, decryptPdfForRead, @@ -478,13 +477,15 @@ describe("pre-parse bounds on the decrypted plaintext", () => { describe("read-only decryption through the MCP server", () => { let client; let transport; + let unlock; beforeAll(async () => { + unlock = await createPrivatePdfUnlockHarness(temporaryRoot); transport = new StdioClientTransport({ command: process.execPath, args: [path.join(REPO_ROOT, "server/index.js")], cwd: REPO_ROOT, - env: { ALLOWED_DIRECTORIES: [REPO_ROOT, temporaryRoot].join(path.delimiter) }, + env: { ...unlock.env, ALLOWED_DIRECTORIES: [REPO_ROOT, temporaryRoot].join(path.delimiter) }, stderr: "ignore", }); client = new Client({ name: "pdf-tools-qpdf-decrypt-test", version: "1.0.0" }); @@ -498,22 +499,16 @@ describe("read-only decryption through the MCP server", () => { const textOf = result => result.content.map(entry => entry.text ?? "").join("\n"); - it("advertises the new password contract on exactly the tools that can decrypt", async () => { + it("advertises private entry without public password arguments", async () => { const { tools } = await client.listTools(); const byName = new Map(tools.map(tool => [tool.name, tool])); - for (const name of ["read_pdf_fields", "validate_pdf"]) { - expect(byName.get(name).inputSchema.properties.password.description, name) - .toBe(PDF_DECRYPTABLE_PASSWORD_DESCRIPTION); + for (const tool of tools) { + for (const field of ["password", "before_password", "after_password"]) { + expect(tool.inputSchema.properties, tool.name).not.toHaveProperty(field); + } } - // extract_to_csv takes a list of documents and therefore has no password - // parameter; one password could not serve a list. - expect(byName.get("extract_to_csv").inputSchema.properties).not.toHaveProperty("password"); - // The mutation tools decrypt too now, and say so with the contract that - // describes what they actually do: decrypt, change, and put the document's - // own protection back. No password-bearing tool claims it cannot decrypt. - for (const name of ["fill_pdf", "fill_with_profile", "bulk_fill_from_csv"]) { - expect(byName.get(name).inputSchema.properties.password.description, name) - .toBe(PDF_REPROTECTING_PASSWORD_DESCRIPTION); + for (const name of ["unlock_pdf", "get_pdf_unlock_status", "lock_pdf"]) { + expect(Object.keys(byName.get(name).inputSchema.properties), name).toEqual(["pdf_path"]); } }, 30_000); @@ -526,9 +521,10 @@ describe("read-only decryption through the MCP server", () => { expect(baseline).toBeGreaterThan(0); for (const name of ["aes256", "aes128", "rc4128"]) { + await unlock.enter(client, fixturePaths[name], USER_PASSWORD); const result = await client.callTool({ name: "read_pdf_fields", - arguments: { pdf_path: fixturePaths[name], password: USER_PASSWORD }, + arguments: { pdf_path: fixturePaths[name] }, }); expect(result.isError, name).not.toBe(true); // The decrypted read has to agree with the same document read @@ -540,21 +536,24 @@ describe("read-only decryption through the MCP server", () => { }, 120_000); it("validates an encrypted form and refuses one whose password is wrong", async () => { + await unlock.enter(client, fixturePaths.aes256, USER_PASSWORD); const ok = await client.callTool({ name: "validate_pdf", - arguments: { pdf_path: fixturePaths.aes256, password: USER_PASSWORD }, + arguments: { pdf_path: fixturePaths.aes256 }, }); expect(ok.isError).not.toBe(true); expect(ok.structuredContent.total_field_count).toBeGreaterThan(0); + await unlock.enter(client, fixturePaths.aes256, "wrong", { accepted: false }); const wrong = await client.callTool({ name: "validate_pdf", - arguments: { pdf_path: fixturePaths.aes256, password: "wrong" }, + arguments: { pdf_path: fixturePaths.aes256 }, }); // The specific, actionable reason has to survive validate_pdf's own // catch-all, which would otherwise replace it with "verify the // path/password", advice the server already knows to be wrong. - expect(textOf(wrong)).toMatch(/password was not accepted/i); + expect(wrong.isError).toBe(true); + expect(textOf(wrong)).toContain("unlock_pdf"); expect(textOf(wrong)).not.toMatch(/qpdf|in\.pdf|invalid password/i); }, 60_000); @@ -603,13 +602,13 @@ describe("read-only decryption through the MCP server", () => { it("writes an encrypted document back still encrypted", async () => { const filled = path.join(temporaryRoot, "filled.pdf"); + await unlock.enter(client, fixturePaths.aes256, USER_PASSWORD); const result = await client.callTool({ name: "fill_pdf", arguments: { pdf_path: fixturePaths.aes256, output_path: filled, field_data: { anything: "value" }, - password: USER_PASSWORD, }, }); // Decryption is no longer scoped to reads: a write path may open this file. From 226d244a4a348e198513e9a84ac2aafc695bdf64 Mon Sep 17 00:00:00 2001 From: silverstein Date: Tue, 20 Oct 2026 21:33:17 +0000 Subject: [PATCH 03/13] Assert private entry status through the MCP response --- test/helpers/private-pdf-unlock.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/helpers/private-pdf-unlock.js b/test/helpers/private-pdf-unlock.js index 36796a1..d21f956 100644 --- a/test/helpers/private-pdf-unlock.js +++ b/test/helpers/private-pdf-unlock.js @@ -43,7 +43,10 @@ export async function createPrivatePdfUnlockHarness(directory) { assert.notEqual(opened.isError, true, "unlock_pdf must open the private entry surface"); let captured; for (let count = 0; count < 100; count += 1) { - try { captured = await fs.readFile(capture, "utf8"); break; } + try { + captured = await fs.readFile(capture, "utf8"); + if (captured) break; + } catch (error) { if (error.code !== "ENOENT") throw error; } await delay(20); } @@ -68,6 +71,8 @@ export async function createPrivatePdfUnlockHarness(directory) { } const status = await call(client, "get_pdf_unlock_status", { pdf_path: pdfPath }); assert.notEqual(status.isError, true); + const state = JSON.parse(status.content.filter(entry => entry.type === "text").map(entry => entry.text).join("\n")); + assert.equal(state.status, accepted ? "unlocked" : "awaiting_password"); for (const value of responses) assert.ok(!value.includes(password), "MCP response leaked a synthetic password"); return { response, body, status }; }, From 23b7588fa299a8560624721b355a2b812ee0b4f2 Mon Sep 17 00:00:00 2001 From: silverstein Date: Tue, 20 Oct 2026 21:35:58 +0000 Subject: [PATCH 04/13] Wire private document credentials into PDF tool dispatch --- README.md | 7 + docs/ENCRYPTED_PDF_CREDENTIAL_PROPOSAL.md | 60 ++- manifest.json | 3 + manifest.mcpb.json | 3 + package-for-friend.js | 1 + pdf-toolkit-mcp-share/manifest.json | 448 ++++++++++++++++++ pdf-toolkit-mcp-share/server/helpers.js | 2 +- pdf-toolkit-mcp-share/server/index.js | 116 ++++- .../server/pdf-comparison.js | 9 +- pdf-toolkit-mcp-share/server/pdfjs-worker.js | 2 +- .../server/private-pdf-credentials.js | 375 +++++++++++++++ pdf-toolkit-mcp-share/server/qpdf-decrypt.js | 6 +- .../server/verified-extraction-tools.js | 4 - .../skills/pdf-tools-workflow/SKILL.md | 9 +- scripts/build-mcpb.mjs | 1 + server/helpers.js | 2 +- server/index.js | 116 ++++- server/pdf-comparison.js | 9 +- server/pdfjs-worker.js | 2 +- server/qpdf-decrypt.js | 6 +- server/verified-extraction-tools.js | 4 - test/compare-pdfs.test.js | 23 +- test/convert-pdf-to-markdown.test.js | 13 +- test/get-pdf-info.test.js | 16 +- test/mcp-contract.test.js | 8 +- test/read-pdf-layout.test.js | 11 +- 26 files changed, 1163 insertions(+), 93 deletions(-) create mode 100644 pdf-toolkit-mcp-share/manifest.json create mode 100644 pdf-toolkit-mcp-share/server/private-pdf-credentials.js diff --git a/README.md b/README.md index edcd383..23437ac 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,13 @@ one-click signup experience. An ordinary user should not need to create a developer app. If signing is not configured, ask the installation's maintainer; creating a personal Lumin account alone does not enable the integration. +For password-protected local PDFs, ask PDF Tools to unlock the document. Enter +the password in its private local browser window, never in chat. Access is +limited to the exact file and the current server session, expires after ten +minutes, and can be ended with `lock_pdf`. Unlocking preserves the document's +permissions and encryption; it does not enable otherwise unsupported encrypted +rendering or comparison. See [private password entry](docs/ENCRYPTED_PDF_CREDENTIAL_PROPOSAL.md). + Once configured, ask PDF Tools to connect Lumin. Sign in and approve access in the browser, never by sharing passwords or callback URLs in chat. If you need an account, create it on Lumin's website. If signup does not return to the diff --git a/docs/ENCRYPTED_PDF_CREDENTIAL_PROPOSAL.md b/docs/ENCRYPTED_PDF_CREDENTIAL_PROPOSAL.md index 2751ffc..cc34636 100644 --- a/docs/ENCRYPTED_PDF_CREDENTIAL_PROPOSAL.md +++ b/docs/ENCRYPTED_PDF_CREDENTIAL_PROPOSAL.md @@ -1,45 +1,58 @@ # Private password entry for encrypted PDFs -Status: design proposal, not implemented or claimed as shipped. +Status: implemented in the private-entry source tranche. Release and desktop-host +qualification are separate from source tests; this document does not claim an +OpenAI allowlisting or public release. ## Current boundary -Several PDF tools currently accept a PDF password in their MCP arguments. -Those arguments are model-visible. The existing viewer has no independent -password-entry prompt, and Lumin's browser OAuth flow is provider-specific. -Neither is an encrypted-PDF credential service. +PDF tools no longer accept top-level `password`, `before_password`, or +`after_password` arguments. Legacy calls, including null or empty values, fail +before filesystem or parser actions without reflecting their values. A PDF form +field or extraction-schema property named `password` is ordinary document data, +not an authentication channel. Low-level isolated worker protocols remain +internal and are not MCP tools. The download tool no longer accepts custom HTTP headers. Authenticated downloads should happen through the provider's user-facing interface, followed by opening the saved local file. Lumin authentication continues to use its existing browser OAuth flow, not credentials supplied in chat. -## Proposed user experience +## User experience When an encrypted document needs a password, PDF Tools offers a local unlock window. The user enters the password there, outside chat. The assistant receives -only a locked/unlocked/expired status for that document. A visible lock action -and short expiry end access. Ordinary non-encrypted workflows remain unchanged. +only an awaiting-password, locked, unlocked, or expired status for that document. +Call `unlock_pdf` with the local path, enter the password in the opened browser +window, then retry the PDF operation. `get_pdf_unlock_status` checks access; +`lock_pdf` and the window's Lock button clear it. Access expires after at most +ten minutes and ends with the server session. Ordinary non-encrypted workflows +remain unchanged. Restarting the host requires unlocking again. -The implementation must provide a direct user-to-local-server channel. +The implementation provides a direct user-to-local-server channel. Sending the password through an app-only MCP tool is not an acceptable shortcut: it would still be a tool argument. A viewer-only PDF.js prompt would likewise not unlock extraction or mutation tools running in the server. -## Proposed implementation boundary +## Implemented boundary - Use a local, user-facing entry surface connected to the already-running server through authenticated local IPC or a tightly bounded loopback service. - Select and qualify that transport before shipping. For a browser surface, - require an unpredictable session capability, exact Host/Origin checks, - POST-only submission, expiry, no external assets, and no request-body logging. + The browser connects only to `127.0.0.1` on an ephemeral port, with a random + 256-bit capability initially carried in the fragment and removed from browser + history by the page. The URL is never returned through MCP. Requests require + exact Host/Origin, POST, JSON content type, bounded bodies and the capability. + The page has no external assets and a restrictive CSP; request bodies are not + logged. Pending windows last five minutes. A successful window retains its + lock capability for the credential lifetime. At most eight documents and one + password validation are active; attempts and validation time are bounded. - Keep credentials in process memory, not command-line arguments, URLs, MCP calls/results, analytics, logs, temporary plaintext PDFs, or saved profiles. JavaScript memory and operating-system swap do not provide guaranteed erasure. - Bind each entry to the canonical path, exact encrypted source hash and file identity. Revalidate before use; changed bytes invalidate it. Scope access to the server session and document, not all files with the same password. -- Remove public password arguments when this mechanism is implemented. Reject +- Remove public password arguments from discovery. Reject legacy password arguments without echoing their values, and resolve the credential internally before invoking existing bounded PDF.js/QPDF workers. - Expire entries, cap their count and lifetime, clear them on explicit lock, @@ -49,6 +62,18 @@ not unlock extraction or mutation tools running in the server. produce encrypted outputs or fail; the existing QPDF reprotection contract remains in force. +Each input to a multi-document mutation must be unlocked independently. The +existing worker requires the same password for all encrypted inputs; this +tranche does not add mixed-password merge support. Encrypted rendering, +geometry, comparison, and accessibility inspection remain unsupported where +the underlying operation already refused them. Unlocking does not lift those +limits. Locking does not erase text already read, completed output files, or +existing extraction workspaces. + +This is a local trusted-user boundary, not protection against malware, browser +extensions, a hostile same-user process, root, OS swap, or a compromised host. +There is no credential persistence or transfer between server instances. + ## Qualification before enabling Test wrong passwords, cancel/expiry/restart, changed sources, concurrent @@ -58,6 +83,7 @@ MCP messages, tool errors, logs, process arguments, persisted state or output artifacts. Test read and write permission distinctions and encrypted-output preservation, then exercise the real user entry flow in supported desktop hosts. -This proposal requires review and implementation before a submission may claim -password-free encrypted-PDF authentication. The annotation and download changes -do not implement this separate capability. +An installed-host run and exact packaged-source check are required before +claiming desktop qualification. Synthetic test passwords may appear in test +fixture code; they must not appear in the captured product MCP traffic, logs, +saved profiles, or generated document metadata. diff --git a/manifest.json b/manifest.json index fdd80dc..3d3a5dc 100644 --- a/manifest.json +++ b/manifest.json @@ -50,6 +50,9 @@ ], "icon": "icon.png", "tools": [ + { "name": "unlock_pdf", "description": "Open private local PDF password entry outside chat" }, + { "name": "get_pdf_unlock_status", "description": "Check this document's private session access" }, + { "name": "lock_pdf", "description": "Clear this document's private in-memory password" }, { "name": "import_host_pdf", "description": "Import app-supplied PDF bytes into the already-permitted private workspace (app-only)" diff --git a/manifest.mcpb.json b/manifest.mcpb.json index b1df685..184a133 100644 --- a/manifest.mcpb.json +++ b/manifest.mcpb.json @@ -50,6 +50,9 @@ ], "icon": "icon.png", "tools": [ + { "name": "unlock_pdf", "description": "Open private local PDF password entry outside chat" }, + { "name": "get_pdf_unlock_status", "description": "Check this document's private session access" }, + { "name": "lock_pdf", "description": "Clear this document's private in-memory password" }, { "name": "display_pdf", "description": "Display an interactive PDF viewer with search, navigation, zoom, and form field detection" diff --git a/package-for-friend.js b/package-for-friend.js index 30d1d70..50f9a0a 100755 --- a/package-for-friend.js +++ b/package-for-friend.js @@ -85,6 +85,7 @@ export const SHARE_FILES = [ "server/pdfjs-worker.js", "server/qpdf-decrypt-worker.js", "server/qpdf-decrypt.js", + "server/private-pdf-credentials.js", "server/resource-uri.js", "server/skill-files.js", "server/skills.js", diff --git a/pdf-toolkit-mcp-share/manifest.json b/pdf-toolkit-mcp-share/manifest.json new file mode 100644 index 0000000..3d3a5dc --- /dev/null +++ b/pdf-toolkit-mcp-share/manifest.json @@ -0,0 +1,448 @@ +{ + "manifest_version": "0.3", + "name": "pdf-toolkit", + "display_name": "PDF Tools", + "description": "Open any PDF already on your computer and work with it: read, fill, extract, merge, split, and compare locally. Optional Lumin signing sends a prepared PDF and recipient details only after explicit confirmation.", + "long_description": "PDF Tools turns Claude Desktop into a complete PDF workstation with local file operations. Claude can fetch PDFs from URLs, inspect them visually, fill forms, manage pages, add signature/date zones, apply local signatures or text, extract structured data, and return document content to the selected MCP host for analysis.\n\nThis package targets Claude Desktop and other local MCP hosts today. It does not yet include a remote Claude/Cowork connector.\n\n**Interactive PDF Viewer:**\n\u2022 View PDFs with page navigation, zoom, search, text selection, and fullscreen\n\u2022 See form fields in a sidebar with fill status\n\u2022 Use visual page management to reorder, rotate, and remove pages before saving\n\u2022 Switch into Sign mode to work with signature/date zones on the PDF\n\n**Sign Mode & Local Signatures:**\n\u2022 Detect signature, initials, printed-name, and date zones with visible coordinates Claude can reason from\n\u2022 Draw or reuse saved local signatures\n\u2022 Place signatures, dates, or other text on detected or custom zones\n\u2022 Inspect a region, preview it, and turn it into a typed signing zone when the automatic detector is not enough\n\u2022 Keep edits local, with active-document tracking and automatic backup behavior for same-file mutations\n\u2022 Optionally send an exact prepared PDF and listed recipient details to Lumin only after explicit confirmation\n\n**URL-to-PDF Workflows:**\n\u2022 Fetch PDFs from HTTP(S) URLs to the user's local machine, including cases where Claude's WebFetch is blocked\n\u2022 Open downloaded PDFs immediately in the viewer for fill, sign, page management, extraction, or analysis\n\u2022 Use page-bounded reads, text search, page renders, and region renders instead of one brittle all-document operation\n\n**Forms & Reusable Profiles:**\n\u2022 Fill W-9s, 1099s, rental applications, waivers, and any fillable PDF\n\u2022 Save personal or business details as reusable profiles\n\u2022 List, load, and apply saved profiles so repeated forms take seconds instead of minutes\n\u2022 Bulk fill many PDFs from CSV data and validate required fields before submission\n\n**Page Organization Tools:**\n\u2022 Merge multiple PDFs into one document\n\u2022 Split PDFs by exact page ranges or regular intervals\n\u2022 Rotate and reorder pages, or apply a full page plan in one pass\n\u2022 Keep the original intact by saving organized output as a new file\n\n**Extraction, Rendering & Analysis:**\n\u2022 Read existing PDF text layers for summarization, question answering, and research workflows\n\u2022 Read specific page ranges and search PDF text with page-numbered snippets\n\u2022 Convert supported born-digital page ranges to deterministic Markdown with explicit coverage gaps\n\u2022 Render full pages or bounded regions to PNG for host/model vision on scanned or image-heavy documents\n\u2022 If a selected `read_pdf_content` extraction has no text, return a rendered image of page 1 when available\n\u2022 Keep rendering and text recognition distinct: page and region renders are raster images, not OCR text\n\u2022 Treat mixed text/raster documents and raster pages after page 1 as potentially unrecognized by broad text reads\n\u2022 Extract structured data to CSV\n\u2022 Inspect page-level details like orientation, text presence, images, and likely blank pages\n\u2022 Review metadata such as page count, dimensions, form fields, and file size\n\n**Local file operations and host-aware privacy:** PDF Tools reads, renders, edits, and saves files on the user's machine. It does not upload PDFs to a separate PDF service unless the user explicitly confirms the optional Lumin signing workflow. Text, images, or metadata returned through MCP may be processed under the selected host or model provider's data terms. Claude Desktop users choose which folders PDF Tools may read from or write to.\n\n**Works with many PDF types:** Forms, contracts, research papers, technical manuals, invoices, financial statements, scanned documents, municipal packets, and encrypted PDFs. The local runtime does not currently bundle OCR; optional local OCR remains planned rather than shipped.\n\n**Best for:** People who need more than a viewer: operators processing forms, lawyers organizing contracts, accountants handling tax documents, researchers reviewing papers, and anyone who wants local PDF file operations with their chosen MCP host.", + "version": "0.14.0", + "author": { + "name": "Open Document Alliance", + "url": "https://github.com/Open-Document-Alliance", + "email": "mat@businessvacation.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/Open-Document-Alliance/PDF-Tools" + }, + "homepage": "https://github.com/Open-Document-Alliance/PDF-Tools", + "documentation": "https://github.com/Open-Document-Alliance/PDF-Tools/blob/master/README.md", + "support": "https://github.com/Open-Document-Alliance/PDF-Tools/issues", + "license": "MIT", + "keywords": [ + "pdf", + "markdown", + "merge", + "split", + "rotate", + "reorder", + "viewer", + "interactive", + "search", + "research", + "academic", + "analyze", + "compare", + "extract", + "documentation", + "contracts", + "forms", + "fill", + "sign", + "signature", + "date", + "url", + "download", + "render", + "w9", + "1099", + "tax", + "invoice" + ], + "icon": "icon.png", + "tools": [ + { "name": "unlock_pdf", "description": "Open private local PDF password entry outside chat" }, + { "name": "get_pdf_unlock_status", "description": "Check this document's private session access" }, + { "name": "lock_pdf", "description": "Clear this document's private in-memory password" }, + { + "name": "import_host_pdf", + "description": "Import app-supplied PDF bytes into the already-permitted private workspace (app-only)" + }, + { + "name": "open_pdf_workspace", + "description": "Open the PDF Workspace starting screen (app-only)" + }, + { + "name": "display_pdf", + "description": "Display an interactive PDF viewer with search, navigation, zoom, and form field detection" + }, + { + "name": "list_pdfs", + "description": "List PDF files in a directory, sorted by name, up to 200 per call" + }, + { + "name": "read_pdf_fields", + "description": "Read all form fields from a PDF file" + }, + { + "name": "fill_pdf", + "description": "Fill a PDF form; may save in place with automatic backup" + }, + { + "name": "bulk_fill_from_csv", + "description": "Fill multiple PDFs using data from a CSV file" + }, + { + "name": "save_profile", + "description": "Save form data as a reusable profile" + }, + { + "name": "load_profile", + "description": "Load a saved profile" + }, + { + "name": "list_profiles", + "description": "List all saved profiles" + }, + { + "name": "fill_with_profile", + "description": "Fill a PDF using a saved profile" + }, + { + "name": "extract_to_csv", + "description": "Extract form data from filled PDFs to a CSV file" + }, + { + "name": "validate_pdf", + "description": "Inspect AcroForm value coverage and actual PDF Required flags without claiming the form is ready to submit" + }, + { + "name": "read_pdf_content", + "description": "Read PDF content with explicit complete, partial, or failed extraction status" + }, + { + "name": "read_pdf_pages", + "description": "Read a bounded page range from a PDF with page-numbered structured output" + }, + { + "name": "read_pdf_layout", + "description": "Extract bounded local text geometry and conservative reading order into the versioned PDF Tools Extraction IR" + }, + { + "name": "convert_pdf_to_markdown", + "description": "Convert supported local PDF text to deterministic Markdown with explicit partial and unsupported-content gaps, including evidence-backed tables and source-validated external http or https links; optionally enable counted compact-mode normalizations" + }, + { + "name": "verify_table_proposal", + "description": "Check a suggested table against the original PDF and return it only when the text and layout match the document; unclear tables stay unconverted for review" + }, + { + "name": "render_pdf_page", + "description": "Render one source-bound PDF.js page view to PNG with distinct raw page geometry, view geometry, renderer policy, and digest evidence" + }, + { + "name": "render_pdf_region", + "description": "Render a bounded source-bound PDF.js viewport region to PNG; these coordinates are not MediaBox-relative signing coordinates" + }, + { + "name": "search_pdf_text", + "description": "Search extracted PDF text for a phrase and return page-numbered snippets" + }, + { + "name": "get_pdf_resource_uri", + "description": "Get a resource URI for a PDF file that can be used with Claude's built-in PDF reading capabilities via the Resources API" + }, + { + "name": "get_pdf_identity", + "description": "Get a parser-independent canonical path, exact byte length, and SHA-256 for a local PDF up to 250 MiB after validating its PDF header" + }, + { + "name": "read_pdf_bytes", + "description": "Read PDF file bytes in chunks (for UI rendering)" + }, + { + "name": "merge_pdfs", + "description": "Merge multiple PDF files into a single PDF" + }, + { + "name": "split_pdf", + "description": "Split a PDF into multiple files by page ranges or intervals" + }, + { + "name": "rotate_pdf_pages", + "description": "Rotate pages in a PDF by 90, 180, or 270 degrees" + }, + { + "name": "reorder_pdf_pages", + "description": "Rearrange the pages of a PDF in a new order" + }, + { + "name": "inspect_pdf_accessibility", + "description": "Inspect eight shallow catalog-level accessibility signals with source binding, fixed limitations, and required human review" + }, + { + "name": "compare_pdfs", + "description": "Deterministically compare two whole local PDFs with source-bound semantic, text, structure, form, annotation, metadata, and optional visual evidence" + }, + { + "name": "get_pdf_info", + "description": "Observe bounded source-bound PDF page geometry, metadata, form widgets, and inert ordinary annotations with explicit coverage" + }, + { + "name": "apply_page_plan", + "description": "Reorder, rotate, and delete PDF pages in one pass, saving as a new file" + }, + { + "name": "get_page_analysis", + "description": "Analyze PDF pages with provenance-safe blank, unknown, orientation, text, and image status" + }, + { + "name": "fetch_pdf_from_url", + "description": "Download a PDF from an HTTP(S) URL to the user's local machine. Works when Claude's WebFetch is blocked" + }, + { + "name": "create_signature", + "description": "Save a reusable typed-name or image signature to ~/.pdf-toolkit-files/signatures/" + }, + { + "name": "get_allowed_directories", + "description": "Report which folders this server may read and write, which configuration layer supplied them, and where the stored configuration file lives" + }, + { + "name": "list_signatures", + "description": "List all saved signatures" + }, + { + "name": "load_signature", + "description": "Load one saved signature by name, including preview data for image signatures" + }, + { + "name": "get_active_document", + "description": "Return the toolkit's current canonical PDF path, backup path, and last mutation metadata" + }, + { + "name": "set_active_document", + "description": "Rehydrate the toolkit's active document state from the currently open viewer session" + }, + { + "name": "add_signature_field", + "description": "Draw a visible 'Sign here' placeholder box on a PDF page (marks location only; does NOT sign)" + }, + { + "name": "apply_signature", + "description": "Stamp a saved signature at a specific location. Requires explicit human intent (user_intent_statement + user_confirmed_at). Basic local stamp, not cryptographic signing." + }, + { + "name": "prepare_signing_packet", + "description": "Fill form fields AND add sign-here placeholder boxes in one pass, returning a pending-signatures manifest" + }, + { + "name": "start_lumin_authorization", + "description": "Connect a Lumin account securely" + }, + { + "name": "finish_lumin_authorization", + "description": "Finish connecting a Lumin account" + }, + { + "name": "prepare_lumin_request", + "description": "Preview a Lumin signing request locally" + }, + { + "name": "send_lumin_request", + "description": "Send a confirmed signing request to Lumin" + }, + { + "name": "check_lumin_status", + "description": "Check an existing Lumin signing request" + }, + { + "name": "download_lumin_artifact", + "description": "Download a completed Lumin signing artifact" + }, + { + "name": "detect_signature_zones", + "description": "Find places in a PDF for signatures, initials, printed names, and dates. Use apply_signature for signatures and initials, and apply_text for names and dates. Never guess coordinates." + }, + { + "name": "apply_text", + "description": "Stamp a plain text string at a location (for date zones or arbitrary text fills; no intent required)" + }, + { + "name": "create_extraction_workspace", + "description": "Create a private local workspace bound to exact PDF and JSON Schema bytes" + }, + { + "name": "inspect_extraction_state", + "description": "Inspect the exact current generation and recovery state of a verified extraction workspace" + }, + { + "name": "read_extraction_workspace", + "description": "Read bounded pages of chunks, pending leaves, proposals, results, or events" + }, + { + "name": "read_extraction_chunk", + "description": "Rebuild one source-bound chunk from fresh local PDF bytes" + }, + { + "name": "submit_extraction_proposal", + "description": "Append one explicitly unverified schema-leaf proposal bound to source chunks" + }, + { + "name": "verify_extraction_proposal", + "description": "Deterministically replay and retain one cited extraction proposal without a model or network" + }, + { + "name": "verify_extraction_batch", + "description": "Replay up to 32 proposals and retain their individual statuses in one atomic generation" + }, + { + "name": "review_extraction_workspace", + "description": "Review source-bound values, citations, page coverage, and correction history" + }, + { + "name": "delete_extraction_workspace", + "description": "Delete a private extraction workspace only under exact identity and generation bindings" + }, + { + "name": "reveal_in_finder", + "description": "Open the OS file manager with the given file selected. Used by the viewer to surface the active PDF or its backup" + } + ], + "prompts": [ + { + "name": "view_and_analyze_pdf", + "description": "Open and analyze any PDF document", + "arguments": [ + "focus" + ], + "text": "Use PDF Tools to open a PDF stored on this computer and analyze it, focusing on ${arguments.focus}. If I haven't given you a path, start by listing the PDFs in my default folder so I can pick one. Then extract key findings, summarize sections, answer questions about specific pages, pull out structured data, and identify form fields if present." + }, + { + "name": "research_paper_analysis", + "description": "Analyze & summarize research papers", + "arguments": [ + "focus_area" + ], + "text": "Use PDF Tools to analyze a research paper stored on this computer, focusing on ${arguments.focus_area}. If I haven't given you a path, start by listing the PDFs in my default folder so I can pick one. Extract the key findings and methodology, identify research gaps, summarize it chapter by chapter, pull out the citations, and draft a literature review outline." + }, + { + "name": "contract_comparison", + "description": "Compare legal documents & contracts", + "arguments": [ + "comparison_focus" + ], + "text": "Use PDF Tools to compare contracts or legal documents stored on this computer, focusing on ${arguments.comparison_focus}. If I haven't given you their paths, start by listing the PDFs in my default folder so I can pick them. Highlight changed clauses, modified terms, added or removed sections, pricing differences, and liability changes, then summarize the comparison." + }, + { + "name": "financial_report_extraction", + "description": "Extract data from financial statements", + "arguments": [ + "output_format" + ], + "text": "Use PDF Tools to extract and analyze data from financial PDFs stored on this computer, including tables and key metrics. If I haven't given you a path, start by listing the PDFs in my default folder so I can pick one. Convert what you extract to ${arguments.output_format} format, cite the page for the values you extract, and tell me plainly about anything you could not extract." + }, + { + "name": "document_qa_session", + "description": "Interactive Q&A about your documents", + "arguments": [ + "initial_question" + ], + "text": "Use PDF Tools to open a PDF stored on this computer so we can discuss it. My first question is: ${arguments.initial_question}. If I haven't given you a path, start by listing the PDFs in my default folder so I can pick one. Find specific information, explain complex sections, cross-reference different parts, and quote passages with their page numbers." + }, + { + "name": "bulk_invoice_processing", + "description": "Process multiple invoices into structured data", + "arguments": [ + "folder_path", + "output_format" + ], + "text": "Use PDF Tools to process the invoice PDFs in the folder ${arguments.folder_path} on this computer and build a unified dataset in ${arguments.output_format} format. Start by listing the PDFs in that folder so I can see what you found. Extract vendor names, invoice numbers, dates, line items, totals, tax amounts, and payment terms, and tell me about any invoice you could not read." + }, + { + "name": "technical_documentation_summary", + "description": "Summarize technical manuals & documentation", + "arguments": [ + "summary_type" + ], + "text": "Use PDF Tools to analyze technical documentation stored on this computer and create ${arguments.summary_type}. If I haven't given you a path, start by listing the PDFs in my default folder so I can pick one. Extract the code examples, build a reference guide, identify the implementation steps, and summarize the troubleshooting sections." + }, + { + "name": "fill_w9_business", + "description": "Fill W-9 for business entity", + "text": "Use PDF Tools to fill out a W-9 stored on this computer for my business. If I haven't already given you the file, start by listing the PDFs in my default folder so I can point you at the right form, then read its fields. Ask me for the business legal name, the DBA or trade name if it differs, the business type (LLC, corporation, partnership), the tax classification, the EIN, and the business address. Check the correct tax classification boxes and tell me about any required field you could not fill." + }, + { + "name": "batch_invoices", + "description": "Process multiple invoices", + "text": "Use PDF Tools to batch process invoice PDFs stored on this computer. If I haven't already named them, start by listing the PDFs in my default folder so I can confirm which ones to use, or I'll name the folder they are in. Extract the amounts and dates, build a summary spreadsheet, flag missing information, total them by vendor, date, and category, and tell me about any invoice you could not read. Ask me which fields matter most before you start." + }, + { + "name": "rental_application", + "description": "Fill rental application", + "text": "Use PDF Tools to help me complete a rental application stored on this computer. If I haven't already given you the file, start by listing the PDFs in my default folder so I can point you at the right form, then read its fields. I'll have ready my personal details, three years of employment history, income verification, three years of previous addresses, references, vehicle information, and an emergency contact. Fill every required field and flag the ones that need supporting documents." + }, + { + "name": "extract_1099_data", + "description": "Extract data from 1099 forms", + "text": "Use PDF Tools to extract the data from 1099 forms stored on this computer (1099-NEC, 1099-MISC, 1099-DIV, 1099-INT and similar) and build a tax summary. If I haven't already named them, start by listing the PDFs in my default folder so I can confirm which ones to use. Organize by payer, identify the type of income, total each category, format it for import into tax software, and tell me about anything you could not read." + }, + { + "name": "merge_documents", + "description": "Combine multiple PDFs into one document", + "text": "Use PDF Tools to merge PDF files stored on this computer into one document. If I haven't already named them, start by listing the PDFs in my default folder so I can choose which ones to combine, then ask me where to save the result. Preserve the page order, and afterwards report the page count and the output path so I can check it. File operations happen locally; content you inspect is handled under my MCP host or model provider's data terms." + }, + { + "name": "split_large_document", + "description": "Split a PDF into smaller files", + "text": "Use PDF Tools to split a PDF stored on this computer into separate files. If I haven't given you a path, start by listing the PDFs in my default folder so I can pick one. I'll either give you exact page ranges (for example pages 1-10 and 11-20) or ask you to split at a regular interval (for example every 5 pages). Save each section as its own PDF and tell me where you wrote them." + }, + { + "name": "organize_scanned_pages", + "description": "Rotate and reorder scanned PDF pages", + "text": "Use PDF Tools to fix up a scanned PDF stored on this computer. If I haven't given you a path, start by listing the PDFs in my default folder so I can pick one. Rotate the pages I tell you are sideways or upside-down (90, 180, or 270 degrees) and reorder pages that were scanned out of sequence. Tell me the page order and rotations you plan to apply before you save, so I can confirm them." + } + ], + "user_config": { + "lumin_oauth_client_id": { + "type": "string", + "title": "Lumin OAuth Client ID", + "description": "Public client ID for an OAuth app registered with the exact redirect URI http://127.0.0.1/callback. This is an application identifier, not a client secret.", + "required": false + }, + "download_directory": { + "type": "directory", + "title": "Download Directory", + "description": "Where PDFs saved via fetch_pdf_from_url land by default. You can still override per-call with the destination_dir argument, but this is the default for new downloads.", + "default": "${HOME}/Downloads", + "required": false + }, + "default_pdf_directory": { + "type": "directory", + "title": "Default PDFs Folder", + "description": "Where list_pdfs looks when no directory is specified.", + "default": "${HOME}/Documents", + "required": false + }, + "allowed_directories": { + "type": "directory", + "title": "Allowed PDF Directories", + "description": "Folders this extension may read from or write to. PDF tools will refuse paths outside these folders. Profiles and saved signatures remain in the extension's private local store.", + "default": [ + "${HOME}/Documents", + "${HOME}/Downloads", + "${HOME}/Desktop" + ], + "multiple": true, + "required": false + } + }, + "server": { + "type": "node", + "entry_point": "server/index.js", + "mcp_config": { + "command": "node", + "args": [ + "${__dirname}/server/index.js", + "--allowed-directories", + "${user_config.allowed_directories}" + ], + "env": { + "DEFAULT_PDF_DIR": "${user_config.default_pdf_directory}", + "DEFAULT_PROFILES_DIR": "${HOME}/.pdf-toolkit-files", + "DEFAULT_DOWNLOAD_DIR": "${user_config.download_directory}", + "ALLOWED_DIRECTORIES": "${user_config.allowed_directories}", + "LUMIN_OAUTH_CLIENT_ID": "${user_config.lumin_oauth_client_id}" + } + } + } +} diff --git a/pdf-toolkit-mcp-share/server/helpers.js b/pdf-toolkit-mcp-share/server/helpers.js index bfadf96..e81242b 100644 --- a/pdf-toolkit-mcp-share/server/helpers.js +++ b/pdf-toolkit-mcp-share/server/helpers.js @@ -34,7 +34,7 @@ export const PDF_LIB_ENCRYPTED_MESSAGE = + "which has no decryption support, so supplying a password here will not help. " + "Decrypt the file first (for example with qpdf) and retry. " + "To read an encrypted PDF as it is, use read_pdf_layout, convert_pdf_to_markdown, " - + "or get_pdf_info, which accept a password and decrypt with PDF.js."; + + "or get_pdf_info after entering the password in the private window opened by unlock_pdf. Those tools decrypt with PDF.js."; // Parameter text for tools that decrypt with PDF.js for one part of their work // but still load page geometry through pdf-lib, which stops on encryption. diff --git a/pdf-toolkit-mcp-share/server/index.js b/pdf-toolkit-mcp-share/server/index.js index 11d0721..2684bc3 100755 --- a/pdf-toolkit-mcp-share/server/index.js +++ b/pdf-toolkit-mcp-share/server/index.js @@ -91,11 +91,12 @@ import { import { PDF_LIB_MUTATION_TOOL_NAMES, forceTerminateAllPdfLibMutations, - runPdfLibInspection, + runPdfLibInspection as runIsolatedPdfLibInspection, inspectHostPdfBytes, - runPdfLibMutation, + runPdfLibMutation as runIsolatedPdfLibMutation, terminateAllPdfLibMutations, } from "./pdf-lib-subprocess.js"; +import { createPrivatePdfCredentials } from "./private-pdf-credentials.js"; import { VERIFIED_EXTRACTION_TOOL_DEFINITIONS, createVerifiedExtractionToolHandler, @@ -1818,6 +1819,41 @@ async function bindPdfLibInspectionSource(resolvedPath) { return bindPdfjsSubprocessSource(resolvedPath); } +// Credentials only enter through the local user window. Dispatch uses the +// actual source envelope that the isolated parser will reopen and verify. +const privatePdfCredentials = createPrivatePdfCredentials({ + bindSource: async pdfPath => bindPdfjsSubprocessSource(resolvePath(pdfPath)), + validatePassword: async (source, password) => { + await runPdfjsSubprocess(createPdfjsSubprocessRequest({ + operation: "observe_document", + source, + password, + options: { max_pages: 1, max_output_characters: 20_000 }, + allowedDirectories: ALLOWED_DIRECTORIES.map(directory => directory.canonical), + }), { timeoutMs: 20_000 }); + return true; + }, + openWindow: url => new Promise((resolve, reject) => { + const platform = osPlatform(); + const command = platform === "darwin" ? "open" : platform === "win32" ? "rundll32.exe" : "xdg-open"; + const args = platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url]; + const child = spawn(command, args, { detached: true, stdio: "ignore" }); + child.once("error", () => reject(new Error("Unable to open the local PDF password window."))); + child.once("spawn", () => { child.unref(); resolve(); }); + }), +}); + +async function runPdfLibInspection(request, ...rest) { + const password = ["read_pdf_fields", "validate_pdf", "extract_to_csv"].includes(request.operation) + ? privatePdfCredentials.passwordForSources(request.sources) ?? null : null; + return runIsolatedPdfLibInspection({ ...request, password }, ...rest); +} + +async function runPdfLibMutation(request, ...rest) { + const password = privatePdfCredentials.passwordForSources(request.sources) ?? null; + return runIsolatedPdfLibMutation({ ...request, password }, ...rest); +} + async function runPdfjsOperation(resolvedPath, { operation, options, @@ -1825,6 +1861,7 @@ async function runPdfjsOperation(resolvedPath, { timeoutMs = 30_000, }) { const source = await bindPdfjsSubprocessSource(resolvedPath); + password = privatePdfCredentials.passwordForSource(source) ?? null; const result = await runPdfjsSubprocess(createPdfjsSubprocessRequest({ operation, source, @@ -2191,6 +2228,7 @@ for (const [signal, exitCode] of new Map([ process.once(signal, () => { if (workerShutdown !== null) return; workerShutdown = settleAllShutdownOperations([ + privatePdfCredentials.close(), terminateAllPdfjsSubprocesses(), terminateAllPdfLibMutations(), // A decryption worker is a live thread and keeps the process alive. It @@ -2203,6 +2241,7 @@ for (const [signal, exitCode] of new Map([ }); } process.once("exit", () => { + void privatePdfCredentials.close(); forceTerminateAllPdfjsSubprocesses(); forceTerminateAllPdfLibMutations(); forceTerminateAllDecryptionWorkers(); @@ -3329,14 +3368,9 @@ function formatCSVValue(value) { return `"${String(value ?? "").replace(/"/g, "\"\"")}"`; } -// read_pdf_content, read_pdf_pages, and search_pdf_text decrypt through PDF.js -// but expose no password argument, so PDF.js's stock "provide it with the -// password parameter" advice names an input those tools cannot accept. Name the -// read tools that do take one instead. const PDFJS_NO_PASSWORD_ARGUMENT_MESSAGE = - "This PDF is encrypted and this tool accepts no password. Use read_pdf_layout, " - + "convert_pdf_to_markdown, or get_pdf_info, which do accept a password, or decrypt " - + "the file first (for example with qpdf) and retry."; + "This PDF requires private password entry. Use unlock_pdf to open the local password window, " + + "enter the password there, then retry. Never put the password in chat or tool arguments."; function passwordlessReadError(error) { if (!["PASSWORD_REQUIRED", "PASSWORD_INCORRECT"].includes(error?.code)) return error; @@ -3346,10 +3380,39 @@ function passwordlessReadError(error) { } // List available tools +const PRIVATE_PDF_TOOL_NAMES = new Set(["unlock_pdf", "get_pdf_unlock_status", "lock_pdf"]); +const LEGACY_PDF_PASSWORD_KEYS = ["password", "before_password", "after_password"]; +const ENCRYPTED_GEOMETRY_UNSUPPORTED = new Set([ + "compare_pdfs", "render_pdf_page", "render_pdf_region", "get_page_analysis", "detect_signature_zones", +]); + +function withPrivatePasswordInputs(tool) { + const properties = { ...tool.inputSchema.properties }; + const hadPassword = LEGACY_PDF_PASSWORD_KEYS.some(key => Object.hasOwn(properties, key)); + for (const key of LEGACY_PDF_PASSWORD_KEYS) delete properties[key]; + const passwordGuidance = ENCRYPTED_GEOMETRY_UNSUPPORTED.has(tool.name) + ? " Encrypted PDFs remain unsupported by this operation, even after unlock_pdf. Private entry does not enable encrypted geometry or comparison. Use an unencrypted copy for this operation." + : " For password-protected PDFs, use unlock_pdf and enter the password in its local window before retrying. Never pass passwords in chat or tool arguments."; + return { + ...tool, + ...(hadPassword ? { description: `${tool.description}${passwordGuidance}` } : {}), + inputSchema: { ...tool.inputSchema, properties }, + }; +} + async function listTools(request) { rejectUnissuedCursor(request, "tools/list"); return { tools: [ + ...[ + ["unlock_pdf", "Open a private local window to enter this PDF's password. The password goes directly to PDF Tools and is never a tool argument. Access lasts at most ten minutes for this exact document in this server session. Does not grant modification permissions or remove encryption."], + ["get_pdf_unlock_status", "Check whether this exact PDF is unlocked in the current PDF Tools session. Returns status only, never a password or unlock link."], + ["lock_pdf", "Clear this PDF's in-memory password and cancel its pending unlock window. Already completed reads or saved outputs are not removed."], + ].map(([name, description]) => ({ + name, description, + inputSchema: { type: "object", properties: { pdf_path: { type: "string", minLength: 1, maxLength: 32768 } }, required: ["pdf_path"], additionalProperties: false }, + annotations: { title: { unlock_pdf: "Unlock PDF Privately", get_pdf_unlock_status: "PDF Unlock Status", lock_pdf: "Lock PDF" }[name], readOnlyHint: name === "get_pdf_unlock_status", destructiveHint: false, idempotentHint: name !== "unlock_pdf", openWorldHint: false }, + })), { name: "import_host_pdf", title: "Import Host PDF", @@ -4835,7 +4898,7 @@ async function listTools(request) { ...VERIFIED_EXTRACTION_TOOL_DEFINITIONS, EXTRACTION_REVIEW_TOOL_DEFINITION, ...(LOCAL_OCR_CONFIGURATION ? [LOCAL_OCR_TOOL_DEFINITION, ...LOCAL_OCR_JOB_TOOL_DEFINITIONS] : []), - ].map(withToolOutputSchema), + ].map(withPrivatePasswordInputs).map(withToolOutputSchema), }; } @@ -4844,6 +4907,26 @@ async function handleToolCall(request) { const { name, arguments: args } = request.params; try { + // Credential positions are rejected before any filesystem, parser or + // browser action. Free-form PDF field/schema names remain ordinary data. + if (args && LEGACY_PDF_PASSWORD_KEYS.some(key => Object.hasOwn(args, key))) { + return createTypedToolError({ + message: "PDF passwords are not accepted in tool arguments. Use unlock_pdf and enter the password in the local window. Never put a password in chat.", + code: "PRIVATE_PDF_PASSWORD_ARGUMENT_FORBIDDEN", + }); + } + if (PRIVATE_PDF_TOOL_NAMES.has(name)) { + if (!args || typeof args !== "object" || Array.isArray(args) + || Object.keys(args).length !== 1 || typeof args.pdf_path !== "string" + || args.pdf_path.length < 1 || args.pdf_path.length > 32768) { + throw new Error("This tool requires only a local pdf_path."); + } + const status = await (name === "unlock_pdf" ? privatePdfCredentials.requestUnlock(args.pdf_path) + : name === "lock_pdf" ? privatePdfCredentials.lock(args.pdf_path) + : privatePdfCredentials.status(args.pdf_path)); + // The service deliberately returns no password or browser capability. + return { content: [{ type: "text", text: JSON.stringify(status) }] }; + } if (name === LOCAL_OCR_TOOL_DEFINITION.name) { return await handleLocalOcrTool(args ?? {}); } @@ -4862,7 +4945,14 @@ async function handleToolCall(request) { }; } if (VERIFIED_EXTRACTION_TOOL_NAMES.has(name)) { - const structuredContent = await handleVerifiedExtractionTool(name, args ?? {}); + // The layout cache keys its private authorization context. Resolve it + // before cache access, not only later when a parser is invoked. + let extractionArgs = args ?? {}; + if (typeof extractionArgs.pdf_path === "string") { + const source = await bindPdfjsSubprocessSource(resolvePath(extractionArgs.pdf_path)); + extractionArgs = { ...extractionArgs, password: privatePdfCredentials.passwordForSource(source) ?? null }; + } + const structuredContent = await handleVerifiedExtractionTool(name, extractionArgs); const summary = name === "create_extraction_workspace" ? `Created verified extraction workspace ${structuredContent.workspace_identity_sha256} at generation ${structuredContent.generation_sha256}.` : name === "verify_extraction_proposal" @@ -7803,7 +7893,7 @@ async function handleToolCall(request) { : null; if (passwordCode) { const message = passwordCode === "PASSWORD_REQUIRED" - ? "This PDF requires a password. Provide it with the password parameter and try again." + ? "This PDF requires a password. Use unlock_pdf and enter it in the private local window, then retry." : "The PDF password was not accepted. Check the password and try again."; return { isError: true, @@ -8186,6 +8276,8 @@ function buildPdfToolsServer({ era }) { // Initialize and start the server async function main() { + process.stdin.once("end", () => { void privatePdfCredentials.close(); }); + process.stdin.once("close", () => { void privatePdfCredentials.close(); }); // Ensure profiles and signatures directories exist for (const directory of [PROFILES_DIR, SIGNATURES_DIR, BACKUPS_DIR]) { await fs.mkdir(directory, { recursive: true, mode: 0o700 }).catch(() => {}); diff --git a/pdf-toolkit-mcp-share/server/pdf-comparison.js b/pdf-toolkit-mcp-share/server/pdf-comparison.js index 4f42c4e..6da13a1 100644 --- a/pdf-toolkit-mcp-share/server/pdf-comparison.js +++ b/pdf-toolkit-mcp-share/server/pdf-comparison.js @@ -1200,7 +1200,7 @@ export function pdfComparisonEncryptedMessage(sides) { + "Rather than report a comparison of whichever channels survive, it stops. Decrypt both " + "documents first (for example with qpdf) and compare the decrypted copies. To read an " + "encrypted PDF as it is, use read_pdf_layout, convert_pdf_to_markdown, or get_pdf_info, " - + "which accept a password and decrypt with PDF.js."; + + "after entering the password in the private window opened by unlock_pdf. Those tools decrypt with PDF.js."; } export function pdfComparisonEncryptedError(sides) { @@ -1230,10 +1230,9 @@ export function pdfComparisonEncryptedError(sides) { */ export const PDF_COMPARISON_INVALID_INPUT_MESSAGE = "The compare_pdfs arguments or PDF inputs are invalid. It accepts only before_pdf_path, " - + "after_pdf_path, before_password, after_password, mode, max_pages, include_visual and " - + "max_output_characters, and rejects any other argument. Note that the passwords are named " - + "before_password and after_password โ€” compare_pdfs takes two documents, so it has no single " - + "'password' argument. Both paths must be absolute paths on this machine."; + + "after_pdf_path, mode, max_pages, include_visual and max_output_characters, and rejects " + + "any other argument. Passwords are never accepted in tool arguments. " + + "Both paths must be absolute paths on this machine."; export function publicPdfComparisonError(error) { const code = error?.code; diff --git a/pdf-toolkit-mcp-share/server/pdfjs-worker.js b/pdf-toolkit-mcp-share/server/pdfjs-worker.js index 937c3a5..d25bd6d 100644 --- a/pdf-toolkit-mcp-share/server/pdfjs-worker.js +++ b/pdf-toolkit-mcp-share/server/pdfjs-worker.js @@ -2204,7 +2204,7 @@ function passwordError(error, pdfjs) { if (error?.name !== "PasswordException" || !pdfjs?.PasswordResponses) return null; if (error.code === pdfjs.PasswordResponses.NEED_PASSWORD) { const mapped = new Error( - "This PDF requires a password. Provide it with the password parameter and try again.", + "This PDF requires a password. Use unlock_pdf and enter it in the private local window, then retry. Never put passwords in chat or tool arguments.", ); mapped.code = "PASSWORD_REQUIRED"; return mapped; diff --git a/pdf-toolkit-mcp-share/server/private-pdf-credentials.js b/pdf-toolkit-mcp-share/server/private-pdf-credentials.js new file mode 100644 index 0000000..9389d3f --- /dev/null +++ b/pdf-toolkit-mcp-share/server/private-pdf-credentials.js @@ -0,0 +1,375 @@ +import http from "node:http"; +import path from "node:path"; +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +const CREDENTIAL_TTL_MS = 10 * 60_000; +const WINDOW_TTL_MS = 5 * 60_000; +const MAX_DOCUMENTS = 8; +const MAX_ATTEMPTS = 5; +const MAX_BODY_BYTES = 4096; +const MAX_PASSWORD_BYTES = 1024; +const VALIDATION_TIMEOUT_MS = 30_000; + +const PAGE_SCRIPT = ` +(() => { + let token = location.hash.slice(1); + history.replaceState(null, "", location.pathname); + const message = document.getElementById("message"); + const password = document.getElementById("password"); + const unlock = document.getElementById("unlock"); + let pending = false; + async function send(endpoint, values = {}) { + const response = await fetch(endpoint, { method: "POST", credentials: "omit", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, ...values }) }); + return { ok: response.ok, data: await response.json() }; + } + async function refresh() { + try { + const result = await send("/session"); + if (!result.ok) { message.textContent = "This unlock window has expired. Request a new window in PDF Tools."; unlock.disabled = true; password.value = ""; return; } + document.getElementById("document").textContent = result.data.filename; + document.getElementById("source").textContent = "Document fingerprint: " + result.data.source_sha256; + message.textContent = result.data.status === "unlocked" + ? "Unlocked for this PDF Tools session. Access expires automatically after 10 minutes. You may close this window or lock the document now." + : "Enter this document's password. It stays in PDF Tools on this computer and is not sent to chat."; + unlock.disabled = result.data.status === "unlocked"; + } catch { message.textContent = "PDF Tools is no longer available. You can close this window."; unlock.disabled = true; } + } + document.getElementById("form").addEventListener("submit", async event => { + event.preventDefault(); + if (pending || unlock.disabled) return; + pending = true; unlock.disabled = true; + let value = password.value; password.value = ""; + try { + const promise = send("/unlock", { password: value }); value = ""; + const result = await promise; + message.textContent = result.ok ? "Document unlocked. You can return to PDF Tools." + : "The document could not be unlocked. Check the password or request a new unlock window in PDF Tools."; + unlock.disabled = result.ok; + } catch { message.textContent = "PDF Tools is no longer available. You can close this window."; } + finally { value = ""; pending = false; } + }); + for (const action of ["lock", "cancel"]) document.getElementById(action).addEventListener("click", async () => { + password.value = ""; unlock.disabled = true; + try { await send("/" + action); } catch {} + token = ""; + message.textContent = "Document locked. You can close this window."; + }); + addEventListener("pagehide", () => { password.value = ""; token = ""; }); + refresh(); +})();`; + +const PAGE = `Unlock PDF in PDF Tools

Unlock your PDF

PDF Tools

Opening private password entry...

Keep this window private. Access is limited to this document and ends when PDF Tools closes.

`; +const SCRIPT_SHA = createHash("sha256").update(PAGE_SCRIPT).digest("base64"); + +function fixedError(code) { + const error = new Error("Private PDF access is unavailable. Request a new unlock window in PDF Tools."); + error.code = code; + return error; +} + +function sourceIdentity(source) { + if (!source || typeof source.canonical_path !== "string" || !path.isAbsolute(source.canonical_path) || + !/^[a-f0-9]{64}$/.test(source.sha256) || !Number.isSafeInteger(source.size_bytes) || source.size_bytes < 0 || + !source.file_identity || !["number", "string", "bigint"].includes(typeof source.file_identity.device) || + !["number", "string", "bigint"].includes(typeof source.file_identity.inode)) { + throw fixedError("PRIVATE_PDF_SOURCE_INVALID"); + } + return JSON.stringify([source.canonical_path, String(source.file_identity.device), + String(source.file_identity.inode), source.sha256, source.size_bytes]); +} + +function tokenMatches(expected, actual) { + return typeof actual === "string" && /^[a-f0-9]{64}$/.test(actual) && + timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(actual, "hex")); +} + +function exactKeys(value, names) { + return value && typeof value === "object" && !Array.isArray(value) && + Object.keys(value).sort().join(",") === names.sort().join(","); +} + +// The injected callbacks are local trusted code. The service does not establish +// authenticity against another process running as the same OS user or root. +// Passwords stay in JavaScript memory; GC and OS swap do not guarantee erasure. +export function createPrivatePdfCredentials({ bindSource, validatePassword, openWindow, now = Date.now }) { + if (![bindSource, validatePassword, openWindow, now].every(value => typeof value === "function")) { + throw fixedError("PRIVATE_PDF_CONFIGURATION_INVALID"); + } + const documents = new Map(); + const windows = new Map(); + let server; + let starting; + let origin; + let closed = false; + let pendingBindings = 0; + let validating = false; + let attemptWindowStart = now(); + let globalAttempts = 0; + let cancelValidation; + + function clearDocument(entry, status = "locked") { + entry.password = undefined; + entry.expiresAt = 0; + entry.status = status; + entry.generation += 1; + if (cancelValidation?.entry === entry) cancelValidation.clear(); + for (const [token, session] of windows) if (session.entry === entry) windows.delete(token); + } + + function prune() { + const time = now(); + for (const entry of documents.values()) { + if (entry.password !== undefined && entry.expiresAt <= time) clearDocument(entry, "expired"); + } + for (const [token, session] of windows) { + if (session.expiresAt <= time) { + windows.delete(token); + if (session.entry.status === "awaiting_password") clearDocument(session.entry, "expired"); + } + } + } + + function result(entry) { + return entry?.password !== undefined + ? { status: "unlocked", expires_at: new Date(entry.expiresAt).toISOString() } + : { status: entry?.status ?? "locked" }; + } + + async function bind(pdfPath) { + if (closed || pendingBindings >= MAX_DOCUMENTS) throw fixedError("PRIVATE_PDF_UNAVAILABLE"); + pendingBindings += 1; + try { + const source = await bindSource(pdfPath); + if (closed) throw fixedError("PRIVATE_PDF_UNAVAILABLE"); + sourceIdentity(source); + return { ...source, file_identity: { ...source.file_identity } }; + } catch { + throw fixedError("PRIVATE_PDF_SOURCE_UNAVAILABLE"); + } finally { pendingBindings -= 1; } + } + + function lookup(source) { + prune(); + const key = sourceIdentity(source); + const entry = documents.get(source.canonical_path); + if (entry && entry.key !== key) { + clearDocument(entry); + documents.delete(source.canonical_path); + return undefined; + } + return entry; + } + + function respond(response, statusCode, value) { + response.writeHead(statusCode, { + "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", + "Pragma": "no-cache", "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'", + "Cross-Origin-Resource-Policy": "same-origin", "Connection": "close", + }); + response.end(JSON.stringify(value)); + } + + async function readBody(request) { + return new Promise((resolve, reject) => { + let bytes = 0; + const chunks = []; + const timer = setTimeout(() => { chunks.length = 0; reject(fixedError("PRIVATE_PDF_REQUEST_INVALID")); }, 5000); + const cleanup = () => clearTimeout(timer); + request.on("data", chunk => { + bytes += chunk.length; + if (bytes > MAX_BODY_BYTES) { chunks.length = 0; cleanup(); reject(fixedError("PRIVATE_PDF_REQUEST_INVALID")); } + else chunks.push(chunk); + }); + request.on("end", () => { + cleanup(); + try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } + catch { reject(fixedError("PRIVATE_PDF_REQUEST_INVALID")); } + finally { chunks.length = 0; } + }); + request.on("error", () => { cleanup(); chunks.length = 0; reject(fixedError("PRIVATE_PDF_REQUEST_INVALID")); }); + request.on("aborted", () => { cleanup(); chunks.length = 0; reject(fixedError("PRIVATE_PDF_REQUEST_INVALID")); }); + }); + } + + async function handle(request, response) { + const denied = (code = 403) => respond(response, code, { error: "Private PDF request rejected." }); + if (closed || request.socket.remoteAddress !== "127.0.0.1" || request.headers.host !== new URL(origin).host || + request.rawHeaders.filter((value, index) => index % 2 === 0 && value.toLowerCase() === "host").length !== 1) return denied(); + if (request.method === "GET" && request.url === "/") { + if (request.headers["sec-fetch-dest"] === "iframe") return denied(); + response.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "Pragma": "no-cache", + "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", + "Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Resource-Policy": "same-origin", + "Content-Security-Policy": `default-src 'none'; script-src 'sha256-${SCRIPT_SHA}'; style-src 'unsafe-inline'; connect-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'`, + }); + response.end(PAGE); + return; + } + if (request.method !== "POST" || !["/session", "/unlock", "/lock", "/cancel"].includes(request.url) || + request.headers.origin !== origin || !/^application\/json(?:;\s*charset=utf-8)?$/i.test(request.headers["content-type"] ?? "") || + Number(request.headers["content-length"] ?? 0) > MAX_BODY_BYTES) return denied(); + let body; + try { body = await readBody(request); } catch { return denied(400); } + prune(); + if (!exactKeys(body, request.url === "/unlock" ? ["token", "password"] : ["token"])) return denied(400); + const session = [...windows.values()].find(item => tokenMatches(item.token, body.token)); + if (!session || closed) return denied(); + const entry = session.entry; + if (request.url === "/lock" || request.url === "/cancel") { + clearDocument(entry); + return respond(response, 200, { status: "locked" }); + } + if (request.url === "/session") { + try { + if (lookup(await bind(entry.source.canonical_path)) !== entry) return denied(); + } catch { clearDocument(entry); return denied(); } + return respond(response, 200, { ...result(entry), filename: path.basename(entry.source.canonical_path), source_sha256: entry.source.sha256 }); + } + if (typeof body.password !== "string" || body.password.length === 0 || Buffer.byteLength(body.password, "utf8") > MAX_PASSWORD_BYTES) return denied(400); + if (entry.password !== undefined || validating || session.validating || session.attempts >= MAX_ATTEMPTS) return denied(409); + if (now() - attemptWindowStart >= 60_000) { attemptWindowStart = now(); globalAttempts = 0; } + if (globalAttempts >= 20) return denied(429); + session.attempts += 1; + globalAttempts += 1; + validating = true; + session.validating = true; + const generation = entry.generation; + let password = body.password; + body.password = undefined; + const cancellation = { entry, clear: () => { password = undefined; } }; + cancelValidation = cancellation; + let timer; + let timedOut = false; + // Retain the global validation lock until the actual callback ends, even if + // its HTTP timeout wins. A hung callback cannot create unbounded workers. + const work = (async () => { + const before = await bind(entry.source.canonical_path); + if (sourceIdentity(before) !== entry.key) { clearDocument(entry); return false; } + if (closed || generation !== entry.generation || windows.get(session.token) !== session || password === undefined) return false; + const valid = await validatePassword(before, password); + const after = await bind(entry.source.canonical_path); + if (sourceIdentity(after) !== entry.key) { clearDocument(entry); return false; } + return valid === true; + })(); + work.catch(() => {}).finally(() => { validating = false; session.validating = false; }); + try { + const valid = await Promise.race([work, new Promise(resolve => { + timer = setTimeout(() => { timedOut = true; resolve(false); }, VALIDATION_TIMEOUT_MS); + })]); + prune(); + if (!valid || timedOut || closed || generation !== entry.generation || windows.get(session.token) !== session || + documents.get(entry.source.canonical_path) !== entry || session.expiresAt <= now()) return denied(); + entry.password = password; + entry.expiresAt = now() + CREDENTIAL_TTL_MS; + entry.status = "unlocked"; + return respond(response, 200, result(entry)); + } catch { return denied(); } + finally { + clearTimeout(timer); + password = undefined; + if (cancelValidation === cancellation) cancelValidation = undefined; + } + } + + async function ensureServer() { + if (closed) throw fixedError("PRIVATE_PDF_UNAVAILABLE"); + if (starting) return starting; + starting = new Promise((resolve, reject) => { + server = http.createServer((request, response) => { + handle(request, response).catch(() => { + if (!response.headersSent) respond(response, 500, { error: "Private PDF request rejected." }); + else response.destroy(); + }); + }); + server.maxConnections = 16; + server.requestTimeout = 10_000; + server.headersTimeout = 5000; + server.keepAliveTimeout = 1000; + server.on("clientError", (_error, socket) => socket.destroy()); + server.once("error", () => reject(fixedError("PRIVATE_PDF_UNAVAILABLE"))); + server.listen(0, "127.0.0.1", () => { + if (closed) { server.close(); reject(fixedError("PRIVATE_PDF_UNAVAILABLE")); return; } + origin = `http://127.0.0.1:${server.address().port}`; + server.unref(); + resolve(); + }); + }); + try { await starting; } catch { starting = undefined; throw fixedError("PRIVATE_PDF_UNAVAILABLE"); } + } + + const expiryTimer = setInterval(prune, 1000); + expiryTimer.unref(); + + function passwordForSource(source) { + if (closed) return undefined; + return lookup(source)?.password; + } + + return { + async requestUnlock(pdfPath) { + const source = await bind(pdfPath); + let entry = lookup(source); + if (entry?.password !== undefined) return result(entry); + if (!entry) { + // Locked/expired records carry no secret and can be evicted to admit a + // new document. Active credentials and windows count against the cap. + for (const [name, item] of documents) { + if (documents.size < MAX_DOCUMENTS) break; + if (!item.password && item.status !== "awaiting_password") documents.delete(name); + } + if (documents.size >= MAX_DOCUMENTS) throw fixedError("PRIVATE_PDF_CAPACITY_REACHED"); + entry = { source, key: sourceIdentity(source), password: undefined, expiresAt: 0, generation: 0, status: "locked" }; + documents.set(source.canonical_path, entry); + } + clearDocument(entry); + const generation = entry.generation; + await ensureServer(); + if (closed || entry.generation !== generation || documents.get(source.canonical_path) !== entry) throw fixedError("PRIVATE_PDF_UNAVAILABLE"); + const token = randomBytes(32).toString("hex"); + const session = { token, entry, expiresAt: now() + WINDOW_TTL_MS, attempts: 0, validating: false }; + windows.set(token, session); + entry.status = "awaiting_password"; + try { await openWindow(`${origin}/#${token}`); } + catch { + if (windows.get(token) === session) clearDocument(entry); + throw fixedError("PRIVATE_PDF_WINDOW_UNAVAILABLE"); + } + if (closed || windows.get(token) !== session) throw fixedError("PRIVATE_PDF_UNAVAILABLE"); + return result(entry); + }, + async status(pdfPath) { + const source = await bind(pdfPath); + return result(lookup(source)); + }, + async lock(pdfPath) { + // Invalidate before any asynchronous source operation. A concurrent + // validation cannot restore a credential after the lock starts. + const direct = documents.get(pdfPath); + if (direct) clearDocument(direct); + const source = await bind(pdfPath); + const entry = documents.get(source.canonical_path); + if (entry) clearDocument(entry); + return { status: "locked" }; + }, + passwordForSource, + passwordForSources(sources) { + if (!Array.isArray(sources) || sources.length === 0) return undefined; + const passwords = sources.map(source => passwordForSource(source)); + if (passwords.every(value => value === undefined)) return undefined; + if (passwords.some(value => value === undefined || value !== passwords[0])) throw fixedError("PRIVATE_PDF_MULTIPLE_DOCUMENTS_LOCKED"); + return passwords[0]; + }, + async close() { + closed = true; + clearInterval(expiryTimer); + for (const entry of documents.values()) clearDocument(entry); + documents.clear(); windows.clear(); + if (server) await new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }); + }, + }; +} diff --git a/pdf-toolkit-mcp-share/server/qpdf-decrypt.js b/pdf-toolkit-mcp-share/server/qpdf-decrypt.js index 6a17eee..067b5d0 100644 --- a/pdf-toolkit-mcp-share/server/qpdf-decrypt.js +++ b/pdf-toolkit-mcp-share/server/qpdf-decrypt.js @@ -397,7 +397,7 @@ export const PDF_PASSWORD_REJECTED_MESSAGE = export const PDF_PASSWORD_REQUIRED_MESSAGE = "This PDF is encrypted and cannot be opened without a password. Supply the document's " - + "password in the 'password' parameter and retry."; + + "password in the private window opened by unlock_pdf and retry. Never put passwords in chat or tool arguments."; export const PDF_PASSWORD_UNREPRESENTABLE_MESSAGE = "This password cannot be used: it contains a line break. Passwords are handed to the decryption " @@ -477,8 +477,8 @@ export function pdfWritePermissionDeniedMessage(operation, deniedCapabilities) { .join(" and "); return `This PDF is encrypted and its permissions deny ${named}, so PDF Tools will not ` + `${activity}. Supplying the user password proves you may open the document, but not that you ` - + "may override a restriction its owner set. If you hold the owner password, supply it in the " - + "'password' parameter and retry; that is the credential which authorises this change."; + + "may override a restriction its owner set. If you hold the owner password, enter it in the " + + "private window opened by unlock_pdf and retry; that is the credential which authorises this change."; } /** diff --git a/pdf-toolkit-mcp-share/server/verified-extraction-tools.js b/pdf-toolkit-mcp-share/server/verified-extraction-tools.js index 65fc73a..f46cc08 100644 --- a/pdf-toolkit-mcp-share/server/verified-extraction-tools.js +++ b/pdf-toolkit-mcp-share/server/verified-extraction-tools.js @@ -210,7 +210,6 @@ export const VERIFIED_EXTRACTION_TOOL_DEFINITIONS = Object.freeze([ additionalProperties: false, properties: { pdf_path: { type: "string" }, - password: { type: "string" }, workspace_id: { type: "string", pattern: "^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$" }, schema: { type: "object" }, max_pages: { type: "integer", minimum: 1, maximum: 200 }, @@ -266,7 +265,6 @@ export const VERIFIED_EXTRACTION_TOOL_DEFINITIONS = Object.freeze([ additionalProperties: false, properties: { pdf_path: { type: "string" }, - password: { type: "string" }, workspace_id: { type: "string" }, workspace_identity_sha256: { type: "string", pattern: "^[a-f0-9]{64}$" }, chunk_id: { type: "string", pattern: "^chunk\\.[a-f0-9]{64}$" }, @@ -305,7 +303,6 @@ export const VERIFIED_EXTRACTION_TOOL_DEFINITIONS = Object.freeze([ additionalProperties: false, properties: { pdf_path: { type: "string" }, - password: { type: "string" }, workspace_id: { type: "string" }, workspace_identity_sha256: { type: "string", pattern: "^[a-f0-9]{64}$" }, proposal_generation_sha256: { type: "string", pattern: "^[a-f0-9]{64}$" }, @@ -334,7 +331,6 @@ export const VERIFIED_EXTRACTION_TOOL_DEFINITIONS = Object.freeze([ type: "object", additionalProperties: false, properties: { pdf_path: { type: "string" }, - password: { type: "string" }, workspace_id: { type: "string" }, workspace_identity_sha256: { type: "string", pattern: "^[a-f0-9]{64}$" }, parent_generation_sha256: { type: "string", pattern: "^[a-f0-9]{64}$" }, diff --git a/plugins/pdf-tools-workflow/skills/pdf-tools-workflow/SKILL.md b/plugins/pdf-tools-workflow/skills/pdf-tools-workflow/SKILL.md index 429ead0..5da901e 100644 --- a/plugins/pdf-tools-workflow/skills/pdf-tools-workflow/SKILL.md +++ b/plugins/pdf-tools-workflow/skills/pdf-tools-workflow/SKILL.md @@ -308,8 +308,13 @@ Use these stage semantics: report `CONTENT_UNAVAILABLE_PASSWORD_REQUIRED`, plan no tools, and list the failed PDF content-read tool as prohibited under the current decision. Do not retry it or substitute another password-dependent content read. - Reconsider content reads only in a new planning turn after the password is - supplied. + Offer unlock_pdf so the user can enter the password in its private local + window. Never ask for a password in chat or pass password, before_password, + or after_password as a tool argument. Reconsider content reads after + get_pdf_unlock_status reports unlocked. Use lock_pdf to clear session access; + access also expires after ten minutes or when the source changes. A schema + label such as pdf_password in a planning record means missing private access, + never a request to collect the credential in the record. Use these record boundaries: diff --git a/scripts/build-mcpb.mjs b/scripts/build-mcpb.mjs index e861653..ad7562d 100644 --- a/scripts/build-mcpb.mjs +++ b/scripts/build-mcpb.mjs @@ -107,6 +107,7 @@ export const SERVER_FILES = [ "pdfjs-worker.js", "qpdf-decrypt-worker.js", "qpdf-decrypt.js", + "private-pdf-credentials.js", "resource-uri.js", "skill-files.js", "skills.js", diff --git a/server/helpers.js b/server/helpers.js index bfadf96..e81242b 100644 --- a/server/helpers.js +++ b/server/helpers.js @@ -34,7 +34,7 @@ export const PDF_LIB_ENCRYPTED_MESSAGE = + "which has no decryption support, so supplying a password here will not help. " + "Decrypt the file first (for example with qpdf) and retry. " + "To read an encrypted PDF as it is, use read_pdf_layout, convert_pdf_to_markdown, " - + "or get_pdf_info, which accept a password and decrypt with PDF.js."; + + "or get_pdf_info after entering the password in the private window opened by unlock_pdf. Those tools decrypt with PDF.js."; // Parameter text for tools that decrypt with PDF.js for one part of their work // but still load page geometry through pdf-lib, which stops on encryption. diff --git a/server/index.js b/server/index.js index 11d0721..2684bc3 100755 --- a/server/index.js +++ b/server/index.js @@ -91,11 +91,12 @@ import { import { PDF_LIB_MUTATION_TOOL_NAMES, forceTerminateAllPdfLibMutations, - runPdfLibInspection, + runPdfLibInspection as runIsolatedPdfLibInspection, inspectHostPdfBytes, - runPdfLibMutation, + runPdfLibMutation as runIsolatedPdfLibMutation, terminateAllPdfLibMutations, } from "./pdf-lib-subprocess.js"; +import { createPrivatePdfCredentials } from "./private-pdf-credentials.js"; import { VERIFIED_EXTRACTION_TOOL_DEFINITIONS, createVerifiedExtractionToolHandler, @@ -1818,6 +1819,41 @@ async function bindPdfLibInspectionSource(resolvedPath) { return bindPdfjsSubprocessSource(resolvedPath); } +// Credentials only enter through the local user window. Dispatch uses the +// actual source envelope that the isolated parser will reopen and verify. +const privatePdfCredentials = createPrivatePdfCredentials({ + bindSource: async pdfPath => bindPdfjsSubprocessSource(resolvePath(pdfPath)), + validatePassword: async (source, password) => { + await runPdfjsSubprocess(createPdfjsSubprocessRequest({ + operation: "observe_document", + source, + password, + options: { max_pages: 1, max_output_characters: 20_000 }, + allowedDirectories: ALLOWED_DIRECTORIES.map(directory => directory.canonical), + }), { timeoutMs: 20_000 }); + return true; + }, + openWindow: url => new Promise((resolve, reject) => { + const platform = osPlatform(); + const command = platform === "darwin" ? "open" : platform === "win32" ? "rundll32.exe" : "xdg-open"; + const args = platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url]; + const child = spawn(command, args, { detached: true, stdio: "ignore" }); + child.once("error", () => reject(new Error("Unable to open the local PDF password window."))); + child.once("spawn", () => { child.unref(); resolve(); }); + }), +}); + +async function runPdfLibInspection(request, ...rest) { + const password = ["read_pdf_fields", "validate_pdf", "extract_to_csv"].includes(request.operation) + ? privatePdfCredentials.passwordForSources(request.sources) ?? null : null; + return runIsolatedPdfLibInspection({ ...request, password }, ...rest); +} + +async function runPdfLibMutation(request, ...rest) { + const password = privatePdfCredentials.passwordForSources(request.sources) ?? null; + return runIsolatedPdfLibMutation({ ...request, password }, ...rest); +} + async function runPdfjsOperation(resolvedPath, { operation, options, @@ -1825,6 +1861,7 @@ async function runPdfjsOperation(resolvedPath, { timeoutMs = 30_000, }) { const source = await bindPdfjsSubprocessSource(resolvedPath); + password = privatePdfCredentials.passwordForSource(source) ?? null; const result = await runPdfjsSubprocess(createPdfjsSubprocessRequest({ operation, source, @@ -2191,6 +2228,7 @@ for (const [signal, exitCode] of new Map([ process.once(signal, () => { if (workerShutdown !== null) return; workerShutdown = settleAllShutdownOperations([ + privatePdfCredentials.close(), terminateAllPdfjsSubprocesses(), terminateAllPdfLibMutations(), // A decryption worker is a live thread and keeps the process alive. It @@ -2203,6 +2241,7 @@ for (const [signal, exitCode] of new Map([ }); } process.once("exit", () => { + void privatePdfCredentials.close(); forceTerminateAllPdfjsSubprocesses(); forceTerminateAllPdfLibMutations(); forceTerminateAllDecryptionWorkers(); @@ -3329,14 +3368,9 @@ function formatCSVValue(value) { return `"${String(value ?? "").replace(/"/g, "\"\"")}"`; } -// read_pdf_content, read_pdf_pages, and search_pdf_text decrypt through PDF.js -// but expose no password argument, so PDF.js's stock "provide it with the -// password parameter" advice names an input those tools cannot accept. Name the -// read tools that do take one instead. const PDFJS_NO_PASSWORD_ARGUMENT_MESSAGE = - "This PDF is encrypted and this tool accepts no password. Use read_pdf_layout, " - + "convert_pdf_to_markdown, or get_pdf_info, which do accept a password, or decrypt " - + "the file first (for example with qpdf) and retry."; + "This PDF requires private password entry. Use unlock_pdf to open the local password window, " + + "enter the password there, then retry. Never put the password in chat or tool arguments."; function passwordlessReadError(error) { if (!["PASSWORD_REQUIRED", "PASSWORD_INCORRECT"].includes(error?.code)) return error; @@ -3346,10 +3380,39 @@ function passwordlessReadError(error) { } // List available tools +const PRIVATE_PDF_TOOL_NAMES = new Set(["unlock_pdf", "get_pdf_unlock_status", "lock_pdf"]); +const LEGACY_PDF_PASSWORD_KEYS = ["password", "before_password", "after_password"]; +const ENCRYPTED_GEOMETRY_UNSUPPORTED = new Set([ + "compare_pdfs", "render_pdf_page", "render_pdf_region", "get_page_analysis", "detect_signature_zones", +]); + +function withPrivatePasswordInputs(tool) { + const properties = { ...tool.inputSchema.properties }; + const hadPassword = LEGACY_PDF_PASSWORD_KEYS.some(key => Object.hasOwn(properties, key)); + for (const key of LEGACY_PDF_PASSWORD_KEYS) delete properties[key]; + const passwordGuidance = ENCRYPTED_GEOMETRY_UNSUPPORTED.has(tool.name) + ? " Encrypted PDFs remain unsupported by this operation, even after unlock_pdf. Private entry does not enable encrypted geometry or comparison. Use an unencrypted copy for this operation." + : " For password-protected PDFs, use unlock_pdf and enter the password in its local window before retrying. Never pass passwords in chat or tool arguments."; + return { + ...tool, + ...(hadPassword ? { description: `${tool.description}${passwordGuidance}` } : {}), + inputSchema: { ...tool.inputSchema, properties }, + }; +} + async function listTools(request) { rejectUnissuedCursor(request, "tools/list"); return { tools: [ + ...[ + ["unlock_pdf", "Open a private local window to enter this PDF's password. The password goes directly to PDF Tools and is never a tool argument. Access lasts at most ten minutes for this exact document in this server session. Does not grant modification permissions or remove encryption."], + ["get_pdf_unlock_status", "Check whether this exact PDF is unlocked in the current PDF Tools session. Returns status only, never a password or unlock link."], + ["lock_pdf", "Clear this PDF's in-memory password and cancel its pending unlock window. Already completed reads or saved outputs are not removed."], + ].map(([name, description]) => ({ + name, description, + inputSchema: { type: "object", properties: { pdf_path: { type: "string", minLength: 1, maxLength: 32768 } }, required: ["pdf_path"], additionalProperties: false }, + annotations: { title: { unlock_pdf: "Unlock PDF Privately", get_pdf_unlock_status: "PDF Unlock Status", lock_pdf: "Lock PDF" }[name], readOnlyHint: name === "get_pdf_unlock_status", destructiveHint: false, idempotentHint: name !== "unlock_pdf", openWorldHint: false }, + })), { name: "import_host_pdf", title: "Import Host PDF", @@ -4835,7 +4898,7 @@ async function listTools(request) { ...VERIFIED_EXTRACTION_TOOL_DEFINITIONS, EXTRACTION_REVIEW_TOOL_DEFINITION, ...(LOCAL_OCR_CONFIGURATION ? [LOCAL_OCR_TOOL_DEFINITION, ...LOCAL_OCR_JOB_TOOL_DEFINITIONS] : []), - ].map(withToolOutputSchema), + ].map(withPrivatePasswordInputs).map(withToolOutputSchema), }; } @@ -4844,6 +4907,26 @@ async function handleToolCall(request) { const { name, arguments: args } = request.params; try { + // Credential positions are rejected before any filesystem, parser or + // browser action. Free-form PDF field/schema names remain ordinary data. + if (args && LEGACY_PDF_PASSWORD_KEYS.some(key => Object.hasOwn(args, key))) { + return createTypedToolError({ + message: "PDF passwords are not accepted in tool arguments. Use unlock_pdf and enter the password in the local window. Never put a password in chat.", + code: "PRIVATE_PDF_PASSWORD_ARGUMENT_FORBIDDEN", + }); + } + if (PRIVATE_PDF_TOOL_NAMES.has(name)) { + if (!args || typeof args !== "object" || Array.isArray(args) + || Object.keys(args).length !== 1 || typeof args.pdf_path !== "string" + || args.pdf_path.length < 1 || args.pdf_path.length > 32768) { + throw new Error("This tool requires only a local pdf_path."); + } + const status = await (name === "unlock_pdf" ? privatePdfCredentials.requestUnlock(args.pdf_path) + : name === "lock_pdf" ? privatePdfCredentials.lock(args.pdf_path) + : privatePdfCredentials.status(args.pdf_path)); + // The service deliberately returns no password or browser capability. + return { content: [{ type: "text", text: JSON.stringify(status) }] }; + } if (name === LOCAL_OCR_TOOL_DEFINITION.name) { return await handleLocalOcrTool(args ?? {}); } @@ -4862,7 +4945,14 @@ async function handleToolCall(request) { }; } if (VERIFIED_EXTRACTION_TOOL_NAMES.has(name)) { - const structuredContent = await handleVerifiedExtractionTool(name, args ?? {}); + // The layout cache keys its private authorization context. Resolve it + // before cache access, not only later when a parser is invoked. + let extractionArgs = args ?? {}; + if (typeof extractionArgs.pdf_path === "string") { + const source = await bindPdfjsSubprocessSource(resolvePath(extractionArgs.pdf_path)); + extractionArgs = { ...extractionArgs, password: privatePdfCredentials.passwordForSource(source) ?? null }; + } + const structuredContent = await handleVerifiedExtractionTool(name, extractionArgs); const summary = name === "create_extraction_workspace" ? `Created verified extraction workspace ${structuredContent.workspace_identity_sha256} at generation ${structuredContent.generation_sha256}.` : name === "verify_extraction_proposal" @@ -7803,7 +7893,7 @@ async function handleToolCall(request) { : null; if (passwordCode) { const message = passwordCode === "PASSWORD_REQUIRED" - ? "This PDF requires a password. Provide it with the password parameter and try again." + ? "This PDF requires a password. Use unlock_pdf and enter it in the private local window, then retry." : "The PDF password was not accepted. Check the password and try again."; return { isError: true, @@ -8186,6 +8276,8 @@ function buildPdfToolsServer({ era }) { // Initialize and start the server async function main() { + process.stdin.once("end", () => { void privatePdfCredentials.close(); }); + process.stdin.once("close", () => { void privatePdfCredentials.close(); }); // Ensure profiles and signatures directories exist for (const directory of [PROFILES_DIR, SIGNATURES_DIR, BACKUPS_DIR]) { await fs.mkdir(directory, { recursive: true, mode: 0o700 }).catch(() => {}); diff --git a/server/pdf-comparison.js b/server/pdf-comparison.js index 4f42c4e..6da13a1 100644 --- a/server/pdf-comparison.js +++ b/server/pdf-comparison.js @@ -1200,7 +1200,7 @@ export function pdfComparisonEncryptedMessage(sides) { + "Rather than report a comparison of whichever channels survive, it stops. Decrypt both " + "documents first (for example with qpdf) and compare the decrypted copies. To read an " + "encrypted PDF as it is, use read_pdf_layout, convert_pdf_to_markdown, or get_pdf_info, " - + "which accept a password and decrypt with PDF.js."; + + "after entering the password in the private window opened by unlock_pdf. Those tools decrypt with PDF.js."; } export function pdfComparisonEncryptedError(sides) { @@ -1230,10 +1230,9 @@ export function pdfComparisonEncryptedError(sides) { */ export const PDF_COMPARISON_INVALID_INPUT_MESSAGE = "The compare_pdfs arguments or PDF inputs are invalid. It accepts only before_pdf_path, " - + "after_pdf_path, before_password, after_password, mode, max_pages, include_visual and " - + "max_output_characters, and rejects any other argument. Note that the passwords are named " - + "before_password and after_password โ€” compare_pdfs takes two documents, so it has no single " - + "'password' argument. Both paths must be absolute paths on this machine."; + + "after_pdf_path, mode, max_pages, include_visual and max_output_characters, and rejects " + + "any other argument. Passwords are never accepted in tool arguments. " + + "Both paths must be absolute paths on this machine."; export function publicPdfComparisonError(error) { const code = error?.code; diff --git a/server/pdfjs-worker.js b/server/pdfjs-worker.js index 937c3a5..d25bd6d 100644 --- a/server/pdfjs-worker.js +++ b/server/pdfjs-worker.js @@ -2204,7 +2204,7 @@ function passwordError(error, pdfjs) { if (error?.name !== "PasswordException" || !pdfjs?.PasswordResponses) return null; if (error.code === pdfjs.PasswordResponses.NEED_PASSWORD) { const mapped = new Error( - "This PDF requires a password. Provide it with the password parameter and try again.", + "This PDF requires a password. Use unlock_pdf and enter it in the private local window, then retry. Never put passwords in chat or tool arguments.", ); mapped.code = "PASSWORD_REQUIRED"; return mapped; diff --git a/server/qpdf-decrypt.js b/server/qpdf-decrypt.js index 6a17eee..067b5d0 100644 --- a/server/qpdf-decrypt.js +++ b/server/qpdf-decrypt.js @@ -397,7 +397,7 @@ export const PDF_PASSWORD_REJECTED_MESSAGE = export const PDF_PASSWORD_REQUIRED_MESSAGE = "This PDF is encrypted and cannot be opened without a password. Supply the document's " - + "password in the 'password' parameter and retry."; + + "password in the private window opened by unlock_pdf and retry. Never put passwords in chat or tool arguments."; export const PDF_PASSWORD_UNREPRESENTABLE_MESSAGE = "This password cannot be used: it contains a line break. Passwords are handed to the decryption " @@ -477,8 +477,8 @@ export function pdfWritePermissionDeniedMessage(operation, deniedCapabilities) { .join(" and "); return `This PDF is encrypted and its permissions deny ${named}, so PDF Tools will not ` + `${activity}. Supplying the user password proves you may open the document, but not that you ` - + "may override a restriction its owner set. If you hold the owner password, supply it in the " - + "'password' parameter and retry; that is the credential which authorises this change."; + + "may override a restriction its owner set. If you hold the owner password, enter it in the " + + "private window opened by unlock_pdf and retry; that is the credential which authorises this change."; } /** diff --git a/server/verified-extraction-tools.js b/server/verified-extraction-tools.js index 65fc73a..f46cc08 100644 --- a/server/verified-extraction-tools.js +++ b/server/verified-extraction-tools.js @@ -210,7 +210,6 @@ export const VERIFIED_EXTRACTION_TOOL_DEFINITIONS = Object.freeze([ additionalProperties: false, properties: { pdf_path: { type: "string" }, - password: { type: "string" }, workspace_id: { type: "string", pattern: "^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$" }, schema: { type: "object" }, max_pages: { type: "integer", minimum: 1, maximum: 200 }, @@ -266,7 +265,6 @@ export const VERIFIED_EXTRACTION_TOOL_DEFINITIONS = Object.freeze([ additionalProperties: false, properties: { pdf_path: { type: "string" }, - password: { type: "string" }, workspace_id: { type: "string" }, workspace_identity_sha256: { type: "string", pattern: "^[a-f0-9]{64}$" }, chunk_id: { type: "string", pattern: "^chunk\\.[a-f0-9]{64}$" }, @@ -305,7 +303,6 @@ export const VERIFIED_EXTRACTION_TOOL_DEFINITIONS = Object.freeze([ additionalProperties: false, properties: { pdf_path: { type: "string" }, - password: { type: "string" }, workspace_id: { type: "string" }, workspace_identity_sha256: { type: "string", pattern: "^[a-f0-9]{64}$" }, proposal_generation_sha256: { type: "string", pattern: "^[a-f0-9]{64}$" }, @@ -334,7 +331,6 @@ export const VERIFIED_EXTRACTION_TOOL_DEFINITIONS = Object.freeze([ type: "object", additionalProperties: false, properties: { pdf_path: { type: "string" }, - password: { type: "string" }, workspace_id: { type: "string" }, workspace_identity_sha256: { type: "string", pattern: "^[a-f0-9]{64}$" }, parent_generation_sha256: { type: "string", pattern: "^[a-f0-9]{64}$" }, diff --git a/test/compare-pdfs.test.js b/test/compare-pdfs.test.js index 27ceba0..2adcfbb 100644 --- a/test/compare-pdfs.test.js +++ b/test/compare-pdfs.test.js @@ -14,6 +14,7 @@ import { } from "../server/pdf-comparison.js"; import { PDF_LIB_ENCRYPTED_MESSAGE } from "../server/helpers.js"; import { createTestTempDirectory, removeTestTempDirectory } from "./helpers/temp-directory.js"; +import { createPrivatePdfUnlockHarness } from "./helpers/private-pdf-unlock.js"; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const FIXTURES = path.join(REPO_ROOT, "test/fixtures/eval/comparison/synthetic"); @@ -47,15 +48,17 @@ describe("compare_pdfs deterministic product", () => { let transport; let materialResult; let tempDirectory; + let unlock; beforeAll(async () => { tempDirectory = await createTestTempDirectory(REPO_ROOT, "compare-pdfs"); + unlock = await createPrivatePdfUnlockHarness(tempDirectory); client = new Client({ name: "compare-pdfs-test", version: "1.0.0" }); transport = new StdioClientTransport({ command: process.execPath, args: [path.join(REPO_ROOT, "server/index.js")], cwd: REPO_ROOT, - env: { ...process.env, ALLOWED_DIRECTORIES: REPO_ROOT }, + env: { ...process.env, ...unlock.env, ALLOWED_DIRECTORIES: REPO_ROOT }, stderr: "pipe", }); await client.connect(transport); @@ -88,12 +91,12 @@ describe("compare_pdfs deterministic product", () => { }, }); expect(tool.inputSchema.properties).toEqual(expect.objectContaining({ - before_password: expect.objectContaining({ maxLength: 4096 }), - after_password: expect.objectContaining({ maxLength: 4096 }), mode: expect.objectContaining({ enum: ["default_material", "forensic"] }), max_pages: expect.objectContaining({ minimum: 1, maximum: 20 }), max_output_characters: expect.objectContaining({ minimum: 20000, maximum: 200000 }), })); + expect(tool.inputSchema.properties).not.toHaveProperty("before_password"); + expect(tool.inputSchema.properties).not.toHaveProperty("after_password"); }); it.each(Object.entries(PAIRS))("discriminates the public synthetic %s comparison role", async (role, [afterName, expectedKinds]) => { @@ -404,9 +407,7 @@ describe("compare_pdfs deterministic product", () => { * and this tool takes two, so the refusal says so by name. */ const unknownText = unknown.content?.map(part => part.text ?? "").join("\n") ?? ""; - expect(unknownText).toContain("before_password"); - expect(unknownText).toContain("after_password"); - expect(unknownText).toMatch(/no single 'password' argument/); + expect(unknownText).toContain("Passwords are never tool arguments"); expect(unknownText).toContain("absolute"); // Naming the accepted arguments is safe โ€” they are in the published input // schema. Naming the caller's own rejected key is not, and never happens. @@ -458,7 +459,7 @@ describe("compare_pdfs deterministic product", () => { // the right one would work. It would not: no password makes an encrypted // comparison run, so the refusal is the encryption one either way. expect(protectedResult.structuredContent.error.code) - .toBe("PDF_ENCRYPTED_COMPARISON_UNSUPPORTED"); + .toBe("PRIVATE_PDF_PASSWORD_ARGUMENT_FORBIDDEN"); expect(JSON.stringify(protectedResult)).not.toContain(passwordSentinel); }); @@ -525,7 +526,13 @@ describe("compare_pdfs deterministic product", () => { }], ]; for (const [label, sides, args] of cases) { - const result = await compare(args); + await unlock.lock(client, ENCRYPTED); + const { before_password, after_password, ...publicArgs } = args; + const privatePassword = before_password ?? after_password; + if (privatePassword) await unlock.enter(client, ENCRYPTED, privatePassword, { + accepted: privatePassword !== passwords.wrong_password_oracle, + }); + const result = await compare(publicArgs); expect(result.isError, label).toBe(true); // Asserted on the SERVED response, at the MCP boundary. A module that // returns the right thing is not the same claim as a caller receiving diff --git a/test/convert-pdf-to-markdown.test.js b/test/convert-pdf-to-markdown.test.js index 413ffc2..769c64e 100644 --- a/test/convert-pdf-to-markdown.test.js +++ b/test/convert-pdf-to-markdown.test.js @@ -9,6 +9,7 @@ import { PDFDocument, PDFName, PDFNumber, PDFString, StandardFonts, degrees, rgb import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { TOOL_OUTPUT_SCHEMAS } from "../server/output-schemas.js"; import { projectMarkdownTable, scoreTable } from "./eval/extraction-bakeoff-scorer.js"; +import { createPrivatePdfUnlockHarness } from "./helpers/private-pdf-unlock.js"; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const MIXED = path.join(REPO_ROOT, "test/fixtures/eval/extraction/synthetic/mixed-text-raster.pdf"); @@ -244,6 +245,7 @@ describe("convert_pdf_to_markdown MCP tool", () => { let client; let transport; let temporaryRoot; + let unlock; let structureFixture; let denseFixture; let geometryFixture; @@ -260,6 +262,7 @@ describe("convert_pdf_to_markdown MCP tool", () => { beforeAll(async () => { temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pdf-tools-markdown-")); + unlock = await createPrivatePdfUnlockHarness(temporaryRoot); structureFixture = path.join(temporaryRoot, "structure.pdf"); denseFixture = path.join(temporaryRoot, "dense.pdf"); geometryFixture = path.join(temporaryRoot, "geometry.pdf"); @@ -290,7 +293,7 @@ describe("convert_pdf_to_markdown MCP tool", () => { command: process.execPath, args: [path.join(REPO_ROOT, "server/index.js")], cwd: REPO_ROOT, - env: { ALLOWED_DIRECTORIES: [REPO_ROOT, temporaryRoot].join(path.delimiter) }, + env: { ...unlock.env, ALLOWED_DIRECTORIES: [REPO_ROOT, temporaryRoot].join(path.delimiter) }, stderr: "ignore", }); client = new Client({ name: "pdf-markdown-test", version: "1.0.0" }); @@ -741,19 +744,21 @@ describe("convert_pdf_to_markdown MCP tool", () => { error: { error_schema_version: 1, code: "PASSWORD_REQUIRED" }, }); + await unlock.enter(client, ENCRYPTED, "definitely-wrong-layout-password", { accepted: false }); const wrong = await client.callTool({ name: "convert_pdf_to_markdown", - arguments: { pdf_path: ENCRYPTED, password: "definitely-wrong-layout-password" }, + arguments: { pdf_path: ENCRYPTED }, }); expect(wrong.isError).toBe(true); expect(wrong.structuredContent).toEqual({ status: "failed", - error: { error_schema_version: 1, code: "PASSWORD_INCORRECT" }, + error: { error_schema_version: 1, code: "PASSWORD_REQUIRED" }, }); + await unlock.enter(client, ENCRYPTED, "oda-layout-user-2026"); const correct = await client.callTool({ name: "convert_pdf_to_markdown", - arguments: { pdf_path: ENCRYPTED, password: "oda-layout-user-2026" }, + arguments: { pdf_path: ENCRYPTED }, }); expect(correct.isError).not.toBe(true); expect(correct.structuredContent.provenance.source.sha256).toBe( diff --git a/test/get-pdf-info.test.js b/test/get-pdf-info.test.js index 0eb0542..4cd57b2 100644 --- a/test/get-pdf-info.test.js +++ b/test/get-pdf-info.test.js @@ -9,6 +9,7 @@ import { PDFArray, PDFDocument, PDFName, PDFNumber } from "pdf-lib"; import { validateStructuredToolResult } from "../server/output-schemas.js"; import { documentEnvelopeSha256 } from "../server/pdf-observations.js"; import { createTestTempDirectory, removeTestTempDirectory } from "./helpers/temp-directory.js"; +import { createPrivatePdfUnlockHarness } from "./helpers/private-pdf-unlock.js"; const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); const EXAMPLE_PDF = path.join(REPO_ROOT, "example-fw9.pdf"); @@ -25,15 +26,17 @@ describe("get_pdf_info source-bound observations", () => { let client; let transport; let tempDirectory; + let unlock; beforeAll(async () => { tempDirectory = await createTestTempDirectory(REPO_ROOT, "pdf-info-observations"); + unlock = await createPrivatePdfUnlockHarness(tempDirectory); client = new Client({ name: "pdf-observation-test", version: "1.0.0" }); transport = new StdioClientTransport({ command: process.execPath, args: [path.join(REPO_ROOT, "server/index.js")], cwd: REPO_ROOT, - env: { ALLOWED_DIRECTORIES: REPO_ROOT }, + env: { ...unlock.env, ALLOWED_DIRECTORIES: REPO_ROOT }, stderr: "pipe", }); await client.connect(transport); @@ -169,16 +172,19 @@ describe("get_pdf_info source-bound observations", () => { name: "get_pdf_info", arguments: { pdf_path: ENCRYPTED_PDF }, }); - const wrong = await client.callTool({ + const rejectedLegacy = await client.callTool({ name: "get_pdf_info", arguments: { pdf_path: ENCRYPTED_PDF, password: provenance.passwords.wrong_password_oracle, }, }); + await unlock.enter(client, ENCRYPTED_PDF, provenance.passwords.wrong_password_oracle, { accepted: false }); + const wrong = await client.callTool({ name: "get_pdf_info", arguments: { pdf_path: ENCRYPTED_PDF } }); + await unlock.enter(client, ENCRYPTED_PDF, provenance.passwords.user); const correct = await client.callTool({ name: "get_pdf_info", - arguments: { pdf_path: ENCRYPTED_PDF, password: provenance.passwords.user }, + arguments: { pdf_path: ENCRYPTED_PDF }, }); expect(missing).toMatchObject({ @@ -187,8 +193,10 @@ describe("get_pdf_info source-bound observations", () => { }); expect(wrong).toMatchObject({ isError: true, - structuredContent: { error: { code: "PASSWORD_INCORRECT" } }, + structuredContent: { error: { code: "PASSWORD_REQUIRED" } }, }); + expect(rejectedLegacy).toMatchObject({ isError: true, structuredContent: { error: { code: "PRIVATE_PDF_PASSWORD_ARGUMENT_FORBIDDEN" } } }); + expect(JSON.stringify(rejectedLegacy)).not.toContain(provenance.passwords.wrong_password_oracle); expect(JSON.stringify(missing)).not.toContain(provenance.passwords.user); expect(JSON.stringify(wrong)).not.toContain(provenance.passwords.wrong_password_oracle); expect(correct.isError).not.toBe(true); diff --git a/test/mcp-contract.test.js b/test/mcp-contract.test.js index 1b4282f..5710b14 100644 --- a/test/mcp-contract.test.js +++ b/test/mcp-contract.test.js @@ -287,6 +287,9 @@ const OPEN_IDEMPOTENT_SESSION_ACTION = Object.freeze({ }); const TOOL_EFFECT_ANNOTATIONS = { + unlock_pdf: CLOSED_SESSION_ACTION, + get_pdf_unlock_status: CLOSED_READ, + lock_pdf: { ...CLOSED_SESSION_ACTION, idempotentHint: true }, start_lumin_authorization: OPEN_SESSION_ACTION, finish_lumin_authorization: OPEN_SESSION_ACTION, prepare_lumin_request: CLOSED_SESSION_ACTION, @@ -534,6 +537,7 @@ describe("MCPB static declarations", () => { "pdf-lib-rss-monitor.js", "pdf-observations.js", "qpdf-decrypt.js", + "private-pdf-credentials.js", "qpdf-decrypt-worker.js", "resource-uri.js", "stderr-suppression.js", @@ -548,7 +552,7 @@ describe("MCPB static declarations", () => { } // A new server file must be added to the list above, not silently shipped // in the mirror unchecked. Two already had been. - expect(mirrored).toHaveLength(38); + expect(mirrored).toHaveLength(39); for (const relativePath of [ "plugins/pdf-tools-workflow/skills/pdf-tools-workflow/SKILL.md", "plugins/pdf-tools-workflow/skills/pdf-tools-workflow/agents/openai.yaml", @@ -643,7 +647,7 @@ describe.each(RUNTIMES)("$name runtime discovery", runtime => { }); it("exposes the same uniquely named, fully annotated tool contract", () => { - expect(tools).toHaveLength(61); + expect(tools).toHaveLength(64); expect(new Set(names(tools)).size).toBe(tools.length); expect(sorted(names(tools))).toEqual(sorted(names(SOURCE_MANIFEST.tools))); expect(createHash("sha256").update(JSON.stringify(tools)).digest("hex")) diff --git a/test/read-pdf-layout.test.js b/test/read-pdf-layout.test.js index 14aea88..c8b5051 100644 --- a/test/read-pdf-layout.test.js +++ b/test/read-pdf-layout.test.js @@ -10,6 +10,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { LoggingMessageNotificationSchema } from "@modelcontextprotocol/sdk/types.js"; import { PDFDocument, PDFName, PDFNumber, StandardFonts, degrees, rgb } from "pdf-lib"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createPrivatePdfUnlockHarness } from "./helpers/private-pdf-unlock.js"; import { extractPdfLayout, inspectType3GlyphEvidenceForPage, @@ -2114,6 +2115,7 @@ describe("read_pdf_layout MCP tool", () => { const dedicatedStderr = []; const protocolLogs = []; const rawProtocol = []; + const unlock = await createPrivatePdfUnlockHarness(temporaryRoot); const dedicatedTransport = new StdioClientTransport({ command: process.execPath, args: [STDERR_SENTINEL_HELPER], @@ -2121,6 +2123,7 @@ describe("read_pdf_layout MCP tool", () => { env: { ALLOWED_DIRECTORIES: [REPO_ROOT, temporaryRoot].join(path.delimiter), PDF_TOOLS_TEST_STDERR_SENTINEL: sentinel, + ...unlock.env, }, stderr: "pipe", }); @@ -2154,18 +2157,18 @@ describe("read_pdf_layout MCP tool", () => { receiveProtocolMessage?.(message, extra); }; missingTrace = await callTraced({ pdf_path: ENCRYPTED_LAYOUT }); + await unlock.enter(dedicatedClient, ENCRYPTED_LAYOUT, provenance.passwords.wrong_password_oracle, { accepted: false }); wrongTrace = await callTraced({ pdf_path: ENCRYPTED_LAYOUT, - password: provenance.passwords.wrong_password_oracle, }); + await unlock.enter(dedicatedClient, ENCRYPTED_LAYOUT, provenance.passwords.user); userTrace = await callTraced({ pdf_path: ENCRYPTED_LAYOUT, - password: provenance.passwords.user, max_output_characters: 200000, }); + await unlock.enter(dedicatedClient, ENCRYPTED_LAYOUT, provenance.passwords.owner); ownerTrace = await callTraced({ pdf_path: ENCRYPTED_LAYOUT, - password: provenance.passwords.owner, max_output_characters: 200000, }); await dedicatedClient.listTools(); @@ -2188,7 +2191,7 @@ describe("read_pdf_layout MCP tool", () => { expect(wrongTrace.error).toBeNull(); expect(wrong).toMatchObject({ isError: true, - structuredContent: { status: "failed", error: { error_schema_version: 1, code: "PASSWORD_INCORRECT" } }, + structuredContent: { status: "failed", error: { error_schema_version: 1, code: "PASSWORD_REQUIRED" } }, }); const correctUser = userTrace.result; expect(userTrace.error).toBeNull(); From cf963b0f485aac9f97806293dd3e51ce9cc9d512 Mon Sep 17 00:00:00 2001 From: silverstein Date: Tue, 20 Oct 2026 21:35:05 +0000 Subject: [PATCH 05/13] Run private browser test openers in both module contexts --- test/encrypted-pdf-password-truth.test.js | 26 +++++++++++++++++++++++ test/helpers/private-pdf-unlock.js | 10 +++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/test/encrypted-pdf-password-truth.test.js b/test/encrypted-pdf-password-truth.test.js index 477540b..333ec14 100644 --- a/test/encrypted-pdf-password-truth.test.js +++ b/test/encrypted-pdf-password-truth.test.js @@ -24,6 +24,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -64,6 +65,31 @@ function probeQpdf() { const QPDF = probeQpdf(); describe("encrypted PDF password truthfulness", () => { + it("captures private browser launches inside and outside the ESM package", async () => { + const roots = []; + try { + roots.push(await createTestTempDirectory(REPO_ROOT, "private-opener-esm")); + roots.push(await fs.mkdtemp(path.join(os.tmpdir(), "private-opener-outside-"))); + for (const root of roots) { + const harness = await createPrivatePdfUnlockHarness(root); + const url = `http://127.0.0.1:12345/#${"a".repeat(64)}`; + for (const command of ["open", "xdg-open", "rundll32.exe"]) { + await fs.rm(harness.env.PDF_TOOLS_TEST_OPENER_CAPTURE, { force: true }); + // Node runs the generated script here; the real stdio tests exercise + // its executable shebang through the production OS-opener command. + const result = spawnSync(process.execPath, [path.join(root, "private-unlock-test", "bin", command), url], { + env: { ...process.env, ...harness.env }, encoding: "utf8", timeout: 5_000, + }); + expect(result.status, result.stderr).toBe(0); + expect(await fs.readFile(harness.env.PDF_TOOLS_TEST_OPENER_CAPTURE, "utf8")).toBe(url); + expect((await fs.stat(harness.env.PDF_TOOLS_TEST_OPENER_CAPTURE)).mode & 0o777).toBe(0o600); + } + } + } finally { + for (const root of roots) await removeTestTempDirectory(root); + } + }); + // Always runs, so the report states the guard's status instead of quietly // omitting it. A skipped encryption guard must be loud: a silent skip is how // a truthfulness regression reaches a release unnoticed. diff --git a/test/helpers/private-pdf-unlock.js b/test/helpers/private-pdf-unlock.js index d21f956..b2470c0 100644 --- a/test/helpers/private-pdf-unlock.js +++ b/test/helpers/private-pdf-unlock.js @@ -10,11 +10,17 @@ export async function createPrivatePdfUnlockHarness(directory) { const bin = path.join(root, "bin"); const capture = path.join(root, "opened-url"); await fs.mkdir(bin, { recursive: true, mode: 0o700 }); + // Explicit type also lets Node execute the simulated Windows command name + // instead of rejecting its .exe extension inside an ESM parent package. + await fs.writeFile(path.join(bin, "package.json"), '{"type":"commonjs"}\n', { mode: 0o600 }); const opener = `#!${process.execPath}\n` - + 'const fs = require("node:fs");\n' + // Dynamic import is valid in both CommonJS and ESM. Extensionless openers + // inherit a surrounding package's type when fixtures live under the repo. + + 'import("node:fs").then(({ default: fs }) => {\n' + 'const url = process.argv.find(value => /^http:\\/\\/127\\.0\\.0\\.1:\\d+\\/#/.test(value));\n' + 'if (!url) process.exit(2);\n' - + 'fs.writeFileSync(process.env.PDF_TOOLS_TEST_OPENER_CAPTURE, url, {mode: 0o600, flag: "wx"});\n'; + + 'fs.writeFileSync(process.env.PDF_TOOLS_TEST_OPENER_CAPTURE, url, {mode: 0o600, flag: "wx"});\n' + + '}).catch(() => process.exit(3));\n'; for (const name of ["open", "xdg-open", "rundll32.exe"]) { await fs.writeFile(path.join(bin, name), opener, { mode: 0o700 }); } From d97a6d3cff3e7e67943366b153b7ace5ee984a28 Mon Sep 17 00:00:00 2001 From: silverstein Date: Tue, 20 Oct 2026 21:35:02 +0000 Subject: [PATCH 06/13] Keep private PDF lock action valid through access lifetime --- server/private-pdf-credentials.js | 34 +++++++++++++---- test/private-pdf-credentials.test.js | 55 +++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/server/private-pdf-credentials.js b/server/private-pdf-credentials.js index 9389d3f..74c37a5 100644 --- a/server/private-pdf-credentials.js +++ b/server/private-pdf-credentials.js @@ -51,9 +51,15 @@ const PAGE_SCRIPT = ` }); for (const action of ["lock", "cancel"]) document.getElementById(action).addEventListener("click", async () => { password.value = ""; unlock.disabled = true; - try { await send("/" + action); } catch {} - token = ""; - message.textContent = "Document locked. You can close this window."; + try { + const result = await send("/" + action); + if (result.ok && result.data.status === "locked") { + token = ""; + message.textContent = "Document locked. You can close this window."; + return; + } + } catch {} + message.textContent = "Could not confirm the document is locked. Try again, or use Lock PDF in PDF Tools."; }); addEventListener("pagehide", () => { password.value = ""; token = ""; }); refresh(); @@ -177,21 +183,32 @@ export function createPrivatePdfCredentials({ bindSource, validatePassword, open return new Promise((resolve, reject) => { let bytes = 0; const chunks = []; - const timer = setTimeout(() => { chunks.length = 0; reject(fixedError("PRIVATE_PDF_REQUEST_INVALID")); }, 5000); + let finished = false; + const fail = () => { + if (finished) return; + finished = true; + chunks.length = 0; + clearTimeout(timer); + reject(fixedError("PRIVATE_PDF_REQUEST_INVALID")); + }; + const timer = setTimeout(fail, 5000); const cleanup = () => clearTimeout(timer); request.on("data", chunk => { + if (finished) return; bytes += chunk.length; - if (bytes > MAX_BODY_BYTES) { chunks.length = 0; cleanup(); reject(fixedError("PRIVATE_PDF_REQUEST_INVALID")); } + if (bytes > MAX_BODY_BYTES) fail(); else chunks.push(chunk); }); request.on("end", () => { + if (finished) return; + finished = true; cleanup(); try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } catch { reject(fixedError("PRIVATE_PDF_REQUEST_INVALID")); } finally { chunks.length = 0; } }); - request.on("error", () => { cleanup(); chunks.length = 0; reject(fixedError("PRIVATE_PDF_REQUEST_INVALID")); }); - request.on("aborted", () => { cleanup(); chunks.length = 0; reject(fixedError("PRIVATE_PDF_REQUEST_INVALID")); }); + request.on("error", fail); + request.on("aborted", fail); }); } @@ -267,6 +284,9 @@ export function createPrivatePdfCredentials({ bindSource, validatePassword, open entry.password = password; entry.expiresAt = now() + CREDENTIAL_TTL_MS; entry.status = "unlocked"; + // After entry, this capability also owns the visible Lock/Cancel action. + // Keep that action valid for the complete credential lifetime. + session.expiresAt = entry.expiresAt; return respond(response, 200, result(entry)); } catch { return denied(); } finally { diff --git a/test/private-pdf-credentials.test.js b/test/private-pdf-credentials.test.js index 1120362..cc96365 100644 --- a/test/private-pdf-credentials.test.js +++ b/test/private-pdf-credentials.test.js @@ -1,4 +1,5 @@ import http from "node:http"; +import vm from "node:vm"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createPrivatePdfCredentials } from "../server/private-pdf-credentials.js"; @@ -45,6 +46,26 @@ async function post(url, route = "/unlock", values = { password: SECRET }, optio return { status: response.status, body: await response.json() }; } +async function pageHarness(url, intercept = () => undefined) { + const html = await (await fetch(url.origin)).text(); + const script = html.match(/