חתימה דיגיטלית על PDF ב-Node.js: איך זה עובד ואיפה זה נשבר

ByteRange, CMS, PAdES ורמות B-T ו-LTA, חותמת זמן RFC 3161, איפה מחזיקים את המפתח הפרטי, ולמה כל שינוי בקובץ אחרי החתימה הורג אותה. כולל קוד אימות.

Node.jsPDFSecurity

חתימה דיגיטלית על PDF היא לא תמונה של חתימה ולא סיסמה על הקובץ. היא הוכחה קריפטוגרפית לשני דברים: שהמסמך לא שונה מאז שנחתם, ושמי שחתם מחזיק במפתח פרטי שקשור לתעודה מזוהה. כל השאר, כולל הריבוע הכחול שאדובי מצייר, הוא תצוגה.

מי שמפיק מסמכים לעסקים בישראל נתקל בזה מהר. מסמך ממוחשב שנשלח ללקוח צריך להיות חתום כדי שיהיה לו מעמד, והצד שמקבל אותו צריך יכולת לאמת. הצד ההנדסי של זה מפורט ומלא מלכודות, ורובן שקטות: הקובץ נראה תקין, קורא PDF פותח אותו, ורק אדובי או pdfsig אומרים שהחתימה שבורה.

מה בעצם חותמים

בתוך ה-PDF יושב מילון חתימה. שני שדות בו מעניינים.

/Contents הוא מחרוזת הקסדצימלית באורך קבוע שבתוכה תשב החתימה. לפני החתימה זה חור מלא באפסים, מה שנקרא placeholder.

/ByteRange הוא מערך של ארבעה מספרים: [a b c d], כלומר "מבייט a באורך b, ומבייט c באורך d". שני הקטעים האלה מכסים את כל הקובץ חוץ מהחור של /Contents. זה ההסבר לכל מה שמוזר כאן: אי אפשר לחתום על קובץ שמכיל את החתימה של עצמו, אז חותמים על הכל למעט החור.

