הצפנה של מטען ייעודי (payload) באינטרנט

Mat Scales

לפני Chrome 50, הודעות Push לא היו יכולות להכיל נתוני מטען ייעודי (payload). כשהאירוע'push' הופעל ב-service worker, כל מה שידעתם הוא שהשרת ניסה לומר לכם משהו, אבל לא מה זה יכול להיות. לאחר מכן היה צריך לשלוח בקשה נוספת לשרת ולקבל את פרטי ההתראה שרוצים להציג, ויכול להיות שהבקשה הזו תיכשל בתנאים של רשת חלשה.

עכשיו ב-Chrome 50 (ובגרסה הנוכחית של Firefox במחשב) אפשר לשלוח נתונים שרירותיים יחד עם ההודעה כדי שהלקוח יוכל להימנע מהגשת הבקשה הנוספת. עם זאת, יש להשתמש בכוח הקנייה באופן אחראי, ולכן כל נתוני המטען הייעודי חייבים להיות מוצפנים.

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

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

שינויים בצד הלקוח

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

הבעיה הראשונה היא שכדי לשלוח את פרטי המינוי לשרת הקצה העורפי, צריך לאסוף מידע נוסף. אם אתם כבר משתמשים ב-JSON.stringify() באובייקט PushSubscription כדי לבצע סריאליזציה שלו לשליחה לשרת, לא צריך לשנות שום דבר. עכשיו למינוי יהיו עוד כמה נתונים בנכס המפתחות.

> JSON.stringify(subscription)
{"endpoint":"https://android.googleapis.com/gcm/send/f1LsxkKphfQ:APA91bFUx7ja4BK4JVrNgVjpg1cs9lGSGI6IMNL4mQ3Xe6mDGxvt_C_gItKYJI9CAx5i_Ss6cmDxdWZoLyhS2RJhkcv7LeE6hkiOsK6oBzbyifvKCdUYU7ADIRBiYNxIVpLIYeZ8kq_A",
"keys":{"p256dh":"BLc4xRzKlKORKWlbdgFaBrrPK3ydWAHo4M0gs0i1oEKgPpWC5cW8OCzVrOQRv-1npXRWk8udnW3oYhIO4475rds=",
"auth":"5I2Bu2oKdyy9CwL8QVF0NQ=="}}

שני הערכים p256dh ו-auth מקודדים בגרסה של Base64 שאקרא לה Base64 ללא סימנים לא חוקיים בכתובות URL.

אם רוצים לקבל את הבייטים ישירות, אפשר להשתמש במקום זאת בשיטה החדשה getKey() במינוי, שמחזירה פרמטר בתור ArrayBuffer. שני הפרמטרים הנדרשים הם auth ו-p256dh.

> new Uint8Array(subscription.getKey('auth'));
[228, 141, 129, ...] (16 bytes)

> new Uint8Array(subscription.getKey('p256dh'));
[4, 183, 56, ...] (65 bytes)

השינוי השני הוא מאפיין נתונים חדש כשהאירוע push מופעל. יש לו שיטות סינכרוניות שונות לניתוח הנתונים שהתקבלו, כמו .text(), .json(), .arrayBuffer() ו-.blob().

self.addEventListener('push', function(event) {
  if (event.data) {
    console.log(event.data.json());
  }
});

שינויים בצד השרת

בצד השרת, הדברים משתנים קצת יותר. התהליך הבסיסי הוא שימוש בפרטי מפתח ההצפנה שקיבלת מהלקוח כדי להצפין את עומס העבודה, ולאחר מכן שליחתו כגוף של בקשת POST לנקודת הקצה (endpoint) במינוי, עם הוספה של כמה כותרות HTTP נוספות.

