ก่อน Chrome 50 ข้อความ Push จะไม่มีข้อมูลเพย์โหลด เมื่อ "push" เหตุการณ์เริ่มทำงานใน Service Worker ของคุณ คุณก็รู้แค่ว่าเซิร์ฟเวอร์กำลังพยายามบอกคุณ แต่ไม่รู้ว่าอาจเป็นอะไร จากนั้นคุณต้องส่งคำขอติดตามผลไปยังเซิร์ฟเวอร์และรับรายละเอียดของการแจ้งเตือนที่จะแสดง ซึ่งอาจล้มเหลวในสภาพเครือข่ายที่สัญญาณไม่ดี
ใน Chrome 50 (และใน Firefox เวอร์ชันปัจจุบันบนเดสก์ท็อป) คุณสามารถส่งข้อมูลที่กำหนดเองบางส่วนไปพร้อมกับ Push เพื่อให้ไคลเอ็นต์หลีกเลี่ยงการส่งคำขอเพิ่มเติมได้ อย่างไรก็ตาม พลังที่ยิ่งใหญ่มาพร้อมกับความรับผิดชอบอันใหญ่ยิ่ง ดังนั้นข้อมูลเพย์โหลดทั้งหมดจึงต้องได้รับการเข้ารหัส
การเข้ารหัสของเพย์โหลดเป็นส่วนสําคัญของการรักษาความปลอดภัยสําหรับ Web Push HTTPS ช่วยให้การสื่อสารระหว่างเบราว์เซอร์กับเซิร์ฟเวอร์ของคุณเองปลอดภัย เนื่องจากคุณไว้ใจเซิร์ฟเวอร์ อย่างไรก็ตาม เบราว์เซอร์จะเลือกผู้ให้บริการพุชที่จะใช้เพื่อส่งเพย์โหลดจริงๆ ดังนั้นคุณในฐานะนักพัฒนาแอปจึงไม่สามารถควบคุมได้
ในกรณีนี้ HTTPS จะรับประกันได้เพียงว่าไม่มีใครสามารถสอดแนมข้อความระหว่างการส่งไปยังผู้ให้บริการ Push เมื่อได้รับแล้ว บุคคลที่สามจะทำสิ่งใดก็ได้ตามต้องการ ซึ่งรวมถึงการส่งต่อเพย์โหลดไปยังบุคคลที่สามอีกครั้งหรือดัดแปลงเป็นอย่างอื่นโดยมีเจตนาร้าย เราป้องกันปัญหานี้โดยใช้การเข้ารหัสเพื่อให้แน่ใจว่าบริการ Push จะอ่านหรือดัดแปลงเพย์โหลดระหว่างการส่งไม่ได้
การเปลี่ยนแปลงฝั่งไคลเอ็นต์
หากใช้ข้อความ Push โดยไม่มีเพย์โหลดอยู่แล้ว คุณจะต้องทำการเปลี่ยนแปลงเล็กน้อยเพียง 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 ที่ฉันจะเรียกว่า URL-Safe Base64
หากคุณต้องการดูไบต์เฉพาะแทน คุณสามารถใช้เมธอด 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');
คีย์สาธารณะช่วยให้เราเข้ารหัสข้อความให้ถอดรหัสได้โดยใช้คีย์ส่วนตัวของไคลเอ็นต์เท่านั้น
โดยปกติแล้วคีย์สาธารณะจะถือว่าเป็นแบบสาธารณะ ดังนั้นเราจึงใช้รหัสลับการตรวจสอบสิทธิ์ด้วยเพื่อให้ไคลเอ็นต์ตรวจสอบสิทธิ์ว่าข้อความนั้นส่งมาจากเซิร์ฟเวอร์ที่เชื่อถือได้ ดังนั้นจึงไม่น่าแปลกใจที่ข้อความนี้ควรเก็บเป็นความลับ แชร์กับแอปพลิเคชันเซิร์ฟเวอร์ที่คุณต้องการส่งข้อความถึงเท่านั้น และจัดการเหมือนกับรหัสผ่าน
นอกจากนี้ เรายังต้องสร้างข้อมูลใหม่ด้วย เราต้องใช้ salt แบบสุ่มที่ปลอดภัยแบบเข้ารหัสลับ 16 ไบต์และคีย์ elliptic Curve สาธารณะ/ส่วนตัว เส้นโค้งเฉพาะที่ใช้โดยข้อกำหนดการเข้ารหัสแบบพุชเรียกว่า P-256 หรือ prime256v1
ควรสร้างคู่คีย์จาก Scratch ทุกครั้งที่คุณเข้ารหัสข้อความและไม่ควรใช้ Salt ซ้ำเพื่อรักษาความปลอดภัยที่ดีที่สุด
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
พอมีเวลาสำหรับตัวสำรองอีกแล้ว สมมติว่าคุณมีข้อมูลที่เป็นความลับซึ่งต้องการใช้เป็นคีย์การเข้ารหัส แต่ข้อมูลดังกล่าวไม่ปลอดภัยจากการเข้ารหัสมากพอ คุณใช้ฟังก์ชันการสร้างคีย์ที่ใช้ HMAC ได้ (HKDF) เพื่อเปลี่ยนข้อมูลลับที่มีระดับความปลอดภัยต่ำให้เป็นรหัสที่มีความปลอดภัยสูง
ผลที่ตามมาอย่างหนึ่งของวิธีการทํางานคือช่วยให้คุณนําข้อมูลลับที่มีจำนวนบิตเท่าใดก็ได้ไปสร้างข้อมูลลับอีกรายการที่มีขนาดเท่าใดก็ได้สูงสุด 255 ครั้ง ตราบใดที่แฮชที่สร้างขึ้นโดยอัลกอริทึมการแฮชใดก็ตามที่คุณใช้ สำหรับ Push นั้น ข้อกำหนดกำหนดให้เราต้องใช้สัดส่วน SHA-256 ซึ่งมีความยาวแฮช 32 ไบต์ (256 บิต)
สำหรับกรณีเช่นนี้ เราทราบว่าเราจำเป็นต้องสร้างคีย์ ที่มีขนาดไม่เกิน 32 ไบต์เท่านั้น ซึ่งหมายความว่าเราจะใช้อัลกอริทึมเวอร์ชันแบบง่าย ซึ่งจัดการเอาต์พุตขนาดใหญ่ไม่ได้
เราได้ใส่โค้ดสำหรับเวอร์ชันโหนดไว้ด้านล่าง แต่คุณดูวิธีการทำงานจริงได้ใน RFC 5869
อินพุตของ HKDF คือ Salt ซึ่งเป็นข้อมูลสำคัญบางส่วน (ikm) ซึ่งเป็นข้อมูลที่มีโครงสร้างที่ไม่บังคับเฉพาะสำหรับ Use Case (ข้อมูล) ปัจจุบันและความยาวเป็นไบต์ของคีย์เอาต์พุตที่ต้องการ
// 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" ซึ่งเป็นชื่อของการเข้ารหัสที่ใช้สําหรับการเข้ารหัส Push
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>
คือเกลือและคีย์สาธารณะของเซิร์ฟเวอร์ที่ใช้ในการเข้ารหัส ซึ่งเข้ารหัสเป็น Base64 ที่ปลอดภัยสำหรับ URL
เมื่อใช้โปรโตคอล Web Push เนื้อหาของ POST จะเป็นไบต์ดิบของข้อความที่เข้ารหัส อย่างไรก็ตาม คุณสามารถใส่ข้อมูลไว้ในเพย์โหลด JSON ที่มีอยู่ได้โดยง่าย ดังนี้ ในระหว่างที่ Chrome และ Firebase Cloud Messaging ยังไม่รองรับโปรโตคอลนี้
{
"registration_ids": [ "…" ],
"raw_data": "BIXzEKOFquzVlr/1tS1bhmobZ…"
}
ค่าของพร็อพเพอร์ตี้ rawData
ต้องเป็นการแสดงข้อความที่เข้ารหัสที่เข้ารหัส Base64
การแก้ไขข้อบกพร่อง / โปรแกรมตรวจสอบ
Peter Beverloo เป็นหนึ่งในวิศวกรของ Chrome ที่นำฟีเจอร์นี้มาใช้ (และเป็นหนึ่งในผู้เขียนข้อกำหนด) ได้สร้างโปรแกรมตรวจสอบ
การทำให้โค้ดแสดงค่ากลางของการเข้ารหัสแต่ละค่า คุณสามารถวางรหัสลงในตัวตรวจสอบ และตรวจสอบว่าคุณมาถูกทางแล้วหรือไม่