Mã hoá tải trọng dữ liệu đẩy web

Cân thảm

Trước Chrome 50, thông báo đẩy không được chứa bất kỳ dữ liệu tải trọng nào. Khi sự kiện "push" được kích hoạt trong trình chạy dịch vụ, tất cả những gì bạn biết là máy chủ đang cố gắng thông báo cho bạn điều gì đó, nhưng không phải nội dung đó là gì. Sau đó, bạn phải gửi yêu cầu tiếp theo đến máy chủ và lấy thông tin chi tiết về thông báo cần hiển thị. Việc này có thể không thành công trong điều kiện mạng kém.

Giờ đây, trong Chrome 50 (và trong phiên bản Firefox hiện tại trên máy tính), bạn có thể gửi một số dữ liệu tuỳ ý cùng với thao tác đẩy để ứng dụng có thể tránh gửi thêm yêu cầu. Tuy nhiên, sức mạnh đi kèm với trách nhiệm lớn lao, do đó, mọi dữ liệu tải trọng đều phải được mã hoá.

Mã hoá tải trọng là một phần quan trọng trong câu chuyện bảo mật của công nghệ đẩy dữ liệu web. HTTPS cung cấp cho bạn sự bảo mật khi giao tiếp giữa trình duyệt và máy chủ của riêng bạn vì bạn tin tưởng máy chủ. Tuy nhiên, trình duyệt chọn nhà cung cấp đẩy sẽ được dùng để thực sự phân phối tải trọng, vì vậy, với tư cách là nhà phát triển ứng dụng, bạn không có quyền kiểm soát đối với tải trọng đó.

Ở đây, HTTPS chỉ có thể đảm bảo rằng không ai có thể xem trộm thông báo trong quá trình truyền đến nhà cung cấp dịch vụ đẩy. Sau khi nhận được, họ có thể thoải mái làm điều mình muốn, bao gồm cả việc truyền lại trọng tải cho bên thứ ba hoặc thay đổi nội dung theo cách có ý đồ xấu. Để tránh điều này, chúng tôi sử dụng tính năng mã hoá nhằm đảm bảo các dịch vụ đẩy không thể đọc hoặc can thiệp vào các tải trọng trong quá trình truyền.

Thay đổi phía máy khách

Nếu đã triển khai thông báo đẩy mà không có tải trọng, thì bạn chỉ cần thực hiện 2 thay đổi nhỏ ở phía máy khách.

Thứ nhất là khi gửi thông tin gói thuê bao đến máy chủ phụ trợ, bạn cần thu thập thêm một số thông tin. Nếu đã sử dụng JSON.stringify() trên đối tượng PushSubscription để chuyển đổi tuần tự nhằm gửi đến máy chủ, thì bạn không cần thay đổi gì thêm. Giờ đây, gói thuê bao sẽ có thêm một số dữ liệu trong thuộc tính khoá.

> JSON.stringify(subscription)
{"endpoint":"https://android.googleapis.com/gcm/send/f1LsxkKphfQ:APA91bFUx7ja4BK4JVrNgVjpg1cs9lGSGI6IMNL4mQ3Xe6mDGxvt_C_gItKYJI9CAx5i_Ss6cmDxdWZoLyhS2RJhkcv7LeE6hkiOsK6oBzbyifvKCdUYU7ADIRBiYNxIVpLIYeZ8kq_A",
"keys":{"p256dh":"BLc4xRzKlKORKWlbdgFaBrrPK3ydWAHo4M0gs0i1oEKgPpWC5cW8OCzVrOQRv-1npXRWk8udnW3oYhIO4475rds=",
"auth":"5I2Bu2oKdyy9CwL8QVF0NQ=="}}

Hai giá trị p256dhauth được mã hoá trong một biến thể của Base64 mà tôi sẽ gọi là Base64 an toàn cho URL.

Thay vào đó, nếu muốn chính xác các byte, bạn có thể sử dụng phương thức getKey() mới trên gói thuê bao trả về một tham số dưới dạng ArrayBuffer. Bạn cần có hai tham số authp256dh.

> new Uint8Array(subscription.getKey('auth'));
[228, 141, 129, ...] (16 bytes)