הפרטים מורכבים יחסית, וכמו בכל דבר שקשור להצפנה, עדיף להשתמש בספרייה שפותחה באופן פעיל מאשר להצפין אותם בעצמכם. צוות Chrome פרסם ספרייה ל-Node.js, ובקרוב יהיו זמינות שפות ופלטפורמות נוספות. הספרייה מטפלת גם בהצפנה וגם בפרוטוקול ה-Web Push, כך ששליחה של הודעת Push משרת Node.js היא פשוטה כמו webpush.sendWebPush(message, subscription).

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

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

קלט

כדי להצפין הודעה, קודם צריך לקבל שני דברים מאובייקט המינוי שקיבלנו מהלקוח. אם השתמשתם ב-JSON.stringify() בלקוח והעברתם אותו לשרת, המפתח הציבורי של הלקוח מאוחסן בשדה keys.p256dh, והסוד המשותף לאימות נמצא בשדה keys.auth. שני הערכים האלה יהיו מקודדים ב-Base64 ללא סימנים שאינם חוקיים בכתובות URL, כפי שצוין למעלה. הפורמט הבינארי של המפתח הציבורי של הלקוח הוא נקודת עקומה אליפטית (EC) לא דחוסה מסוג P-256.

const clientPublicKey = new Buffer(subscription.keys.p256dh, 'base64');
const clientAuthSecret = new Buffer(subscription.keys.auth, 'base64');

המפתח הציבורי מאפשר לנו להצפין את ההודעה כך שאפשר לפענח אותה רק באמצעות המפתח הפרטי של הלקוח.

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

אנחנו גם צריכים ליצור נתונים חדשים. אנחנו זקוקים למלח אקראי מאובטח מבחינה קריפטוגרפית באורך 16 בייטים, ולזוג מפתחות אליפטיים ציבורי/פרטי. העקומה הספציפית שבה נעשה שימוש במפרט ההצפנה של הדחיפה נקראת P-256 או prime256v1. כדי לשפר את האבטחה, צריך ליצור את זוג המפתחות מחדש בכל פעם שמצפינים הודעה, ואסור להשתמש שוב באותו מלח.

ECDH

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

זהו הבסיס לפרוטוקול ההסכמה על מפתחות של Diffie-Hellman (ECDH) בעקומה אליפטית, שמאפשר לשני הצדדים לקבל את אותו סוד משותף, למרות שהם רק החליפו מפתחות ציבוריים. נשתמש בסוד המשותף הזה בתור הבסיס למפתח ההצפנה האמיתי שלנו.

const crypto = require('crypto');

const salt = crypto.randomBytes(16);

// Node has ECDH built-in to the standard crypto library. For some languages
// you may need to use a third-party library.
const serverECDH = crypto.createECDH('prime256v1');
const serverPublicKey = serverECDH.generateKeys();
const sharedSecret = serverECDH.computeSecret(clientPublicKey);

HKDF

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

אחת מההשלכות של אופן הפעולה שלו היא שאפשר לקחת סוד של כל מספר סיביות וליצור סוד אחר בכל גודל, עד פי 255 מגיבוב שנוצר על ידי כל אלגוריתם גיבוב שבו משתמשים. כדי לבצע push, המפרט מחייב אותנו להשתמש ב-SHA-256, עם אורך גיבוב של 32 בייטים (256 ביט).

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

כללתי בהמשך את הקוד של גרסת צומת, אבל אפשר לראות איך הוא עובד בפועל ב-RFC 5869.

הקלט של HKDF הוא מלח, חומר מפתח ראשוני (ikm), קטע אופציונלי של נתונים מובְנים שספציפי לתרחיש לדוגמה הנוכחי (info) והאורך בבייט של מפתח הפלט הרצוי.

