Web Push Payload Encryption

Mat Scales

قبل از Chrome 50، پیام‌های فشار نمی‌توانست حاوی داده‌های باری باشد. هنگامی که رویداد "فشار" در سرویس‌کار شما فعال شد، تنها چیزی که می‌دانستید این بود که سرور می‌خواست چیزی را به شما بگوید، اما نه آنچه ممکن است باشد. سپس باید یک درخواست پیگیری به سرور ارائه می‌دادید و جزئیات اعلان را برای نمایش به دست می‌آورید، که ممکن است در شرایط بد شبکه با شکست مواجه شود.

اکنون در کروم 50 (و در نسخه فعلی فایرفاکس روی دسکتاپ) می توانید برخی از داده های دلخواه را همراه با فشار ارسال کنید تا مشتری بتواند از درخواست اضافی جلوگیری کند. با این حال، با قدرت زیاد، مسئولیت بزرگی به همراه دارد، بنابراین تمام داده‌های محموله باید رمزگذاری شوند.

رمزگذاری بارها بخش مهمی از داستان امنیتی برای فشار وب است. HTTPS هنگام برقراری ارتباط بین مرورگر و سرور خود امنیت به شما می دهد، زیرا به سرور اعتماد دارید. با این حال، مرورگر انتخاب می‌کند که از کدام ارائه‌دهنده فشار برای تحویل بار واقعی استفاده شود، بنابراین شما، به‌عنوان توسعه‌دهنده برنامه، هیچ کنترلی روی آن ندارید.

در اینجا، HTTPS فقط می‌تواند تضمین کند که هیچ‌کس نمی‌تواند پیامی را که در حال انتقال به ارائه‌دهنده سرویس فشار است، جاسوسی کند. هنگامی که آنها آن را دریافت کردند، آزادند هر کاری را که دوست دارند انجام دهند، از جمله انتقال مجدد محموله به شخص ثالث یا تغییر بدخواهانه آن به چیز دیگری. برای محافظت در برابر این، از رمزگذاری استفاده می‌کنیم تا اطمینان حاصل کنیم که سرویس‌های فشار نمی‌توانند محموله‌های حمل و نقل را بخوانند یا دستکاری کنند.

تغییرات سمت مشتری

اگر قبلاً اعلان‌های فشاری را بدون بار پیاده‌سازی کرده‌اید، تنها دو تغییر کوچک وجود دارد که باید در سمت مشتری انجام دهید.

این اول این است که وقتی اطلاعات اشتراک را به سرور باطن خود ارسال می کنید، باید اطلاعات اضافی را جمع آوری کنید. اگر قبلاً از JSON.stringify() روی شی PushSubscription برای سریال سازی آن برای ارسال به سرور خود استفاده می کنید، نیازی به تغییر چیزی ندارید. اشتراک اکنون مقداری داده اضافی در ویژگی keys خواهد داشت.

> 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)

تغییر دوم یک ویژگی داده جدید است که رویداد push فعال می شود. دارای روش‌های همزمان مختلف برای تجزیه داده‌های دریافتی، مانند .text() .json() ، .arrayBuffer() و .blob() .

self.addEventListener('push', function(event) {
  if (event.data) {
    console.log(event.data.json());
  }
});

تغییرات سمت سرور

در سمت سرور، همه چیز کمی بیشتر تغییر می کند. فرآیند اصلی این است که شما از اطلاعات کلید رمزگذاری که از مشتری دریافت کرده اید برای رمزگذاری بار استفاده می کنید و سپس آن را به عنوان متن درخواست POST به نقطه پایانی در اشتراک ارسال می کنید و تعدادی هدر HTTP اضافی اضافه می کنید.

جزئیات نسبتاً پیچیده هستند، و مانند هر چیزی که به رمزگذاری مربوط می شود، بهتر است از یک کتابخانه توسعه یافته فعال استفاده کنید تا کتابخانه خودتان. تیم کروم کتابخانه‌ای را برای Node.js منتشر کرده است که به زودی زبان‌ها و پلتفرم‌های بیشتری را در اختیار دارد. این کار هم رمزگذاری و هم پروتکل فشار وب را کنترل می کند، به طوری که ارسال پیام فشار از سرور Node.js به آسانی webpush.sendWebPush(message, subscription) است.