> new Uint8Array(subscription.getKey('p256dh'));
[4, 183, 56, ...] (65 bytes)

Thay đổi thứ hai là thuộc tính data mới khi sự kiện push kích hoạt. Phương thức này có nhiều phương thức đồng bộ để phân tích cú pháp dữ liệu đã nhận, chẳng hạn như .text(), .json(), .arrayBuffer().blob().

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

Thay đổi phía máy chủ

Về phía máy chủ, mọi thứ sẽ thay đổi nhiều hơn một chút. Quy trình cơ bản là bạn sử dụng thông tin khoá mã hoá nhận được từ ứng dụng để mã hoá tải trọng, sau đó gửi dưới dạng phần nội dung của yêu cầu POST tới điểm cuối trong gói thuê bao, đồng thời bổ sung một số tiêu đề HTTP bổ sung.

Các chi tiết tương đối phức tạp, và như với mọi thứ liên quan đến mã hoá, bạn nên sử dụng một thư viện được phát triển chủ động thay vì triển khai thư viện của riêng mình. Nhóm Chrome đã phát hành một thư viện cho Node.js, và sẽ sớm hỗ trợ thêm nhiều ngôn ngữ và nền tảng khác. Phương thức này xử lý cả quá trình mã hoá và giao thức đẩy web, nhờ đó, việc gửi thông báo đẩy từ máy chủ Node.js cũng dễ dàng như webpush.sendWebPush(message, subscription).

Mặc dù bạn nên sử dụng thư viện, nhưng đây là một tính năng mới và vẫn còn nhiều ngôn ngữ phổ biến chưa có thư viện nào. Nếu bạn cần tự mình triển khai việc này, sau đây là thông tin chi tiết.

Tôi sẽ minh hoạ các thuật toán bằng JavaScript phiên bản Nút, nhưng các nguyên tắc cơ bản phải giống nhau trong mọi ngôn ngữ.

Thông tin đầu vào

Để mã hoá một thông báo, trước tiên, chúng ta cần lấy hai thứ từ đối tượng gói thuê bao mà chúng ta nhận được từ ứng dụng. Nếu bạn đã sử dụng JSON.stringify() trên ứng dụng và truyền mã đó đến máy chủ, thì khoá công khai của ứng dụng sẽ được lưu trữ trong trường keys.p256dh, còn khoá bí mật xác thực dùng chung sẽ nằm trong trường keys.auth. Cả hai đều được mã hoá Base64 an toàn với URL như đã đề cập ở trên. Định dạng nhị phân của khoá công khai ứng dụng là một điểm đường cong elip P-256 không nén.

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

Khoá công khai cho phép chúng ta mã hoá thông báo để chỉ có thể giải mã thông báo bằng khoá riêng tư của ứng dụng.

Khoá công khai thường được coi là công khai, vì vậy, để cho phép ứng dụng xác thực rằng thông báo do một máy chủ đáng tin cậy gửi, chúng tôi cũng sử dụng khoá bí mật xác thực. Không có gì ngạc nhiên khi mật khẩu này phải được giữ bí mật, chỉ chia sẻ với máy chủ ứng dụng mà bạn muốn gửi thư cho bạn và được xử lý như mật khẩu.

Chúng ta cũng cần tạo một số dữ liệu mới. Chúng tôi cần một muối ngẫu nhiên bảo mật bằng mật mã có kích thước 16 byte và một cặp khoá đường cong elip công khai/riêng tư. Đường cong cụ thể được sử dụng trong thông số kỹ thuật mã hoá đẩy được gọi là P-256, hay prime256v1. Để bảo mật tốt nhất, cặp khoá phải được tạo từ đầu mỗi khi bạn mã hoá thư và bạn không bao giờ được sử dụng lại dữ liệu ngẫu nhiên.

ECDH

Hãy dành một chút thời gian để nói về một thuộc tính gọn gàng của mật mã đường cong elip. Có một quy trình tương đối đơn giản, kết hợp khoá riêng tư của bạn với khoá công khai của người khác để tạo ra giá trị. Vậy thì sao? Nếu bên kia lấy khoá riêng tư của họ và khoá công khai của bạn, thì họ sẽ nhận được cùng một giá trị!

