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

موازين حصيرة

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

في Chrome 50 (وفي الإصدار الحالي من 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 سأطلق عليها URL-Safe Base64.

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

مدخلات

لتشفير رسالة، نحتاج أولاً إلى الحصول على شيئين من كائن الاشتراك الذي تلقيناه من العميل. إذا استخدمت JSON.stringify() على جهاز العميل ونقلته إلى خادمك، سيتم تخزين مفتاح المصادقة العام للعميل في الحقل keys.p256dh، بينما يتوفّر سر المصادقة المشتركة في حقل keys.auth. سيكون كلاهما متوافقًا مع عنوان URL بترميز Base64، كما هو مذكور أعلاه. التنسيق الثنائي للمفتاح العام للعميل هو نقطة منحنى بيضاوية غير مضغوطة غير مضغوطة.

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

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

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

نحتاج أيضًا إلى إنشاء بعض البيانات الجديدة. نحتاج إلى ملح عشوائي آمن بحجم 16 بايت على شكل 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);

30

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

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

في هذا المثال، بياناتنا عبارة عن سلسلة، ولكن يمكن أن تكون أي بيانات ثنائية. يمكنك إرسال حمولات يصل حجمها إلى 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 هو مجرد وحدات البايت الأولية للرسالة المُشفرة. ومع ذلك، إلى أن يتيح كل من Chrome و"المراسلة عبر السحابة الإلكترونية من Firebase" استخدام هذا البروتوكول، يمكنك بسهولة تضمين البيانات في حمولة JSON الحالية على النحو التالي.

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

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

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

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

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

.