การเข้ารหัสเพย์โหลด Web Push

เครื่องชั่งน้ำหนักเสื่อ

ก่อนที่จะมี Chrome 50 ข้อความ Push ต้องไม่มีข้อมูลเพย์โหลด เมื่อเหตุการณ์ "push" เริ่มทำงานใน Service Worker ของคุณ คุณแค่รู้แค่ว่าเซิร์ฟเวอร์พยายามบอกอะไรคุณ แต่ไม่ได้บอกอะไรเลย จากนั้นคุณต้องส่งคำขอติดตามผลไปยังเซิร์ฟเวอร์และรับรายละเอียดของการแจ้งเตือนเพื่อแสดง ซึ่งอาจมีกรณีที่เครือข่ายมีปัญหา

ใน Chrome 50 (และใน Firefox เวอร์ชันปัจจุบันบนเดสก์ท็อป) คุณสามารถส่งข้อมูลที่กำหนดเองไปพร้อมกับพุชเพื่อให้ลูกค้าไม่ต้องส่งคำขอเพิ่มเติม อย่างไรก็ตาม พลังที่ยิ่งใหญ่มาพร้อมกับความรับผิดชอบที่ใหญ่ยิ่ง ดังนั้น ข้อมูลเปย์โหลดทั้งหมดจึงต้องมีการเข้ารหัส

การเข้ารหัสเพย์โหลดเป็นส่วนสำคัญของเรื่องราวด้านความปลอดภัยสำหรับพุชในเว็บ HTTPS ให้ความปลอดภัยแก่คุณเมื่อสื่อสารกันระหว่างเบราว์เซอร์และเซิร์ฟเวอร์ของคุณเอง เนื่องจากคุณเชื่อถือเซิร์ฟเวอร์ แต่เบราว์เซอร์จะเลือกว่าจะใช้ผู้ให้บริการพุชใดเพื่อส่งเพย์โหลดจริง คุณในฐานะนักพัฒนาแอปจะไม่สามารถควบคุมได้

ซึ่ง HTTPS รับประกันได้แค่ว่าไม่มีใครสามารถสอดแนมข้อความระหว่างการส่งไปยังผู้ให้บริการพุชได้ เมื่อได้รับแล้ว พวกเขาจะมีอิสระทำในสิ่งที่ชอบ เช่น การส่งเปย์โหลดไปยังบุคคลที่สามอีกครั้ง หรือเปลี่ยนแปลงอย่างไม่ประสงค์ดี ในการป้องกันปัญหานี้ เราใช้การเข้ารหัสเพื่อให้แน่ใจว่าบริการพุชจะไม่อ่านหรือแทรกแซงเพย์โหลดที่อยู่ระหว่างการส่ง

การเปลี่ยนแปลงฝั่งไคลเอ็นต์

หากคุณใช้ข้อความ 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 ที่ฉันเรียกว่า 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 โดยเราจะเพิ่มภาษาและแพลตฟอร์มอื่นๆ ในเร็วๆ นี้ วิธีนี้จะจัดการทั้งการเข้ารหัสและโปรโตคอลการพุชจากเว็บ เพื่อให้การส่งข้อความพุชจากเซิร์ฟเวอร์ Node.js ทำได้ง่ายเหมือน webpush.sendWebPush(message, subscription)

แม้ว่าเราจะแนะนำให้ใช้ห้องสมุด แต่นี่เป็นฟีเจอร์ใหม่และมีภาษายอดนิยมมากมายที่ยังไม่มีห้องสมุด หากคุณจำเป็นต้อง ติดตั้งใช้งานด้วยตนเอง โปรดดูรายละเอียดดังนี้

ผมจะสาธิตอัลกอริทึมโดยใช้ JavaScript ที่แต่งเติมโหนด แต่หลักการพื้นฐานควรจะเหมือนกันในทุกภาษา

อินพุต

ในการเข้ารหัสข้อความ ขั้นแรกเราต้องรับข้อมูล 2 อย่างจากออบเจ็กต์การสมัครใช้บริการที่ได้รับจากไคลเอ็นต์ หากคุณใช้ JSON.stringify() บนไคลเอ็นต์และส่งไปยังเซิร์ฟเวอร์ของคุณ ระบบจะจัดเก็บคีย์สาธารณะของไคลเอ็นต์ไว้ในช่อง keys.p256dh ในขณะที่ข้อมูลลับในการตรวจสอบสิทธิ์ที่แชร์จะอยู่ในช่อง keys.auth ทั้ง 2 รายการนี้จะมีการเข้ารหัสแบบ Base64 ที่ปลอดภัย ตามที่กล่าวไว้ข้างต้น รูปแบบไบนารีของคีย์สาธารณะไคลเอ็นต์คือจุดเส้นโค้ง 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

เรามาพูดถึงคุณสมบัติที่เป็นระเบียบของวิทยาเส้นโค้ง แบบ Elliptic Curve กันก่อนดีกว่า มีกระบวนการที่ค่อนข้างง่ายซึ่งรวมคีย์ส่วนตัวของคุณกับคีย์สาธารณะของผู้อื่นเพื่อรับค่า แล้วยังไงต่อ ถ้าอีกฝ่ายใช้คีย์ส่วนตัวของตนและคีย์สาธารณะของคุณ ก็จะได้ค่าเดียวกันทุกประการ

