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

קנה מידה של משטח

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

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

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

במקרה הזה, פרוטוקול 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, שנקרא URL-Safe Base64.

אם רוצים להגיע ישירות לבייטים במקום זאת, אפשר להשתמש ב-method החדש 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());
  }
});

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

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

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

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

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

קלט

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

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

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

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

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

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

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

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

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

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

הקלט ל-HKDF הוא salt, חומר מפתח ראשוני (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.

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

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

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 סיביות, שמציין את אורך המרווח הפנימי ואחריו 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).

בדוגמה הזו הנתונים שלנו הם מחרוזת, אבל הם יכולים להיות כל נתונים בינאריים. אפשר לשלוח מטענים ייעודיים (payloads) בגודל של עד 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()]);

דחיפה באינטרנט

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

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

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

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

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

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

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

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

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

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

.