در حالی که ما قطعا استفاده از کتابخانه را توصیه می کنیم، این یک ویژگی جدید است و بسیاری از زبان های محبوب وجود دارند که هنوز هیچ کتابخانه ای ندارند. اگر لازم است این را برای خودتان پیاده کنید، در اینجا جزئیات وجود دارد.

من الگوریتم ها را با استفاده از جاوا اسکریپت با طعم گره نشان خواهم داد، اما اصول اولیه باید در هر زبانی یکسان باشد.

ورودی ها

برای رمزگذاری یک پیام، ابتدا باید دو چیز را از شیء اشتراکی که از مشتری دریافت کردیم دریافت کنیم. اگر از JSON.stringify() روی کلاینت استفاده کرده اید و آن را به سرور خود ارسال کرده اید، کلید عمومی مشتری در قسمت keys.p256dh ذخیره می شود، در حالی که راز احراز هویت مشترک در قسمت keys.auth است. همانطور که در بالا ذکر شد، هر دوی این ها با URL ایمن Base64 کدگذاری می شوند. فرمت باینری کلید عمومی مشتری یک نقطه منحنی بیضوی 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

در حال حاضر زمان برای دیگری کنار. فرض کنید برخی از داده‌های مخفی دارید که می‌خواهید از آنها به عنوان کلید رمزگذاری استفاده کنید، اما از نظر رمزنگاری به اندازه کافی امن نیستند. می‌توانید از تابع استخراج کلید مبتنی بر HMAC (HKDF) برای تبدیل یک راز با امنیت پایین به راز با امنیت بالا استفاده کنید.

یکی از پیامدهای روش کار این است که به شما امکان می‌دهد یک راز از هر تعداد بیت را بردارید و یک راز دیگر با هر اندازه تا 255 برابر طولانی‌تر از هش تولید شده توسط هر الگوریتم هش که استفاده می‌کنید تولید کنید. برای فشار، مشخصات ما را ملزم به استفاده از SHA-256 می کند که طول هش آن 32 بایت (256 بیت) است.

همانطور که اتفاق می افتد، ما می دانیم که فقط باید کلیدهایی با اندازه حداکثر 32 بایت تولید کنیم. این بدان معنی است که ما می توانیم از یک نسخه ساده شده از الگوریتم استفاده کنیم که نمی تواند اندازه های خروجی بزرگتر را مدیریت کند.

من کد یک نسخه Node را در زیر قرار داده ام، اما می توانید نحوه عملکرد آن را در RFC 5869 دریابید.

ورودی‌های HKDF عبارتند از نمک، مقداری مواد کلیدی اولیه (ikm)، یک قطعه اختیاری از داده‌های ساختاریافته خاص برای استفاده فعلی (اطلاعات) و طول کلید خروجی مورد نظر بر حسب بایت.

// 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 بیتی است، هر کارمند جاسوسی می‌تواند بدون رمزگشایی پیام‌ها، فقط از روی طول داده، تشخیص دهد که دونات‌ها چه زمانی می‌رسند.

به همین دلیل، پروتکل فشار وب به شما امکان می دهد تا به ابتدای داده ها padding اضافه کنید. نحوه استفاده از این به برنامه شما بستگی دارد، اما در مثال بالا می‌توانید تمام پیام‌ها را دقیقاً 32 بایت داشته باشید، که تشخیص پیام‌ها را فقط بر اساس طول غیرممکن می‌کند.

مقدار padding یک عدد صحیح بزرگ اندیان 16 بیتی است که طول padding و به دنبال آن تعداد NUL بایت padding را مشخص می کند. بنابراین حداقل padding دو بایت است - عدد صفر به 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 بایت برای padding ارسال کنید.

// 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 Cloud Messaging از پروتکل پشتیبانی نکنند، می‌توانید به‌راحتی داده‌ها را به شرح زیر در payload JSON موجود خود قرار دهید.

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

مقدار ویژگی rawData باید نمایش کدگذاری شده base64 پیام رمزگذاری شده باشد.

اشکال زدایی / تأیید کننده

پیتر بورلو، یکی از مهندسان کروم که این ویژگی را پیاده‌سازی کرده است (و همچنین یکی از افرادی است که روی این مشخصات کار کرده است)، یک تأییدکننده ایجاد کرده است.

با دریافت کد خود برای خروجی هر یک از مقادیر میانی رمزگذاری، می توانید آنها را در تأیید کننده قرار دهید و بررسی کنید که در مسیر درستی هستید.