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