تشفير حمولة البيانات على الويب

Mat Scales

قبل الإصدار 50 من Chrome، لم يكن بإمكان الرسائل الفورية أن تحتوي على أي بيانات حمولة. عند بدء حدث "push" في worker الخدمة، كل ما كنت تعرفه هو أنّ الخادم كان يحاول إعلامك بشيء ما، ولكن لم تكن تعرف ما هو. بعد ذلك، كان عليك إرسال طلب متابعة إلى الخادم والحصول على تفاصيل الإشعار المطلوب عرضه، والذي قد يتعذّر عرضه في حالات ضعف شبكة الاتصال.

في الإصدار 50 من Chrome (وفي الإصدار الحالي من Firefox على أجهزة الكمبيوتر المكتبي)، يمكنك الآن إرسال بعض البيانات العشوائية مع عملية الإرسال لكي يتمكّن العميل منتجنُّب تقديم الطلب الإضافي. ومع ذلك، مع زيادة القدرات تزداد المسؤولية، لذلك يجب تشفير جميع بيانات الحمولة.

يشكّل تشفير الحمولات جزءًا مهمًا من قصة الأمان في إشعارات الدفع على الويب. يوفّر لك بروتوكول HTTPS أمانًا عند التواصل بين المتصفّح وخادمك، لأنّك تثق بالخادم. ومع ذلك، يختار المتصفح مقدم خدمة الدفع الذي سيتم استخدامه لتسليم الحمولة، وبالتالي لا يمكنك، بصفتك مطور التطبيق، التحكم في ذلك.

ولا يضمن HTTPS هنا سوى عدم تمكن أي شخص من التجسس على الرسالة أثناء نقلها إلى مقدم خدمة الإرسال. وبعد استلامها، يمكنهم فعل ما يحلو لهم، بما في ذلك إعادة إرسال الحمولة إلى جهات خارجية أو تغييرها بشكل ضار إلى شيء آخر. للحماية من ذلك، نستخدم التشفير لضمان عدم تمكّن خدمات الإرسال الفوري من قراءة الحمولات أثناء نقلها أو التلاعب بها.

التغييرات من جهة العميل

إذا سبق لك تنفيذ إشعارات فورية بدون حِزم بيانات، فما عليك سوى إجراء تغييرَين صغيرَين على جانب العميل.

الطريقة الأولى هي أنّه عند إرسال معلومات الاشتراك إلى خادم الخلفية، عليك جمع بعض المعلومات الإضافية. إذا كنت تستخدم 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 إلى نقطة النهاية في الاشتراك، مع إضافة بعض رؤوس HTTP الإضافية.

إنّ التفاصيل معقّدة نسبيًا، وكما هو الحال مع أيّ شيء مرتبط بتشفير، من الأفضل استخدام مكتبة تم تطويرها بشكل نشط بدلاً من إنشاء مكتبتك الخاصة. نشر فريق Chrome مكتبة لنظام Node.js، وسنضيف قريبًا المزيد من اللغات والمنصات. ويعالج هذا الإجراء كلاً من التشفير وبروتوكول إرسال البيانات على الويب، بحيث يكون إرسال رسالة الدفع من خادم Node.js أمرًا سهلاً مثل webpush.sendWebPush(message, subscription).

على الرغم من أنّنا ننصح باستخدام مكتبة، إلا أنّ هذه الميزة جديدة ويوجد العديد من اللغات الشائعة التي لا تتضمّن أي مكتبات بعد. إذا كنت بحاجة إلى تنفيذ ذلك لنفسك، فإليك التفاصيل.

سأوضّح الخوارزميات باستخدام JavaScript المتوافقة مع Node، ولكن يجب أن تكون مبادئها الأساسية متطابقة في أي لغة.

مدخلات

لتشفير رسالة، يجب أولاً الحصول على شيئَين من عنصر الاشتراك الذي تلقّيناه من العميل. إذا استخدمت 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');

يسمح لنا المفتاح العام بتشفير الرسالة بحيث لا يمكن فك تشفيرها إلا باستخدام المفتاح الخاص للعميل.

تُعتبر المفاتيح العامة عادةً علنية، لذا للسماح للعميل بإثبات أنّ الرسالة قد أرسلها خادم موثوق، نستخدم أيضًا سر المصادقة. من غير المفاجئ أنّه يجب الحفاظ على سرية هذا المفتاح، وعدم مشاركته إلا مع خادم التطبيق الذي تريد أن يرسل إليك الرسائل، والتعامل معه مثل كلمة المرور.

