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

Mat Scales

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

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

הצפנת עומסי העבודה היא חלק חשוב באבטחה של הודעות Web Push. פרוטוקול 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)

השינוי השני הוא נכס data חדש שמופיע כשהאירוע 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, כך ששליחת הודעת דחיפה משרת Node.js היא פשוטה כמו webpush.sendWebPush(message, subscription).

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

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

קלט

כדי להצפין הודעה, קודם צריך לקבל שני דברים מאובייקט המינוי שקיבלנו מהלקוח. אם השתמשתם ב-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 בייטים. המשמעות היא שאנחנו יכולים להשתמש בגרסה פשוטה יותר של האלגוריתם, שלא יכולה לטפל בגדלים גדולים יותר של פלט.

הקוד לגרסה של Node מופיע בהמשך, אבל אפשר לקרוא איך הוא פועל בפועל במאמר 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 כדי לערבב את הסוד לאימות הלקוח ואת הסוד המשותף לסוד ארוך יותר ומאובטח יותר מבחינה קריפטוגרפית. במפרט הוא נקרא מפתח פסאודו-אקראי (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);

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

הצפנה

עכשיו יש לנו סוף-סוף את כל מה שדרוש כדי לבצע את ההצפנה. הצופן הנדרש להודעות 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> הם המלח והמפתח הציבורי של השרת שנעשה בהם שימוש בהצפנה, בקידוד Base64 בטוח לכתובות URL.

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

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

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

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

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

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