Đây là cơ sở của giao thức thoả thuận khoá Diffie-Hellman (ECDH) trên đường cong elip, cho phép cả hai bên có cùng một khoá bí mật dùng chung mặc dù họ chỉ trao đổi khoá công khai. Chúng tôi sẽ sử dụng mã bí mật dùng chung này làm cơ sở cho khoá mã hoá thực tế của mình.

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

200

Đã đến lúc dành cho chuyện khác rồi. Giả sử bạn có một số dữ liệu bí mật muốn sử dụng làm khoá mã hoá, nhưng dữ liệu này không đủ an toàn về mặt mã hoá. Bạn có thể sử dụng Hàm dẫn xuất khoá (HKDF) dựa trên HMAC để chuyển khoá bí mật có mức độ bảo mật thấp thành khoá bí mật có độ bảo mật cao.

Một hệ quả của cách hoạt động của hàm này là cho phép bạn lấy bí mật bất kỳ số bit và tạo một bí mật khác có kích thước bất kỳ lên đến 255 lần, miễn là hàm băm được tạo bằng bất kỳ thuật toán băm nào mà bạn sử dụng. Đối với công nghệ đẩy, thông số kỹ thuật yêu cầu chúng ta sử dụng SHA-256, có độ dài hàm băm là 32 byte (256 bit).

Khi điều này xảy ra, chúng ta biết rằng chỉ cần tạo khoá có kích thước tối đa 32 byte. Điều này có nghĩa là chúng ta có thể sử dụng phiên bản đơn giản của thuật toán để không xử lý các kích thước đầu ra lớn hơn.

Tôi đã bao gồm mã cho phiên bản Nút bên dưới, nhưng bạn có thể tìm hiểu cách hoạt động của phiên bản đó trong RFC 5869.

Dữ liệu đầu vào cho HKDF là dữ liệu ngẫu nhiên, một số tài liệu khoá ban đầu (ikm), một phần dữ liệu có cấu trúc không bắt buộc dành riêng cho trường hợp sử dụng hiện tại (thông tin) và độ dài tính bằng byte của khoá đầu ra mong muốn.

// 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);
}

Lấy thông số mã hoá

Giờ đây, chúng tôi sử dụng HKDF để chuyển dữ liệu có được thành các tham số cho quá trình mã hoá thực tế.

Điều đầu tiên chúng ta cần làm là sử dụng HKDF để kết hợp khoá bí mật xác thực ứng dụng khách và khoá bí mật dùng chung thành một khoá bí mật dài hơn, được bảo mật bằng mật mã hơn. Về thông số kỹ thuật, đây được gọi là Khoá ngẫu nhiên (PRK) nên tôi sẽ gọi nó ở đây, mặc dù những người thuần tuý mật mã học có thể lưu ý rằng đây hoàn toàn không phải là PRK.

Bây giờ, chúng ta tạo khoá mã hoá nội dung cuối cùng và một số chỉ dùng một lần sẽ được truyền cho thuật toán mật mã. Các lớp này được tạo bằng cách tạo một cấu trúc dữ liệu đơn giản cho mỗi luồng (được tham chiếu trong thông số kỹ thuật) dưới dạng thông tin chứa thông tin dành riêng cho đường cong elip, người gửi và người nhận thông tin để xác minh thêm nguồn của thông báo. Sau đó, chúng ta sử dụng HKDF với PRK, dữ liệu ngẫu nhiên và thông tin để lấy khoá và số chỉ dùng một lần có kích thước chính xác.

Loại thông tin để mã hoá nội dung là "aesgcm". Đây là tên của thuật toán mật mã dùng để mã hoá đẩy.

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

Khoảng đệm

Một ví dụ khác và hãy dành thời gian cho một ví dụ ngớ ngẩn và đầy minh hoạ. Giả sử sếp của bạn có một máy chủ gửi cho cô ấy một thông báo đẩy vài phút một lần kèm theo giá cổ phiếu của công ty. Thông báo rõ ràng sẽ luôn là một số nguyên 32 bit có giá trị tính bằng xu. Cô cũng có một giao dịch lén lút với nhân viên phục vụ ăn uống, tức là họ có thể gửi cho cô chuỗi "bánh vòng trong phòng nghỉ" 5 phút trước khi thực sự giao đến để cô "tình cờ có mặt" khi họ đến và lấy món ngon nhất.