نحتاج أيضًا إلى إنشاء بعض البيانات الجديدة. نحتاج إلى ملح عشوائي آمن من ناحية التشفير بسعة 16 بايت ومفتاحَي المنحنى الإهليلجي عام/خاص. ويُسمى المنحنى الخاص الذي تستخدمه مواصفات تشفير الدفع P-256 أو prime256v1. ولتحقيق أفضل مستوى أمان، يجب إنشاء مفتاحَي تشفير من البداية في كل مرة تشفِّر فيها رسالة، ويجب عدم إعادة استخدام البيانات العشوائية أبدًا.

ECDH

لنتحدث جانبًا قليلاً عن الخصائص المرتبة لتشفير منحنى الإهليلجي. هناك عملية بسيطة نسبيًا تجمع بين مفتاحك الخاص والمفتاح العام لشخص آخر لاشتقاق قيمة. ماذا يعني ذلك؟ حسنًا، إذا أخذ الطرف الآخر مفتاحه الخاص ومفتاحك العام، سيحصل على القيمة نفسها تمامًا.

وهذا هو الأساس لبروتوكول اتفاقية مفتاح منحنى ديفي هيلمان (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 مرة أطول من تجزئة تم إنشاؤها باستخدام أي خوارزمية تجزئة تستخدمها. بالنسبة إلى الدفع، تتطلّب المَواصفات استخدام 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 لخلط سر مصادقة العميل والسر المشترَك لإنشاء سر أطول وأكثر أمانًا من الناحية التشفيرية. في المواصفات، تتم الإشارة إلى ذلك باسم مفتاح شبه عشوائي (PRK)، وسأستخدم هذا الاسم هنا، على الرغم من أنّ خبراء التشفير قد يلاحظون أنّ هذا المفتاح ليس مفتاحًا شبه عشوائي بدقة.

الآن سننشئ مفتاح التشفير النهائي للمحتوى ومفتاح تشفير عشوائي سيتم تمريره إلى التشفير. يتم إنشاء هذه العناصر من خلال إنشاء بنية بيانات بسيطة لكل منها، ويُشار إليها في المواصفات باسم info، والتي تحتوي على معلومات خاصة بالمنحنى الإهليجي ومُرسِل ومستلِم المعلومات من أجل التحقّق بشكل أكبر من مصدر الرسالة. بعد ذلك، نستخدم HKDF مع PRK، والملح والمعلومات لاشتقاق المفتاح وnonce بالحجم الصحيح.

نوع المعلومات لتشفير المحتوى هو 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 بت، سيتمكّن أي موظف يتجسّس من معرفة وقت وصول الحلويات بدون فك تشفير الرسائل، وذلك من طول البيانات فقط.

لهذا السبب، يسمح لك بروتوكول الإشعارات الفورية على الويب بإضافة مساحة فارغة إلى بداية البيانات. تعتمد طريقة استخدام هذه الميزة على تطبيقك، ولكن في المثال أعلاه، يمكنك إضافة محتوى زائد إلى جميع الرسائل ليصبح حجمها 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. نستخدم مفتاح تشفير المحتوى لدينا كمفتاح والرقم غير ذلك كمتجه تهيئة (IV).

بياناتنا في هذا المثال عبارة عن سلسلة، ولكن يمكن أن تكون أي بيانات ثنائية. يمكنك إرسال حمولات يصل حجمها إلى 4078 بايت - 4096 بايت كحد أقصى لكل مشاركة، مع 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()]);

إرسال إلى الويب

أخيرًا! بعد أن أصبحت لديك حمولة مشفّرة، ما عليك سوى إرسال طلب HTTP POST بسيط نسبيًا إلى نقطة النهاية المحدّدة من خلال اشتراك المستخدم.

عليك ضبط ثلاثة عناوين.

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

<SALT> و<PUBLICKEY> هما الملح والمفتاح العام للخادم المستخدَمَين في التشفير، ويتم ترميزهما بترميز Base64 الآمن لعناوين URL.

عند استخدام بروتوكول Web Push، يكون نص طلب POST هو ملف ‎.bin غير المُعدَّل فقط للرسالة المشفَّرة. ومع ذلك، إلى أن يتيح Chrome وFirebase Cloud Messaging هذا البروتوكول، يمكنك بسهولة تضمين البيانات في حمولة JSON الحالية على النحو التالي.

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

يجب أن تكون قيمة السمة rawData هي التمثيل المشفَّر بترميز base64 للرسالة المشفَّرة.

تصحيح الأخطاء / أداة التحقّق

أنشأ "بيتر بيفيرلو"، أحد مهندسي Chrome الذين نفّذوا الميزة (بالإضافة إلى أحد الأشخاص الذين عملوا على المواصفات)،أداة التحقّق.

من خلال جعل الرمز يعرض كل قيمة من القيم الوسيطة للتشفير، يمكنك لصقها في أداة التحقّق والتأكّد من أنّك على المسار الصحيح.

.