ข้อมูลนี้เป็นพื้นฐานของโปรโตคอลข้อตกลงหลัก 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 เท่า เทียบเท่ากับแฮชที่ผลิตโดยอัลกอริทึมการแฮชใดก็ตามที่คุณใช้ สำหรับพุช ข้อกำหนดกำหนดให้เราใช้ 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 เพื่อผสมข้อมูลลับในการตรวจสอบสิทธิ์ไคลเอ็นต์และข้อมูลลับที่ใช้ร่วมกันให้เป็นข้อมูลลับที่ยาวกว่าและมีการเข้ารหัสลับมากกว่า ในข้อมูลจำเพาะในที่นี้ เราเรียกคีย์นี้ว่า Pseudo-Random Key (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);

ระยะห่างจากขอบ

อีกหัวข้อหนึ่งและถึงเวลายกตัวอย่างขี้เล่นๆ สมมติว่าเจ้านายของคุณมีเซิร์ฟเวอร์ที่ส่งข้อความ Push ถึงเธอทุก 2-3 นาทีพร้อมราคาหุ้นของบริษัท ข้อความธรรมดาสำหรับข้อความนี้จะเป็นจำนวนเต็ม 32 บิต ที่มีค่าเป็นเซ็นต์เสมอ เธอยังมีดีลลับกับพนักงานจัดเลี้ยง ซึ่งหมายความว่าพวกเขาจะส่งสตริง "โดนัทในห้องพัก" ให้เธอ 5 นาทีก่อนที่พนักงานจะส่งของจริง เพื่อที่จะได้ "โดยบังเอิญ" พวกเขามาถึงร้านและหยิบชิ้นที่ดีที่สุด

การเข้ารหัสที่ Web Push ใช้จะสร้างค่าที่เข้ารหัสซึ่งยาวกว่าอินพุตที่ไม่ได้เข้ารหัส 16 ไบต์พอดี เนื่องจาก "โดนัทในห้องพัก" จะนานกว่าราคาหุ้น 32 บิต พนักงานที่สอดแนมจึงรู้ได้ว่าโดนัทจะมาถึงเมื่อใดโดยไม่ต้องถอดรหัสข้อความ เพียงแต่ดูจากความยาวของข้อมูล

ด้วยเหตุนี้ โปรโตคอลพุชในเว็บจึงให้คุณเพิ่มระยะห่างจากขอบในตอนต้นของข้อมูลได้ วิธีที่คุณจะใช้ข้อมูลนี้ขึ้นอยู่กับแอปพลิเคชันของคุณ แต่ในตัวอย่างข้างต้น คุณสามารถจัดข้อความทั้งหมดให้มี 32 ไบต์พอดี ทำให้ไม่สามารถแยกความแตกต่างของข้อความโดยพิจารณาจากความยาวได้

ค่าระยะห่างจากขอบคือจำนวนเต็มขนาดใหญ่ 16 บิตซึ่งระบุความยาวของระยะห่างจากขอบตามด้วยจำนวนช่องว่างขนาด NUL ไบต์ดังกล่าว ดังนั้นระยะห่างจากขอบขั้นต่ำคือ 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);

เมื่อข้อความพุชมาถึงไคลเอ็นต์ เบราว์เซอร์จะสามารถลบระยะห่างจากขอบออกโดยอัตโนมัติเพื่อให้โค้ดไคลเอ็นต์ได้รับเฉพาะข้อความที่ไม่มีอักขระเสริม

การเข้ารหัส

ตอนนี้เราก็มีทุกอย่างสำหรับการเข้ารหัสแล้ว การเข้ารหัสที่จำเป็นสำหรับ Web Push คือ AES128 โดยใช้ GCM เราใช้คีย์การเข้ารหัสเนื้อหาเป็นคีย์และ Nonce เป็นเวกเตอร์การเริ่มต้น (IV)

ในตัวอย่างนี้ ข้อมูลของเราเป็นสตริง แต่อาจเป็นข้อมูลไบนารีก็ได้ คุณสามารถส่งเปย์โหลดได้สูงสุด 4,078 ไบต์ หรือสูงสุด 4,096 ไบต์ต่อโพสต์ โดยมี 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 ต้องเป็นค่าแทนที่เข้ารหัสแบบ Base64 ของข้อความที่เข้ารหัส

การแก้ไขข้อบกพร่อง / เครื่องมือยืนยัน

Peter Beverloo หนึ่งในวิศวกรของ Chrome ที่นำฟีเจอร์นี้มาใช้ (และเป็นหนึ่งในผู้ที่ทำงานเกี่ยวกับข้อกำหนดดังกล่าว) ได้สร้างผู้ตรวจสอบขึ้นมา

การรับโค้ดเพื่อแสดงค่ากลางของการเข้ารหัสแต่ละค่าจะทำให้คุณวางค่าเหล่านั้นในเครื่องมือยืนยันและตรวจสอบว่าคุณมาถูกทางแล้ว