מה שנכנס לחור הוא מבנה CMS מסוג detached signature (‏PKCS#7): ה-hash של שני הקטעים, חתום במפתח הפרטי, יחד עם תעודת החותם ושרשרת התעודות. הטקסט עצמו לא נכנס פנימה, ומכאן השם detached.

מכאן נובעות שתי מסקנות מעשיות. הראשונה: אורך ה-placeholder נקבע לפני שיודעים את גודל החתימה בפועל, ואם הזמנת 8KB וה-CMS יצא גדול יותר, החתימה נכשלת. השנייה: כל בייט שמשתנה בקובץ אחרי החתימה, כולל שינוי מטא-דאטה או "אופטימיזציה" של הקובץ, שובר אותה.

PAdES והרמות

התקן שרלוונטי ל-PDF הוא PAdES של ETSI. במקום להתעמק בטבלאות התאימות, מספיק להכיר את ארבע הרמות, כי הן קובעות כמה זמן החתימה תישאר ניתנת לאימות.

רמה מה מוסיפה מה זה קונה
B-B חתימה בסיסית בלבד שלמות ומקור, כאן ועכשיו
B-T חותמת זמן מ-TSA הוכחה שהחתימה קדמה לזמן מסוים
B-LT תעודות ומצב ביטול משובצים בקובץ אימות גם כשה-OCSP למטה או התעודה פגה
B-LTA חותמות זמן ארכיוניות הארכה חוזרת, לפני שאלגוריתם נחלש

B-B מספיקה כדי לומר "המסמך לא שונה". היא לא מספיקה כדי לענות בעוד חמש שנים על השאלה "האם התעודה הייתה בתוקף ברגע החתימה". מסמכים חשבונאיים נשמרים שבע שנים, אז B-T היא המינימום הסביר ו-B-LT היא מה שבאמת רוצים.

הקוד המינימלי

בעולם Node החבילות של @signpdf הן הדרך הקצרה. הן מפרידות בין הכנת ה-placeholder לבין החתימה עצמה, וזו הפרדה נכונה.

import { readFileSync } from 'node:fs';
import signpdf from '@signpdf/signpdf';
import { P12Signer } from '@signpdf/signer-p12';
import { plainAddPlaceholder } from '@signpdf/placeholder-plain';

const withPlaceholder = plainAddPlaceholder({
  pdfBuffer: readFileSync('invoice.pdf'),
  reason: 'הפקת חשבונית',
  contactInfo: '[email protected]',
  name: 'Example Ltd',
  location: 'Tel Aviv',
  signatureLength: 16384,
});

const signer = new P12Signer(readFileSync('cert.p12'), { passphrase: process.env.P12_PASS });
const signed = await signpdf.sign(withPlaceholder, signer);

שימו לב ל-signatureLength. ברירת המחדל נוחה עד שמוסיפים חותמת זמן ושרשרת תעודות ארוכה, ואז ה-CMS גדל והחתימה נופלת בהרצה מסוימת ולא באחרות. תנו מרווח, ובדקו את הגודל בפועל על תעודת הפרודקשן ולא על תעודת בדיקה קצרה.

אם המסמך נוצר ב-pdf-lib, יש placeholder ייעודי שמשתלב עם אובייקט המסמך במקום לתקן בייטים גולמיים, וזו האפשרות הנקייה יותר כשגם ככה בונים את הקובץ בקוד.

pdf-lib עצמו לא חותם. אין בו קריפטוגרפיה של CMS, והוא לא מנסה להעמיד פנים אחרת. הוא הכלי להכנת הקובץ, לא לחתימה עליו.

חותמת זמן

בלי חותמת זמן, כל מה שיש הוא שעון המחשב שחתם. זה חסר ערך: אפשר לשקר, והתוקף של התעודה יפוג בשלב כלשהו וייווצר מצב שבו אי אפשר להוכיח שהחתימה נעשתה כשהתעודה עוד הייתה תקפה.

חותמת זמן לפי RFC 3161 פותרת את זה. שולחים hash של החתימה לשירות TSA, מקבלים token חתום שאומר "ה-hash הזה היה קיים בזמן הזה", ומשבצים אותו כתכונה לא חתומה בתוך ה-CMS.

הרבה TSA ציבוריים דורשים רישום או מגבילים קצב. בשירות שמפיק אלפי מסמכים ביום זה משנה: תור, retry עם backoff, ומדיניות ברורה למקרה שה-TSA לא זמין. הבחירה היא בין להיכשל בהפקה לבין להנפיק חתימה ברמה B-B ולהוסיף חותמת מאוחר יותר. שתי האפשרויות לגיטימיות, אבל צריך להחליט מראש ולא בזמן תקלה.

איפה יושב המפתח הפרטי

קובץ .p12 על הדיסק עם סיסמה במשתנה סביבה הוא מה שכל דוגמה מראה, וזה גם המקום שממנו מפתחות דולפים. שלוש אפשרויות, לפי סדר עולה של רצינות.

הקובץ המקומי מתאים לפיתוח ולבדיקות. בפרודקשן זה אומר שכל מי שמגיע לקונטיינר, לגיבוי או ללוג של תקלה, מגיע גם למפתח.

KMS מנוהל, כמו AWS KMS או Cloud KMS, מחזיק מפתח אסימטרי שלא יוצא החוצה. אתם שולחים hash ומקבלים חתימה. המחיר: @signpdf מצפה ל-Signer שמחזיר CMS שלם, אז צריך לבנות את ה-CMS בעצמכם ב-PKI.js ולהאציל ל-KMS רק את פעולת החתימה הגולמית. זה כמה עשרות שורות, לא יותר.

HSM דרך PKCS#11 הוא מה שנדרש כשהתעודה היא תעודה ארגונית מאושרת והמדיניות של גורם האישור מחייבת אחסון בחומרה. זה גם הכי יקר לתפעול.

בכל המקרים: אל תכניסו את ה-.p12 לתמונת Docker, אל תדפיסו את הסיסמה בשגיאה, וסובבו את התעודה לפני שהיא פגה ולא אחרי.

חתימה שנייה, ולמה עריכה הורגת חתימה

PDF תומך ב-incremental update: מוסיפים בייטים לסוף הקובץ במקום לכתוב אותו מחדש. זה המנגנון שמאפשר חתימה שנייה, למשל לקוח שחותם על מסמך שכבר חתום על ידכם. החתימה הראשונה ממשיכה לכסות את הבייטים הישנים, והשנייה מכסה את הכל.

מכאן שכל כלי שכותב את הקובץ מחדש, ולא כתוספת, שובר את הראשונה. זה כולל דברים שלא נראים כמו עריכה: מעבר דרך pdf-lib עם save() רגיל, דחיסה, מיזוג, הוספת מטא-דאטה, ולפעמים גם כלי "אופטימיזציה" שמישהו הכניס לפייפליין כדי להקטין קבצים.

הסדר היחיד שעובד: לייצר את הקובץ הסופי לגמרי, ורק אז לחתום. אם הכתבה הקודמת כאן על הפקת PDF רלוונטית לכם, זו נקודת החיבור: Chromium מייצר, ומיד אחר כך שלב החתימה, בלי שום צעד באמצע.

מי שרוצה לאסור שינויים עתידיים משתמש ב-DocMDP, שקובע רמת הרשאה: אין שינויים בכלל, מותר למלא טפסים, או מותר גם להעיר. ברירת המחדל הסבירה למסמך חשבונאי היא הראשונה.

איך מאמתים

אימות אמיתי הוא חמישה שלבים, וכל אחד מהם נכשל אחרת.

קוראים את /ByteRange ומוודאים שהוא באמת מכסה את כל הקובץ חוץ מהחור. חתימה שמכסה רק חצי מהמסמך היא תרגיל ידוע, והיא נראית תקינה לכלי שלא בודק את זה.

מחשבים hash על שני הקטעים ומשווים למה שחתום ב-CMS.

מאמתים את החתימה מול המפתח הציבורי שבתעודה.

בונים את שרשרת התעודות עד עוגן אמון שאתם מכירים, ובודקים ביטול מול OCSP או CRL. תעודה שנפרצה ובוטלה עדיין מייצרת חתימה שעוברת את שלושת השלבים הראשונים.

בודקים את חותמת הזמן, ומוודאים שהתעודה הייתה בתוקף באותו רגע.

לבדיקה מהירה בשורת הפקודה, pdfsig מחבילת poppler עושה את רוב זה:

pdfsig -nocert signed.pdf

בקוד, PKI.js מפרק את ה-CMS ומאפשר את כל חמשת השלבים. עמוד אימות ציבורי צריך להראות את התוצאה בשפה של בן אדם: מי חתם, מתי, האם הקובץ שהועלה זהה לזה שנחתם, ואם לא, שהתשובה תהיה "לא" בלי סייגים. עמוד שמראה וי ירוק על סמך זה שנמצא מילון חתימה בקובץ הוא גרוע יותר מכלום.

מה זה לא פותר

חתימה מוכיחה שהמסמך לא שונה ושמי שחתם מחזיק במפתח. היא לא אומרת שהסכום נכון, שהעסקה קרתה, או שהחותם היה מורשה לחתום. אלה שאלות של הרשאות ושל תהליך, לא של קריפטוגרפיה, ומערכת שמתייחסת לחתימה כאילו היא עונה עליהן פשוט העבירה את הבעיה למקום שבו קשה יותר לראות אותה.