Thuật toán mật mã mà phương thức đẩy dữ liệu web sử dụng sẽ tạo các giá trị được mã hoá dài hơn đúng 16 byte so với giá trị đầu vào chưa mã hoá. Vì "bánh rán trong phòng nghỉ" dài hơn giá cổ phiếu 32 bit, nên bất kỳ nhân viên rình mò nào cũng có thể biết khi nào bánh vòng đến mà không cần giải mã tin nhắn, chỉ từ độ dài của dữ liệu.

Vì lý do này, giao thức đẩy web cho phép bạn thêm khoảng đệm vào đầu dữ liệu. Cách bạn sử dụng việc này tuỳ thuộc vào ứng dụng của bạn, nhưng trong ví dụ trên, bạn có thể chèn chính xác tất cả các thông điệp thành 32 byte, khiến không thể phân biệt các thông điệp chỉ dựa trên độ dài.

Giá trị khoảng đệm là một số nguyên lớn-endian 16 bit chỉ định độ dài khoảng đệm, theo sau là số lượng khoảng đệm NUL đó. Vì vậy, khoảng đệm tối thiểu là 2 byte – số 0 được mã hoá thành 16 bit.

const padding = new Buffer(2 + paddingLength);
// The buffer must be only zeroes, except the length
padding.fill(0);
padding.writeUInt16BE(paddingLength, 0);

Khi thông báo đẩy đến ứng dụng khách, trình duyệt sẽ có thể tự động loại bỏ mọi khoảng đệm, vì vậy, mã ứng dụng khách của bạn chỉ nhận được thông báo chưa được thêm vào.

Mã hoá

Giờ đây, cuối cùng chúng ta đã có tất cả mọi thứ để mã hoá. Thuật toán mật mã bắt buộc cho chế độ Gửi dữ liệu trên web là AES128 dùng GCM. Chúng tôi dùng khoá mã hoá nội dung làm khoá và số chỉ dùng một lần làm vectơ khởi tạo (IV).

Trong ví dụ này, dữ liệu của chúng ta là một chuỗi nhưng cũng có thể là bất kỳ dữ liệu nhị phân nào. Bạn có thể gửi các gói dữ liệu có kích thước lên tới 4078 byte – tối đa 4096 byte cho mỗi bài đăng, với 16 byte cho thông tin mã hoá và ít nhất 2 byte cho khoảng đệm.

// 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()]);

Đẩy nội dung trên web

Chà! Giờ đây, khi đã có tải trọng đã mã hoá, bạn chỉ cần thực hiện một yêu cầu POST HTTP tương đối đơn giản đến điểm cuối do gói thuê bao của người dùng chỉ định.

Bạn cần đặt ba tiêu đề.

Encryption: salt=<SALT>
Crypto-Key: dh=<PUBLICKEY>
Content-Encoding: aesgcm

<SALT><PUBLICKEY> là dữ liệu ngẫu nhiên và khoá công khai của máy chủ được dùng trong quá trình mã hoá, được mã hoá dưới dạng Base64 an toàn với URL.

Khi sử dụng giao thức Đẩy web, phần nội dung của yêu cầu POST khi đó chỉ là byte thô của thông báo đã mã hoá. Tuy nhiên, cho đến khi Dịch vụ gửi thông báo qua đám mây của Chrome và Firebase hỗ trợ giao thức, bạn có thể dễ dàng đưa dữ liệu vào tải trọng JSON hiện tại như sau.

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

Giá trị của thuộc tính rawData phải là giá trị đại diện được mã hoá base64 của thông báo đã mã hoá.

Gỡ lỗi / trình xác minh

Peter Beverloo, một trong các kỹ sư Chrome đã triển khai tính năng này (đồng thời là một trong những người phụ trách phần quy cách), đã tạo một trình xác minh.

Bằng cách lấy mã để xuất từng giá trị trung gian của quá trình mã hoá, bạn có thể dán các giá trị đó vào trình xác minh và kiểm tra xem bạn có đang đi đúng hướng hay không.