ก่อน Chrome 50 ข้อความ Push จะไม่มีข้อมูลเพย์โหลด เมื่อเหตุการณ์ "push" เริ่มต้นขึ้นใน Service Worker สิ่งเดียวที่คุณรู้คือเซิร์ฟเวอร์พยายามบอกอะไรบางอย่างกับคุณ แต่ไม่รู้ว่าเป็นอะไร จากนั้นคุณต้องส่งคําขอติดตามผลไปยังเซิร์ฟเวอร์และรับรายละเอียดการแจ้งเตือนเพื่อแสดง ซึ่งอาจไม่สําเร็จในสภาพเครือข่ายที่ไม่เอื้ออำนวย
ใน Chrome 50 (และใน Firefox เวอร์ชันปัจจุบันบนเดสก์ท็อป) คุณสามารถส่งข้อมูลที่กำหนดเองบางส่วนไปพร้อมกับ Push เพื่อให้ไคลเอ็นต์หลีกเลี่ยงการส่งคำขอเพิ่มเติมได้ อย่างไรก็ตาม พลังที่ยิ่งใหญ่มาพร้อมกับความรับผิดชอบอันใหญ่ยิ่ง ดังนั้นข้อมูลเพย์โหลดทั้งหมดจึงต้องได้รับการเข้ารหัส
การเข้ารหัสของเพย์โหลดเป็นส่วนสําคัญของการรักษาความปลอดภัยสําหรับ Web Push HTTPS ช่วยให้การสื่อสารระหว่างเบราว์เซอร์กับเซิร์ฟเวอร์ของคุณเองปลอดภัย เนื่องจากคุณไว้ใจเซิร์ฟเวอร์ อย่างไรก็ตาม เบราว์เซอร์จะเลือกผู้ให้บริการ Push ที่จะใช้ในการส่งพายโหลดจริง คุณในฐานะนักพัฒนาแอปจึงไม่สามารถควบคุมได้
ในกรณีนี้ HTTPS จะรับประกันได้เพียงว่าไม่มีใครสามารถสอดแนมข้อความระหว่างการส่งไปยังผู้ให้บริการ Push เมื่อได้รับแล้ว บุคคลที่สามจะทำสิ่งใดก็ได้ตามต้องการ ซึ่งรวมถึงการส่งต่อเพย์โหลดไปยังบุคคลที่สามอีกครั้งหรือดัดแปลงเป็นอย่างอื่นโดยมีเจตนาร้าย เราป้องกันปัญหานี้โดยใช้การเข้ารหัสเพื่อให้แน่ใจว่าบริการ Push จะอ่านหรือดัดแปลงเพย์โหลดระหว่างการส่งไม่ได้
การเปลี่ยนแปลงฝั่งไคลเอ็นต์
หากคุณได้ติดตั้งใช้งาน Push Notification โดยไม่มีพายโหลดแล้ว คุณต้องทำการเปลี่ยนแปลงเล็กๆ น้อยๆ เพียง 2 อย่างในฝั่งไคลเอ็นต์
ประการแรกคือเมื่อคุณส่งข้อมูลการสมัครใช้บริการไปยังเซิร์ฟเวอร์แบ็กเอนด์ คุณจะต้องรวบรวมข้อมูลเพิ่มเติม หากคุณใช้ 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=="}}
ค่า 2 ค่า p256dh
และ auth
ได้รับการเข้ารหัสในรูปแบบ Base64 ที่เราเรียกว่า Base64 ที่ปลอดภัยสำหรับ URL
หากต้องการดูเฉพาะไบต์แทน ให้ใช้เมธอด getKey()
ใหม่ในการสมัครใช้บริการซึ่งจะแสดงผลพารามิเตอร์เป็น ArrayBuffer
พารามิเตอร์ 2 รายการที่คุณต้องการคือ auth
และ p256dh
> new Uint8Array(subscription.getKey('auth'));
[228, 141, 129, ...] (16 bytes)
> new Uint8Array(subscription.getKey('p256dh'));
[4, 183, 56, ...] (65 bytes)
การเปลี่ยนแปลงที่ 2 คือพร็อพเพอร์ตี้ 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 เพื่อให้การส่งข้อความ Push จากเซิร์ฟเวอร์ Node.js เป็นเรื่องง่ายเพียง webpush.sendWebPush(message, subscription)
แม้ว่าเราจะขอแนะนำให้ใช้คลังอย่างจริงจัง แต่ฟีเจอร์นี้ยังเป็นฟีเจอร์ใหม่และยังมีภาษายอดนิยมหลายภาษาที่ไม่มีคลัง หากจำเป็นต้องติดตั้งใช้งานด้วยตนเอง โปรดดูรายละเอียดต่อไปนี้
เราจะอธิบายอัลกอริทึมโดยใช้ JavaScript รูปแบบ Node แต่หลักการพื้นฐานควรเหมือนกันในทุกภาษา
อินพุต
หากต้องการเข้ารหัสข้อความ เราต้องรับ 2 สิ่งจากออบเจ็กต์การสมัครใช้บริการที่ได้รับจากไคลเอ็นต์ก่อน หากคุณใช้ JSON.stringify()
ในไคลเอ็นต์และส่งไปยังเซิร์ฟเวอร์ ระบบจะจัดเก็บคีย์สาธารณะของไคลเอ็นต์ไว้ในช่อง keys.p256dh
ส่วนรหัสลับที่ใช้ร่วมกันเพื่อตรวจสอบสิทธิ์จะอยู่ในช่อง keys.auth
ข้อมูลทั้ง 2 รายการนี้จะเข้ารหัส 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
เรามาพักสักครู่เพื่อพูดคุยเกี่ยวกับคุณสมบัติที่น่าสนใจของวิทยาการเข้ารหัสด้วยเวกเตอร์รูปไข่กัน กระบวนการนี้ค่อนข้างง่าย ซึ่งจะรวมคีย์ส่วนตัวของคุณเข้ากับคีย์สาธารณะของบุคคลอื่นเพื่อหาค่า แล้วไงล่ะ หากอีกฝ่ายใช้คีย์ส่วนตัวของตนเองและคีย์สาธารณะของคุณ ก็จะได้ค่าเดียวกันทุกประการ
หลักการนี้เป็นพื้นฐานของโปรโตคอลการเข้ารหัสคีย์ Diffie-Hellman (ECDH) รูปไข่ ซึ่งช่วยให้ทั้ง 2 ฝ่ายมีความลับที่แชร์เดียวกันแม้ว่าจะแลกเปลี่ยนเฉพาะคีย์สาธารณะเท่านั้น เราจะใช้ข้อมูลลับที่ใช้ร่วมกันนี้เป็นพื้นฐานสำหรับคีย์การเข้ารหัสจริง
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 ครั้ง ตราบใดที่แฮชที่สร้างขึ้นโดยอัลกอริทึมการแฮชใดก็ตามที่คุณใช้ สำหรับ Push นั้น ข้อกำหนดกำหนดให้เราต้องใช้สัดส่วน SHA-256 ซึ่งมีความยาวแฮช 32 ไบต์ (256 บิต)
เราทราบดีว่าต้องสร้างคีย์ที่มีขนาดไม่เกิน 32 ไบต์ ซึ่งหมายความว่าเราสามารถใช้อัลกอริทึมเวอร์ชันที่เรียบง่ายซึ่งจัดการกับเอาต์พุตขนาดใหญ่ไม่ได้
เราได้ใส่โค้ดสำหรับเวอร์ชัน Node ไว้ด้านล่างแล้ว แต่คุณสามารถดูวิธีการทํางานจริงได้ใน RFC 5869
อินพุตของ HKDF คือเกลือ ข้อมูลการเข้ารหัสเริ่มต้น (ikm) บางส่วน ข้อมูลที่มีโครงสร้างซึ่งไม่บังคับสำหรับ Use Case ปัจจุบัน (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 เช่นกัน แม้ว่าผู้เชี่ยวชาญด้านวิทยาการเข้ารหัสอาจสังเกตได้ว่านี่ไม่ใช่ PRK จริงๆ
ตอนนี้เราจะสร้างคีย์การเข้ารหัสเนื้อหาสุดท้ายและข้อมูลที่ไม่ซ้ำกันที่จะส่งไปยังการเข้ารหัส ข้อมูลเหล่านี้สร้างขึ้นโดยการสร้างโครงสร้างข้อมูลที่เรียบง่ายสำหรับแต่ละรายการ ซึ่งอ้างอิงในข้อกำหนดว่าเป็น "ข้อมูล" ที่มีข้อมูลเฉพาะสำหรับรูปไข่ ผู้ส่ง และผู้ที่รับข้อมูลเพื่อยืนยันแหล่งที่มาของข้อความเพิ่มเติม จากนั้นเราจะใช้ HKDF กับ PRK, Salt และข้อมูลเพื่อดึงข้อมูลคีย์และ 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);
Padding
อีกเรื่องหนึ่งและถึงเวลาสําหรับตัวอย่างที่ไร้สาระและจงใจ สมมติว่าหัวหน้าของคุณมีเซิร์ฟเวอร์ที่ส่งข้อความ Push พร้อมราคาหุ้นของบริษัทให้เธอทุก 2-3 นาที ข้อความธรรมดาสำหรับการดำเนินการนี้จะเป็นตัวเลขที่สมบูรณ์ 32 บิตที่มีค่าเป็นสิบเซ็นเสมอ นอกจากนี้ เธอยังมีข้อตกลงลับๆ กับเจ้าหน้าที่จัดเลี้ยงด้วย ซึ่งหมายความว่าเจ้าหน้าที่สามารถส่งข้อความ "โดนัทในห้องพักผ่อน" มาให้เธอ 5 นาทีก่อนที่โดนัทจะมาถึงจริง เพื่อให้เธอ "บังเอิญ" อยู่ที่นั่นเมื่อโดนัทมาถึงและเลือกโดนัทที่ดีที่สุดได้
การเข้ารหัสที่ใช้โดย Web Push จะสร้างค่าที่เข้ารหัสซึ่งมีความยาวมากกว่าอินพุตที่ไม่ได้เข้ารหัส 16 ไบต์ เนื่องจาก "โดนัทในห้องพักผ่อน" มีความยาวมากกว่าราคาหุ้น 32 บิต พนักงานที่แอบดูจะทราบได้ว่าโดนัทจะมาถึงเมื่อใดโดยไม่ต้องถอดรหัสข้อความ เพียงแค่ดูจากความยาวของข้อมูล
ด้วยเหตุนี้ โปรโตคอล Web Push จึงอนุญาตให้คุณเพิ่มการถ่วงหน้าไว้ที่ตอนต้นของข้อมูล วิธีใช้ขึ้นอยู่กับแอปพลิเคชันของคุณ แต่ในตัวอย่างข้างต้น คุณสามารถเพิ่มค่าให้กับข้อความทั้งหมดให้มีขนาด 32 ไบต์พอดี ซึ่งทำให้แยกแยะข้อความตามความยาวเพียงอย่างเดียวไม่ได้
ค่าการเติมคือจำนวนเต็มแบบ Big Endian 16 บิตที่ระบุความยาวการเติม followed by that number of NUL
bytes of padding. ดังนั้นการเติมค่าขั้นต่ำคือ 2 บิต - ตัวเลข 0 ที่เข้ารหัสเป็น 16 บิต
const padding = new Buffer(2 + paddingLength);
// The buffer must be only zeroes, except the length
padding.fill(0);
padding.writeUInt16BE(paddingLength, 0);
เมื่อข้อความ Push มาถึงไคลเอ็นต์ เบราว์เซอร์จะตัดการถอดช่องว่างโดยอัตโนมัติเพื่อให้โค้ดไคลเอ็นต์ได้รับเฉพาะข้อความที่ไม่มีการตัดช่องว่าง
การเข้ารหัส
ตอนนี้เรามีทุกอย่างสําหรับการเข้ารหัสแล้ว การเข้ารหัสที่จําเป็นสําหรับ 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()]);
ข้อความ Push จากเว็บ
ในที่สุด เมื่อคุณมีพายโหลดที่เข้ารหัสแล้ว คุณก็เพียงแค่ต้องส่งคําขอ HTTP POST ที่ค่อนข้างง่ายไปยังปลายทางที่การสมัครใช้บริการของผู้ใช้ระบุ
คุณต้องตั้งค่าส่วนหัว 3 รายการ
Encryption: salt=<SALT>
Crypto-Key: dh=<PUBLICKEY>
Content-Encoding: aesgcm
<SALT>
และ <PUBLICKEY>
คือ Salt และคีย์สาธารณะของเซิร์ฟเวอร์ที่ใช้ในการเข้ารหัส ซึ่งเข้ารหัสเป็น Base64 ที่ปลอดภัยสำหรับ URL
เมื่อใช้โปรโตคอล Web Push เนื้อหาของ POST จะเป็นไบต์ดิบของข้อความที่เข้ารหัส อย่างไรก็ตาม คุณสามารถใส่ข้อมูลไว้ในเพย์โหลด JSON ที่มีอยู่ได้โดยง่าย ดังนี้ ในระหว่างที่ Chrome และ Firebase Cloud Messaging ยังไม่รองรับโปรโตคอลนี้
{
"registration_ids": [ "…" ],
"raw_data": "BIXzEKOFquzVlr/1tS1bhmobZ…"
}
ค่าของพร็อพเพอร์ตี้ rawData
ต้องเป็นข้อความที่เข้ารหัสฐาน 64
การแก้ไขข้อบกพร่อง / โปรแกรมตรวจสอบ
Peter Beverloo เป็นหนึ่งในวิศวกรของ Chrome ที่นำฟีเจอร์นี้มาใช้ (และเป็นหนึ่งในผู้เขียนข้อกำหนด) ได้สร้างโปรแกรมตรวจสอบ
เมื่อทำให้โค้ดแสดงผลค่ากลางแต่ละค่าของการเข้ารหัส คุณสามารถวางค่าเหล่านั้นลงในโปรแกรมตรวจสอบเพื่อตรวจสอบว่าคุณดำเนินการถูกต้อง