Skip to content

Commit a7f516c

Browse files
authored
Decrypt protected PDFs using qpdf and upload new SS-5 form (#691)
1 parent 5c59510 commit a7f516c

14 files changed

Lines changed: 212 additions & 27 deletions

File tree

pdf-manager/lib/handlers.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { existsSync, readFileSync, writeFileSync } from "node:fs";
22
import { join } from "node:path";
3-
import { PDF } from "@libpdf/core";
43
import type { Context } from "hono";
54
import { findPdfById, listAllPdfs } from "./catalog";
65
import {
@@ -9,7 +8,12 @@ import {
98
writeDefinitionFiles,
109
} from "./define";
1110
import { loadJurisdictions } from "./fields";
12-
import { applyRenames, extractFields, extractFieldsFromBytes } from "./pdf";
11+
import {
12+
applyRenames,
13+
extractFields,
14+
extractFieldsFromBytes,
15+
loadPdf,
16+
} from "./pdf";
1317
import { loadExclusions, processPdf } from "./schema";
1418
import { suggestName } from "./suggest";
1519

@@ -108,7 +112,7 @@ export async function handleAddPdf(c: Context) {
108112
}
109113

110114
const rawBytes = Buffer.from(pdfBase64, "base64");
111-
const pdfDoc = await PDF.load(rawBytes);
115+
const pdfDoc = await loadPdf(rawBytes);
112116
const cleanedBytes = await pdfDoc.save();
113117

114118
const pdfFields = await extractFieldsFromBytes(cleanedBytes);
@@ -198,7 +202,7 @@ export async function handleReplacePdf(c: Context) {
198202
let fieldNames: string[];
199203
try {
200204
const newBytes = Buffer.from(pdfBase64, "base64");
201-
const pdfDoc = await PDF.load(newBytes);
205+
const pdfDoc = await loadPdf(newBytes);
202206
writeFileSync(found.pdfPath, await pdfDoc.save());
203207

204208
if (renames.length > 0) await applyRenames(found.pdfPath, renames);
@@ -216,12 +220,12 @@ export async function handleReplacePdf(c: Context) {
216220
exclude: deletes,
217221
keep: keepNames,
218222
}));
219-
formatFiles([schemaPath]);
220223
} catch (err) {
221224
writeFileSync(found.pdfPath, oldBytes);
222225
writeFileSync(schemaPath, oldSchemaContent);
223226
throw err;
224227
}
228+
formatFiles([schemaPath]);
225229

226230
const before = new Set(activeFields);
227231
const fieldSet = new Set(fieldNames);

pdf-manager/lib/pdf.ts

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { readFileSync, writeFileSync } from "node:fs";
1+
import { spawnSync } from "node:child_process";
2+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
25
import type { FieldType, FormField, PDFPage } from "@libpdf/core";
36
import { PDF, PdfArray, PdfDict, PdfName, PdfString } from "@libpdf/core";
47

@@ -62,16 +65,63 @@ export async function extractFields(pdfPath: string): Promise<PdfFieldInfo[]> {
6265
return extractFieldsFromBytes(readFileSync(pdfPath));
6366
}
6467

68+
export async function loadPdf(bytes: Uint8Array | Buffer): Promise<PDF> {
69+
const doc = await PDF.load(bytes);
70+
if (!doc.isEncrypted) return doc;
71+
return PDF.load(decryptWithQpdf(bytes));
72+
}
73+
6574
/** Same as extractFields but accepts raw bytes instead of a path. */
6675
export async function extractFieldsFromBytes(
6776
bytes: Uint8Array | Buffer,
6877
): Promise<PdfFieldInfo[]> {
69-
const doc = await PDF.load(bytes);
78+
const doc = await loadPdf(bytes);
7079
const form = doc.getForm();
7180
const sorted = fieldReadingOrder(form?.getFields() ?? [], doc.getPages());
7281
return sorted.map((f) => ({ name: f.name, type: f.type }));
7382
}
7483

84+
/**
85+
* Temporary workaround for https://github.com/LibPDF-js/core/issues/82.
86+
*
87+
* When a PDF is encrypted with an empty user password but an unknown owner
88+
* password, libpdf authenticates successfully (isAuthenticated: true) but
89+
* removeProtection() throws PermissionDeniedError. Calling save() without
90+
* removing protection regenerates the /ID trailer entry, invalidating the AES
91+
* key derivation — the saved file is unreadable. qpdf derives the correct file
92+
* key from the empty user password and strips encryption without needing the
93+
* owner password. Remove this function and inline PDF.load() in loadPdf() once
94+
* libpdf preserves the original /ID on save().
95+
*/
96+
function decryptWithQpdf(bytes: Uint8Array | Buffer): Buffer {
97+
const tmpDir = mkdtempSync(join(tmpdir(), "namesake-pdf-"));
98+
try {
99+
const inPath = join(tmpDir, "input.pdf");
100+
const outPath = join(tmpDir, "output.pdf");
101+
writeFileSync(inPath, bytes);
102+
const result = spawnSync("qpdf", ["--decrypt", inPath, outPath], {
103+
timeout: 60_000,
104+
});
105+
if (result.error) {
106+
const isNotFound =
107+
(result.error as NodeJS.ErrnoException).code === "ENOENT";
108+
throw new Error(
109+
isNotFound
110+
? "qpdf is required to process encrypted PDFs. Install it with: brew install qpdf"
111+
: `qpdf failed: ${result.error.message}`,
112+
);
113+
}
114+
if (result.status !== 0) {
115+
throw new Error(
116+
"PDF is password-protected. Please provide an unprotected version.",
117+
);
118+
}
119+
return readFileSync(outPath);
120+
} finally {
121+
rmSync(tmpDir, { recursive: true, force: true });
122+
}
123+
}
124+
75125
/**
76126
* Converts all dropdown fields to plain text fields in the PDF on disk.
77127
* This lets fillPdf write any value without needing to match the PDF's fixed
@@ -81,7 +131,7 @@ export async function convertDropdownsToTextFields(
81131
pdfPath: string,
82132
): Promise<void> {
83133
const bytes = readFileSync(pdfPath);
84-
const doc = await PDF.load(bytes);
134+
const doc = await loadPdf(bytes);
85135
const form = doc.getForm();
86136
let changed = false;
87137
for (const field of form?.getFields() ?? []) {
@@ -105,7 +155,7 @@ export async function normalizeRadioOptionNames(
105155
pdfPath: string,
106156
): Promise<void> {
107157
const bytes = readFileSync(pdfPath);
108-
const doc = await PDF.load(bytes);
158+
const doc = await loadPdf(bytes);
109159
const form = doc.getForm();
110160
let changed = false;
111161

@@ -176,7 +226,7 @@ export async function applyRenames(
176226
renames: Rename[],
177227
): Promise<void> {
178228
const bytes = readFileSync(pdfPath);
179-
const doc = await PDF.load(bytes);
229+
const doc = await loadPdf(bytes);
180230
const form = doc.getForm();
181231
const acroForm = form?.acroForm();
182232
for (const { from, to } of renames) {

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"fathom-client": "^3.7.2",
6565
"idb": "^8.0.3",
6666
"language-name-map": "^0.3.0",
67+
"libphonenumber-js": "^1.13.8",
6768
"react": "^19.2.7",
6869
"react-aria-components": "^1.19.0",
6970
"react-dom": "^19.2.7",

web/src/components/forms/YesNoField/YesNoField.test.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ describe("getYesNoStringFromBoolean", () => {
161161
);
162162
});
163163

164+
it("returns 'dontKnow' if the value is 'dontKnow'", () => {
165+
expect(getYesNoStringFromBoolean("dontKnow")).toBe("dontKnow");
166+
});
167+
164168
it("returns null if the value is undefined", () => {
165169
expect(getYesNoStringFromBoolean(undefined as any)).toBeNull();
166170
});
@@ -185,6 +189,10 @@ describe("getBooleanValueFromYesNoString", () => {
185189
);
186190
});
187191

192+
it("returns 'dontKnow' if the value is 'dontKnow'", () => {
193+
expect(getBooleanValueFromYesNoString("dontKnow")).toBe("dontKnow");
194+
});
195+
188196
it("returns false for other values", () => {
189197
expect(getBooleanValueFromYesNoString("")).toBe(false);
190198
expect(getBooleanValueFromYesNoString("garbage")).toBe(false);

web/src/components/forms/YesNoField/YesNoField.tsx

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { Controller, useFormContext } from "react-hook-form";
2-
import { type FieldName, PREFER_NOT_TO_ANSWER } from "#constants/fields";
2+
import {
3+
DONT_KNOW,
4+
type FieldName,
5+
PREFER_NOT_TO_ANSWER,
6+
} from "#constants/fields";
37
import { smartquotes } from "#lib/utils/smartquotes";
48
import {
59
Radio,
@@ -8,27 +12,19 @@ import {
812
} from "../../common/RadioGroup";
913
import "./YesNoField.css";
1014

11-
type YesNoValue = boolean | typeof PREFER_NOT_TO_ANSWER;
15+
type YesNoValue = boolean | typeof PREFER_NOT_TO_ANSWER | typeof DONT_KNOW;
1216

13-
/**
14-
* Converts a boolean value to a string value.
15-
* @param value - The boolean value to convert.
16-
* @returns "yes" if the value is true, "no" if the value is false, "preferNotToAnswer" if the value is null.
17-
*/
1817
export const getYesNoStringFromBoolean = (value: YesNoValue) => {
1918
if (value === undefined || value === null) return null;
2019
if (value === PREFER_NOT_TO_ANSWER) return PREFER_NOT_TO_ANSWER;
20+
if (value === DONT_KNOW) return DONT_KNOW;
2121
if (value) return "yes";
2222
return "no";
2323
};
2424

25-
/**
26-
* Converts a string value to a boolean value.
27-
* @param value - The string value to convert.
28-
* @returns true if the value is "yes", false if the value is "no", null if the value is "preferNotToAnswer".
29-
*/
3025
export const getBooleanValueFromYesNoString = (value: string): YesNoValue => {
3126
if (value === PREFER_NOT_TO_ANSWER) return PREFER_NOT_TO_ANSWER;
27+
if (value === DONT_KNOW) return DONT_KNOW;
3228
return value === "yes";
3329
};
3430

@@ -40,6 +36,7 @@ export interface YesNoFieldProps extends RadioGroupProps {
4036
yesLabel?: string;
4137
noLabel?: string;
4238
includePreferNotToAnswer?: boolean;
39+
includeDontKnow?: boolean;
4340
}
4441

4542
export function YesNoField({
@@ -51,6 +48,7 @@ export function YesNoField({
5148
children,
5249
defaultValue,
5350
includePreferNotToAnswer,
51+
includeDontKnow,
5452
}: YesNoFieldProps) {
5553
const { control, setValue } = useFormContext();
5654

@@ -77,6 +75,7 @@ export function YesNoField({
7775
{includePreferNotToAnswer && (
7876
<Radio value={PREFER_NOT_TO_ANSWER}>Prefer not to answer</Radio>
7977
)}
78+
{includeDontKnow && <Radio value={DONT_KNOW}>I don’t know</Radio>}
8079
</RadioGroup>
8180
)}
8281
/>

web/src/constants/fields.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -702,7 +702,7 @@ export type FieldType<K extends FieldName> = K extends any
702702
(typeof FIELD_DEFS)[number],
703703
{ name: K }
704704
>["type"] extends "boolean"
705-
? boolean
705+
? boolean | typeof PREFER_NOT_TO_ANSWER | typeof DONT_KNOW
706706
: Extract<
707707
(typeof FIELD_DEFS)[number],
708708
{ name: K }
@@ -718,3 +718,4 @@ export type FormData = {
718718
export const COMMON_PRONOUNS = ["they/them", "she/her", "he/him"];
719719

720720
export const PREFER_NOT_TO_ANSWER = "preferNotToAnswer";
721+
export const DONT_KNOW = "dontKnow";

web/src/content/forms/social-security/steps/PreviousSocialSecurityCardStep.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export const previousSocialSecurityCardStep = defineStep({
3636
labelHidden
3737
yesLabel="Yes, I have a previous Social Security card or have applied for one"
3838
noLabel="No, I have never filed for or received a Social Security card before"
39+
includeDontKnow
3940
/>
4041
<FormSubsection
4142
title="What is the name shown on your most recent Social Security card?"

web/src/content/pdfs/federal/ss5-application-for-social-security-card/index.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it } from "vitest";
2+
import { DONT_KNOW } from "#constants/fields";
23
import { expectPdfFieldsMatch } from "#lib/pdfs/expectPdfFieldsMatch";
34
import { getPdfForm } from "#lib/pdfs/getPdfForm";
45
import ss5Application from ".";
@@ -52,7 +53,7 @@ describe("SS-5 Application for Social Security Card", () => {
5253
previousSocialSecurityCardLastName: "Michaels, Jr.",
5354

5455
// Field 15: Phone number
55-
phoneNumber: "555-555-5555",
56+
phoneNumber: "212-867-5309",
5657

5758
// Field 16: Address
5859
mailingStreetAddress: "123 Main St",
@@ -69,6 +70,31 @@ describe("SS-5 Application for Social Security Card", () => {
6970
await expectPdfFieldsMatch(ss5Application, testData);
7071
});
7172

73+
it("splits phone number into area code and local number", async () => {
74+
const form = await getPdfForm({ pdf: ss5Application, userData: testData });
75+
expect(form.getTextField("areaCode")?.getValue()).toBe("212");
76+
expect(form.getTextField("phoneNumber")?.getValue()).toBe("867-5309");
77+
});
78+
79+
it("checks previousSocialSecurityCardUnknown when hasPreviousSocialSecurityCard is unknown", async () => {
80+
const form = await getPdfForm({
81+
pdf: ss5Application,
82+
userData: {
83+
...testData,
84+
hasPreviousSocialSecurityCard: DONT_KNOW,
85+
},
86+
});
87+
expect(
88+
form.getCheckbox("previousSocialSecurityCardUnknown")?.isChecked(),
89+
).toBe(true);
90+
expect(form.getCheckbox("hasPreviousSocialSecurityCard")?.isChecked()).toBe(
91+
false,
92+
);
93+
expect(
94+
form.getCheckbox("hasNoPreviousSocialSecurityCard")?.isChecked(),
95+
).toBe(false);
96+
});
97+
7298
it("derives birthplaceState from country code when outside US", async () => {
7399
const dataWithForeignBirthplace = {
74100
...testData,

web/src/content/pdfs/federal/ss5-application-for-social-security-card/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { DONT_KNOW } from "#constants/fields";
12
import { definePdf } from "#lib/pdfs/definePdf";
23
import { formatBirthplaceCountryOrState } from "#lib/utils/formatBirthplaceCountryOrState";
34
import { formatDateMMDDYYYY } from "#lib/utils/formatDateMMDDYYYY";
5+
import { splitPhoneNumber } from "#lib/utils/splitPhoneNumber";
46
import type { PdfFieldName } from "./schema";
57
import pdf from "./ss5-application-for-social-security-card.pdf";
68

@@ -50,6 +52,8 @@ export default definePdf<PdfFieldName>({
5052
hasPreviousSocialSecurityCard: data.hasPreviousSocialSecurityCard === true,
5153
hasNoPreviousSocialSecurityCard:
5254
data.hasPreviousSocialSecurityCard === false,
55+
previousSocialSecurityCardUnknown:
56+
data.hasPreviousSocialSecurityCard === DONT_KNOW,
5357
previousSocialSecurityCardFirstName:
5458
data.previousSocialSecurityCardFirstName,
5559
previousSocialSecurityCardMiddleName:
@@ -60,7 +64,8 @@ export default definePdf<PdfFieldName>({
6064
day: "2-digit",
6165
year: "numeric",
6266
}),
63-
phoneNumber: data.phoneNumber,
67+
areaCode: splitPhoneNumber(data.phoneNumber).areaCode,
68+
phoneNumber: splitPhoneNumber(data.phoneNumber).localNumber,
6469
mailingStreetAddress: data.mailingStreetAddress,
6570
mailingCity: data.mailingCity,
6671
mailingState: data.mailingState,

0 commit comments

Comments
 (0)