// Simplified HKDF, returning keys up to 32 bytes long
function hkdf(salt, ikm, info, length) {
  if (length > 32) {
    throw new Error('Cannot return keys of more than 32 bytes, ${length} requested');
  }

  // Extract
  const keyHmac = crypto.createHmac('sha256', salt);
  keyHmac.update(ikm);
  const key = keyHmac.digest();

  // Expand
  const infoHmac = crypto.createHmac('sha256', key);
  infoHmac.update(info);
  // A one byte long buffer containing only 0x01
  const ONE_BUFFER = new Buffer(1).fill(1);
  infoHmac.update(ONE_BUFFER);
  return infoHmac.digest().slice(0, length);
}

הפקת פרמטרים של הצפנה

עכשיו אנחנו משתמשים ב-HKDF כדי להפוך את הנתונים שיש לנו לפרמטרים של ההצפנה בפועל.

הדבר הראשון שאנחנו עושים הוא להשתמש ב-HKDF כדי לערבב את הסוד לאימות הלקוח ואת הסוד המשותף לסוד ארוך יותר ומאובטח יותר מבחינה קריפטוגרפית. במפרט הוא נקרא מפתח Pseudo-Random Key (ראשי תיבות של PRK), ולכן זה מה שנקרא כאן, אבל מומחי קריפטוגרפיה עשויים לשים לב שלא מדובר רק ב-PRK.

עכשיו יוצרים את מפתח ההצפנה הסופי של התוכן וnonce שיועברו למצפין. כדי ליצור אותם, יוצרים מבנה נתונים פשוט לכל אחד מהם, שנקרא במפרט info, שמכיל מידע ספציפי לגבי העקומה האליפטית, השולח והנמען של המידע, כדי לאמת עוד יותר את מקור ההודעה. לאחר מכן אנחנו משתמשים ב-HKDF עם ה-PRK, המלח והמידע כדי להפיק את המפתח ואת המזהה החד-פעמי בגודל הנכון.

סוג המידע של הצפנת התוכן הוא 'aesgcm', שהוא השם של הצופן שמשמש להצפנת דחיפה.

const authInfo = new Buffer('Content-Encoding: auth\0', 'utf8');
const prk = hkdf(clientAuthSecret, sharedSecret, authInfo, 32);

function createInfo(type, clientPublicKey, serverPublicKey) {
  const len = type.length;

  // The start index for each element within the buffer is:
  // value               | length | start    |
  // -----------------------------------------
  // 'Content-Encoding: '| 18     | 0        |
  // type                | len    | 18       |
  // nul byte            | 1      | 18 + len |
  // 'P-256'             | 5      | 19 + len |
  // nul byte            | 1      | 24 + len |
  // client key length   | 2      | 25 + len |
  // client key          | 65     | 27 + len |
  // server key length   | 2      | 92 + len |
  // server key          | 65     | 94 + len |
  // For the purposes of push encryption the length of the keys will
  // always be 65 bytes.
  const info = new Buffer(18 + len + 1 + 5 + 1 + 2 + 65 + 2 + 65);

  // The string 'Content-Encoding: ', as utf-8
  info.write('Content-Encoding: ');
  // The 'type' of the record, a utf-8 string
  info.write(type, 18);
  // A single null-byte
  info.write('\0', 18 + len);
  // The string 'P-256', declaring the elliptic curve being used
  info.write('P-256', 19 + len);
  // A single null-byte
  info.write('\0', 24 + len);
  // The length of the client's public key as a 16-bit integer
  info.writeUInt16BE(clientPublicKey.length, 25 + len);
  // Now the actual client public key
  clientPublicKey.copy(info, 27 + len);
  // Length of our public key
  info.writeUInt16BE(serverPublicKey.length, 92 + len);
  // The key itself
  serverPublicKey.copy(info, 94 + len);

  return info;
}

// Derive the Content Encryption Key
const contentEncryptionKeyInfo = createInfo('aesgcm', clientPublicKey, serverPublicKey);
const contentEncryptionKey = hkdf(salt, prk, contentEncryptionKeyInfo, 16);

// Derive the Nonce
const nonceInfo = createInfo('nonce', clientPublicKey, serverPublicKey);
const nonce = hkdf(salt, prk, nonceInfo, 12);

