קישור סודי חד-פעמי הוא URL שמציג את התוכן שלו פעם אחת בדיוק, ואחר כך מפסיק לעבוד לצמיתות. הדפדפן של השולח מצפין את הסוד ב-AES-GCM-256, השרת שומר רק את הטקסט המוצפן, והמפתח עובר בחלק של הקישור שאחרי #k=, שדפדפנים אף פעם לא שולחים. ההצגה הראשונה אצל הנמען מפענחת את הסוד, ובאותה פעולה העותק השמור נמחק.
אפשר ליצור קישור כזה בלי חשבון במחולל הקישורים הסודיים החינמי.
המנגנון פשוט, אבל מקרי הקצה הם החלק המעניין, ושם מימושים רשלניים דולפים בלי שאיש ישים לב.
התהליך הבסיסי
- השולח מדביק את הסוד. הדפדפן יוצר מפתח אקראי, מצפין את התוכן ב-AES-GCM-256 דרך ה-Web Crypto API, ושולח רק את הטקסט המוצפן. השרת שומר אותו תחת סיומת קישור אקראית שקשה לנחש ומחזיר URL. הדפדפן מוסיף את המפתח כחלק
#k=שאחרי הסימן #, והחלק הזה לא יוצא מהמכשיר של השולח. - השולח משתף את ה-URL בכל ערוץ: אימייל, צ'אט, SMS.
- הנמען פותח את ה-URL. בקשת
GETרגילה מחזירה רק סטטוס. כשהנמען לוחץ על הצגה, השרת מסמן את הסוד כנצרך ומחזיר את הטקסט המוצפן בפעולה אחת שאי אפשר לפצל. הדפדפן של הנמען מפענח אותו עם המפתח מהחלק שאחרי הסימן #. - כל ביקור נוסף מקבל עמוד סטטוס של "נמחק" במקום התוכן.
השלב הקריטי הוא שלב 3. אם הקריאה ועדכון הסטטוס מתבצעים בשתי שאילתות נפרדות, SELECT לבדיקה ואחריה UPDATE לסימון, שני צופים בו-זמניים יכולים לעבור את הבדיקה לפני שאחד מהם כותב. שניהם מקבלים את התוכן. זה באג TOCTOU קלאסי (time-of-check to time-of-use), והוא הופך את ההבטחה "בדיוק פעם אחת" ל"בדרך כלל פעם אחת".
למה המחיקה והקריאה חייבות לקרות בצעד אחד?
כי אחרת שני צופים בו-זמניים יכולים לעבור את הבדיקה לפני שאחד מהם מסמן את הסוד כנצרך. הפתרון הוא לאחד את הבדיקה, הסימון והקריאה לפעולת מסד נתונים אחת. ב-Postgres, עם UPDATE ... RETURNING, זה נראה כך:
UPDATE secrets
SET burned_at = now()
WHERE slug = $1
AND burned_at IS NULL
AND expires_at > now()
RETURNING payload;
ה-UPDATE נועל את השורה, ולכן קריאות בו-זמניות מתבצעות בזו אחר זו: הראשונה עומדת בתנאי WHERE, משנה את burned_at ומקבלת את התוכן בחזרה דרך RETURNING. כל קריאה מאוחרת יותר מוצאת ש-burned_at כבר מוגדר, לא תואמת אף שורה ומקבלת את סטטוס המחיקה. אין חלון זמן בין "בדיקה" ל"צריכה", כי שתיהן אותה פקודה.
בדיוק מהסיבה הזאת LinkPilot רושם צפיות דרך פונקציה במסד הנתונים, בצעד אחד שאי אפשר לפצל, וניסיונות הצגה בו-זמניים לא יכולים להדליף את הסוד פעמיים. אם אתם בודקים כלי, זו שאלה הוגנת לשאול את הספק ישירות: האם המחיקה והקריאה קורות בצעד אחד?
למה קישורים שנמחקו מחזירים HTTP 410 ולא 404?
כי 410 Gone אומר שהמשאב היה קיים ואינו זמין עוד לצמיתות, וזה בדיוק המצב של סוד שנמחק. אחרי שהקישור נצרך, הוא צריך להיכשל באופן ברור וקבוע. LinkPilot עושה שני דברים במחיקה:
- התוכן השמור נדרס ב-
[REDACTED]באותה פעולת כתיבה שמשנה את הסטטוס, כך שגם פריצה עתידית למסד הנתונים לא תוכל לשחזר סוד שכבר נצרך. - ניסיונות הצגה נוספים מחזירים HTTP 410 Gone עם עמוד ברור שאומר שהתוכן "כבר נצפה והושמד".
410 הוא קוד הסטטוס הנכון: הוא אומר שהמשאב היה קיים ושהוא לא זמין עוד, בכוונה ולצמיתות. 404, לעומתו, אומר "אין לנו מושג על מה מדובר". ההבדל חשוב גם לבני אדם: נמען שרואה "הסוד הזה כבר נצפה" יודע לספר לשולח, והשולח יכול לבדוק את יומן הביקורת ולהחליף את הסיסמה אם הצופה לא היה הנמען.
סיומות קישור שקשה לנחש, ולמה זה לא מספיק
סיומת הקישור היא מה שעומד בין האינטרנט הציבורי לבין טקסט מוצפן שעוד לא נקרא (המפתח נמצא בחלק שאחרי הסימן #, לא בשרת), ולכן היא חייבת לשאת אנטרופיה אמיתית. סיומת שנוצרת ממקור אקראי מאובטח קריפטוגרפית, למשל 22 תווים ב-base62, נושאת בערך 128 ביט. במצב כזה ניחוש שיטתי אינו מתקפה מציאותית בשום קצב בקשות ששרת היה עומד בו.
אבל האנטרופיה של הסיומת מגינה רק מפני ניחוש. היא לא עוזרת כשמישהו רואה את ה-URL: היסטוריית הדפדפן, לוגים של פרוקסי, אימיילים שהועברו הלאה, שיתופי מסך, תוכנות לניהול לוח ההעתקה. לכן מימושים רציניים מוסיפים עוד שכבות:
- תפוגה, כך שתוקף של URL שנצפה פג בזמן שנקבע מראש.
- ביטוי סיסמה, כך שה-URL לבדו לא מספיק למי שראה אותו.
- מחיקה לאחר קריאה, כך שאחרי הקריאה הלגיטימית ל-URL שנצפה אין שום ערך.
תצוגות מקדימות של קישורים ובוטים
אפליקציות צ'אט (Slack, Teams, iMessage) וסורקי אבטחה של אימייל מייצרים תצוגה מקדימה של URL: הם מורידים אותו בבקשת GET ברגע שההודעה נמסרת. אם נקודת הקצה של ההצגה מוחקת את הסוד ב-GET, הבוט של התצוגה המקדימה משמיד את הסוד לפני שאדם בכלל ראה אותו. זה הכשל הנפוץ ביותר בפועל בכלים פשטניים של מחיקה לאחר קריאה.
ההגנות, כפי ש-LinkPilot מממש אותן:
- הפרדה בין פעולת המטא-דאטה לפעולת ההצגה. בקשת
GETלנקודת הקצה של ההצגה מחזירה רק את הסטטוס ואם נדרש ביטוי סיסמה. צפייה נרשמת רק בבקשתPOSTמפורשת, הפעולה שאדם מבצע כשהוא לוחץ על כפתור ההצגה. - שער ביטוי סיסמה (Pro). כשמוגדר ביטוי סיסמה, התוכן לא מוחזר לאף אחד, בוט או אדם, בלי ה-hash הנכון.
Cache-Control: no-storeבכל תגובת הצגה, כך שגורמי ביניים אף פעם לא שומרים את תוכן הסוד במטמון.
עמוד ארכיטקטורת האבטחה של LinkPilot מתעד את הטיפול הזה מקצה לקצה.
מחזור החיים, מצב אחר מצב
| מצב | מה גורם לו | מה המבקר רואה | אפשר לשחזר את התוכן? |
|---|---|---|---|
| פעיל | יצירה | עמוד ההצגה (בקשה לביטוי סיסמה אם הוגדר) | כן: הצגה אחת זמינה |
| נמחק | הגעה למגבלת הצפיות | "כבר נצפה והושמד", HTTP 410 | לא: התוכן נדרס |
| פג תוקף | חלף מועד התפוגה | עמוד סטטוס של פג תוקף | לא: נדרס בגישה הבאה |
| בוטל | השולח מבטל את הקישור מלוח הבקרה | מסרב להציג, HTTP 410 | לא: מיידי ולצמיתות |
כל מעבר בין מצבים נרשם ביומן הביקורת, תכונה של Pro ב-LinkPilot. סוגי האירועים הם created, viewed, failed_passphrase, burned, expired ו-revoked, כל אחד עם חותמת זמן וכתובת IP שעברה hash (SHA-256 עם salt שמתחלף מדי יום; כתובת ה-IP הגולמית לא נשמרת אף פעם). היומן הזה הוא מה שהופך את "נראה לי שזה הגיע" ל"נצפה ב-10:05 מהמדינה הצפויה, צפייה אחת, ואז נמחק".
הרחבות נפוצות
- שער ביטוי סיסמה: דרישה לביטוי סיסמה שנמסר בערוץ אחר לפני ההצגה, כך ש-URL שדלף לבדו אינו סוד שדלף. ב-LinkPilot זו תכונה של Pro ($29 לחודש), לצד תפוגה מותאמת אישית ויומן הביקורת; המחולל שעובד בלי חשבון לא יכול להגדיר ביטוי סיסמה. הגבילו את קצב הניסיונות: LinkPilot מאפשר 5 ניסיונות לכל IP לדקה לכל סוד, ורושם כל כישלון.
- תפוגה לפי זמן: הסוד מת אחרי פרק זמן קבוע גם אם אף אחד לא קרא אותו. הטופס של LinkPilot למשתמשים מחוברים מציע 1 שעה, 24 שעות או 7 ימים בכל תוכנית, ותאריך ושעה מותאמים אישית בתוכנית Pro. המחולל שעובד בלי חשבון מגדיר 7 ימים כברירת מחדל. זה מכסה את המקרה של קישור שנשכח בתיבת הדואר, שמחיקה לאחר קריאה לא מכסה.
- ביטול ידני: השולח יכול לבטל את הקישור לסוד שלא נקרא מלוח הבקרה ברגע שמשהו נראה לא בסדר. ביטוח זול למקרה של "שלחתי לאלכס הלא נכון".
מקרי קצה שמבדילים מימושים טובים
האדם הלא נכון פותח את הקישור ראשון
מחיקה לאחר קריאה לא עוצרת את הצופה הלא נכון, אבל היא הופכת את המסירה השגויה לגלויה. הנמען המיועד מגיע לעמוד שאומר שהתוכן כבר נמחק, יומן הביקורת מראה צפייה אחת שלא ציפיתם לה, ואתם מחליפים את פרטי ההתחברות. זה כואב, אבל תחום: חשיפה אחת בדיוק, שמתגלה מהר. השוו את זה לסוד שהודבק בצ'אט, שבו לא הייתם יודעים על כך אף פעם.
בעיית צילום המסך
חד-פעמי פירושו הצגה אחת, לא עותק אחד. הנמען יכול לצלם את המסך, להעתיק או להקליד מחדש את הסוד, ושום דבר בקישור לא מונע את זה. ספקים שרומזים אחרת מוכרים בדיה. התיאור הכן: מחיקה לאחר קריאה שולטת בערוץ המסירה, לא בנמען. האמון בנמען הוא נתון שאתם מביאים, לא משהו שהכלי מספק.
טעינה מוקדמת וטעינה ספקולטיבית בדפדפן
חלק מהדפדפנים והאפליקציות טוענים מראש URL שסביר שילחצו עליהם. כלי שבנוי נכון מוחק רק בבקשת POST מפורשת, ולכן טעינה מוקדמת מתנהגת בדיוק כמו בוט של תצוגה מקדימה: היא יכולה להוריד את מעטפת העמוד, אף פעם לא את התוכן.
מתי לא להשתמש בקישור חד-פעמי
- תצטרכו את הערך שוב. אפשרות השחזור היא כל מטרת התכנון של מנהל סיסמאות, ובדיוק מה שקישור חד-פעמי נועד למנוע. השתמשו בפריט בכספת.
- כמה נמענים צריכים את הסוד. קישור אחד, קריאה אחת. הפצה לכמה אנשים פירושה ליצור קישור לכל אדם (זה בסדר, ואפשר לעקוב אחריו ביומן), או להודות שאתם צריכים בקרת גישה משותפת.
- מי שצורך את הסוד הוא מכונה. שירותים ו-CI צריכים לקרוא ממנהל סודות, לא ללחוץ על קישורים.
אם אתם רוצים לראות את המנגנון בפעולה, צרו קישור במחולל הקישורים הסודיים החינמי: בלי חשבון, מוצפן בדפדפן שלכם, נמחק אחרי ההצגה הראשונה.
קריאה נוספת
- שיתוף סיסמאות מאובטח: השוואה בין 5 שיטות
- One Time Secret, קישור חד-פעמי וקישור סודי: העמודים הקצרים עם הכלי בראש העמוד
- האם OneTimeSecret מאובטח?
- החלופות הטובות ביותר ל-OneTimeSecret
- OneTimeSecret מול Privnote מול Password Pusher
- קישורים עם תפוגה: הסוד מושמד, לא רק מפנה למקום אחר
- מחולל קישורים סודיים חינמי
מקורות
- W3C, Web Cryptography API.
- IETF, RFC 3986, section 3.5 Fragment.
- IETF, RFC 9110: HTTP Semantics, section 15.5.11 410 Gone.
- PostgreSQL Documentation, UPDATE.