מרווח

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

ההצפנה שבה נעשה שימוש ב-Web Push יוצרת ערכים מוצפנים באורך של 16 בייטים בדיוק מאשר הקלט הלא מוצפן. מכיוון שההודעה 'דונאטים במנוחה' ארוכה יותר ממחיר מניה של 32 ביט, כל עובד שמתגנב יוכל לדעת מתי מגיעים הדונאטים בלי לפענח את ההודעות, רק לפי אורך הנתונים.

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

ערך המילוי הוא מספר שלם של 16 ביט ב-big-endian שקובע את אורך המילוי, ואחריו מספר הבייטים של המילוי בגודל NUL. לכן, המרווח הפנימי המינימלי הוא שני בייטים – המספר אפס מקודד ל-16 ביטים.

const padding = new Buffer(2 + paddingLength);
// The buffer must be only zeroes, except the length
padding.fill(0);
padding.writeUInt16BE(paddingLength, 0);

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

הצפנה

עכשיו יש לנו סוף-סוף את כל מה שדרוש כדי לבצע את ההצפנה. הצופן הנדרש להודעות Web Push הוא AES128 באמצעות GCM. אנחנו משתמשים במפתח ההצפנה של התוכן כמפתח, וב-nonce כוקטור האתחול (IV).

בדוגמה הזו, הנתונים הם מחרוזת, אבל הם יכולים להיות כל נתונים בינאריים. אפשר לשלוח עומסי נתונים (payload) בגודל של עד 4,078 בייטים – 4,096 בייטים לכל היותר בכל הודעה, עם 16 בייטים לפרטי ההצפנה ולפחות 2 בייטים למעטפת.

// Create a buffer from our data, in this case a UTF-8 encoded string
const plaintext = new Buffer('Push notification payload!', 'utf8');
const cipher = crypto.createCipheriv('id-aes128-GCM', contentEncryptionKey,
nonce);

const result = cipher.update(Buffer.concat(padding, plaintext));
cipher.final();

// Append the auth tag to the result - https://nodejs.org/api/crypto.html#crypto_cipher_getauthtag
return Buffer.concat([result, cipher.getAuthTag()]);

התראות Push מהאינטרנט

סוף סוף! עכשיו, כשיש לכם עומס נתונים מוצפן, אתם צריכים רק לשלוח בקשת HTTP POST פשוטה יחסית לנקודת הקצה שצוינה במינוי של המשתמש.

צריך להגדיר שלוש כותרות.

Encryption: salt=<SALT>
Crypto-Key: dh=<PUBLICKEY>
Content-Encoding: aesgcm

<SALT> ו-<PUBLICKEY> הם המפתח הציבורי של השרת וה-salt שמשמשים להצפנה, ומקודדים כ-Base64 ללא כתובת URL.

כשמשתמשים בפרוטוקול Web Push, גוף ה-POST הוא רק הבייטים הגולמיים של ההודעה המוצפנת. עם זאת, עד ש-Chrome ו-Firebase Cloud Messaging יתמכו בפרוטוקול, תוכלו לכלול את הנתונים בקלות במטען הייעודי (payload) הקיים של ה-JSON באופן הבא.

{
    "registration_ids": [ "…" ],
    "raw_data": "BIXzEKOFquzVlr/1tS1bhmobZ…"
}

הערך של המאפיין rawData חייב להיות ייצוג בקידוד base64 של ההודעה המוצפנת.

ניפוי באגים / מאמת

Peter Beverloo, אחד מהמהנדסים של Chrome שהטמיעו את התכונה (וגם אחד מהאנשים שעבדו על המפרט), יצר מאמת.

אם תגרמו לקוד להפיק את כל הערכים הביניים של ההצפנה, תוכלו להדביק אותם באימות ולבדוק שאתם בדרך הנכונה.