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 "đẩy" kích hoạt trong worker dịch vụ, tất cả những gì bạn biết là máy chủ đang cố gắng cho bạn biết điều gì đó, nhưng không biết đó là gì. Sau đó, bạn phải tạo một yêu cầu tiếp theo cho 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 thông báo đẩy để ứng dụng có thể tránh tạo yêu cầu bổ sung. Tuy nhiên, quyền lực càng lớn thì trách nhiệm càng cao, vì vậy, tất cả dữ liệu tải trọng phải được mã hoá.
Việc 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 tính năng đẩy web. HTTPS mang lại cho bạn sự an toàn 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 sẽ 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, bạn (với tư cách là nhà phát triển ứng dụng) không có quyền kiểm soát nhà cung cấp đó.
Ở đây, HTTPS chỉ có thể đảm bảo rằng không ai có thể xem trộm thư trong quá trình truyền đến nhà cung cấp dịch vụ đẩy. Sau khi nhận được, họ có thể làm bất cứ điều gì họ muốn, bao gồm cả việc truyền tải lại tải trọng cho bên thứ ba hoặc thay đổi tải trọng đó thành một nội dung khác một cách độc hại. Để ngăn chặn điều này, chúng tôi sử dụng phương thức mã hoá để đảm bảo rằng các dịch vụ đẩy không thể đọc hoặc can thiệp vào 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 không có tải trọng, thì bạn chỉ cần thực hiện hai thay đổi nhỏ ở phía máy khách.
Trước tiên, khi gửi thông tin 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ự đối tượng đó nhằm gửi đến máy chủ, thì bạn không cần thay đổi gì cả. 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ị p256dh
và auth
được mã hoá trong một biến thể của Base64 mà tôi sẽ gọi là Base64 an toàn với URL.
Nếu muốn nhận ngay 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
.
Hai tham số bạn cần là auth
và p256dh
.
> 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à một thuộc tính dữ liệu mới khi sự kiện push
kích hoạt. Lớp 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()
và .blob()
.
self.addEventListener('push', function(event) {
if (event.data) {
console.log(event.data.json());
}
});
Thay đổi phía máy chủ
Ở phía máy chủ, mọi thứ sẽ thay đổi một chút. Quy trình cơ bản là bạn sử dụng thông tin khoá mã hoá mà bạn nhận được từ ứng dụng để mã hoá tải trọng, sau đó gửi thông tin đó dưới dạng nội dung của yêu cầu POST đến điểm cuối trong gói thuê bao, thêm một số tiêu đề HTTP bổ sung.
Thông tin chi tiết tương đối phức tạp và như mọi thứ liên quan đến việc mã hoá, bạn nên sử dụng một thư viện được phát triển tích cực thay vì tự tạo. Nhóm Chrome đã phát hành một thư viện cho Node.js, sắp tới sẽ có thêm nhiều ngôn ngữ và nền tảng. Thao tá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à có nhiều ngôn ngữ phổ biến chưa có thư viện nào. Nếu bạn cần triển khai tính năng này cho chính mình, hãy xem thông tin chi tiết bên dưới.
Tôi sẽ minh hoạ các thuật toán bằng cách sử dụng JavaScript có hương vị Node, nhưng các nguyên tắc cơ bản sẽ 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ông tin từ đối tượng 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 khoá đó đến máy chủ, thì khoá công khai của ứng dụng sẽ được lưu trữ trong trường keys.p256dh
, trong khi khoá 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 cho URL như đã đề cập ở trên. Định dạng tệp nhị phân của khoá công khai của ứng dụng là một điểm trên đườ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ư sao cho chỉ có thể giải mã thư bằng khoá riêng tư của ứng dụng khách.
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 được gửi bởi một máy chủ đáng tin cậy, chúng ta cũng sử dụng khoá xác thực bí mật. Không có gì đáng ngạc nhiên khi bạn phải giữ bí mật mã này, chỉ chia sẻ với máy chủ ứng dụng mà bạn muốn gửi thông báo và coi mã này như mật khẩu.
Chúng ta cũng cần tạo một số dữ liệu mới. Chúng ta cần một muối ngẫu nhiên 16 byte được mã hoá an toàn và một cặp khoá đường cong elip công khai/riêng tư. Đường cong cụ thể mà thông số kỹ thuật mã hoá đẩy sử dụng được gọi là P-256 hoặc prime256v1
. Để đảm bảo an toàn tối đa, bạn nên tạo cặp khoá từ đầu mỗi khi mã hoá một thông báo và không bao giờ sử dụng lại muối.
ECDH
Hãy dành chút thời gian để nói về một thuộc tính thú vị của phương thức mã hoá đườ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 để lấy một 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ì giá trị sẽ giống hệt nhau!
Đâ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 bí mật dùng chung mặc dù họ chỉ trao đổi khoá công khai. Chúng ta sẽ sử dụng khoá bí mật dùng chung này làm cơ sở cho khoá mã hoá thực tế.
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
Đã đến lúc nói về một vấn đề khác. Giả sử bạn có một số dữ liệu bí mật mà bạn muốn dùng làm khoá mã hoá, nhưng dữ liệu đó chưa đủ bảo mật 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 đổi một khoá bí mật có độ bảo mật thấp thành một khoá có độ bảo mật cao.
Một hệ quả của cách hoạt động này là cho phép bạn lấy một khoá bí mật có số bit bất kỳ và tạo một khoá 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 tính năng đẩ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).
Chúng ta biết rằng chúng ta chỉ cần tạo khoá có kích thước tối đa là 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 thể xử lý kích thước đầu ra lớn hơn.
Tôi đã đưa mã cho phiên bản Node vào bên dưới, nhưng bạn có thể tìm hiểu cách hoạt động thực tế của mã này trong RFC 5869.
Dữ liệu đầu vào của HKDF là một giá trị muối, 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 (info) 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);
}
Trích xuất các tham số mã hoá
Bây giờ, chúng ta sử dụng HKDF để biến dữ liệu hiện có thành các tham số cho quá trình mã hoá thực tế.
Việc đầu tiên chúng ta làm là sử dụng HKDF để kết hợp khoá xác thực ứng dụng và khoá dùng chung thành một khoá dài hơn, an toàn hơn về mặt mã hoá. Trong thông số kỹ thuật, giá trị này được gọi là Khoá giả ngẫu nhiên (PRK), vì vậy, tôi sẽ gọi giá trị này ở đây, mặc dù những người theo chủ nghĩa thuần tuý về mật mã có thể lưu ý rằng đây không hoàn toàn là PRK.
Bây giờ, chúng ta sẽ 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 đến thuật toán mật mã. Các thông tin 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 thông tin, được đề cập 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, muối 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 cho quá trình mã hoá nội dung là "aesgcm", tên của thuật toán mã hoá dùng cho quá trình 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
Ngoài ra, hãy xem một ví dụ ngớ ngẩn và giả tạo. Giả sử rằng cấp trên của bạn có một máy chủ gửi cho cô ấy một thông báo đẩy mỗi vài phút với giá cổ phiếu của công ty. Thông báo thuần tuý cho trường hợp này 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 thoả thuận lén lút với nhân viên phục vụ, theo đó họ có thể gửi cho cô chuỗi "bánh donut trong phòng nghỉ" 5 phút trước khi thực sự giao hàng để cô có thể "trùng hợp" có mặt tại đó khi bánh đến và lấy chiếc bánh ngon nhất.
Thuật toán mã hoá mà Web Push sử dụng sẽ tạo ra các giá trị đã mã hoá dài hơn 16 byte so với giá trị đầu vào chưa mã hoá. Vì "bánh donut trong phòng nghỉ" dài hơn giá cổ phiếu 32 bit, nên bất kỳ nhân viên nào rình mò cũng có thể biết thời điểm bánh donut đến mà không cần giải mã thông báo, chỉ cần dựa vào độ 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 thuộc về ứng dụng của bạn, nhưng trong ví dụ trên, bạn có thể thêm vào tất cả thông báo để có kích thước chính xác là 32 byte, khiến bạn không thể phân biệt các thông báo chỉ dựa trên độ dài.
Giá trị khoảng đệm là một số nguyên big-endian 16 bit chỉ định độ dài khoảng đệm, theo sau là số byte 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 của bạn đến máy khách, trình duyệt sẽ có thể tự động loại bỏ mọi khoảng đệm, vì vậy, mã máy khách của bạn chỉ nhận được thông báo không có khoảng đệm.
Mã hoá
Cuối cùng, chúng ta đã có tất cả các yếu tố cần thiết để mã hoá. Thuật toán mã hoá bắt buộc cho tính năng Đẩy web là AES128 sử dụng GCM. Chúng ta sử 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ó thể là bất kỳ dữ liệu nhị phân nào. Bạn có thể gửi tải trọng có kích thước tối đa là 4078 byte – 4096 byte cho mỗi bài đăng, trong đó 16 byte là thông tin mã hoá và ít nhất 2 byte là phần đệ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()]);
Thông báo đẩy trên web
Chà! Giờ đây, khi đã có tải trọng được mã hoá, bạn chỉ cần tạo một yêu cầu POST qua 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>
và <PUBLICKEY>
là giá trị muối 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 Web Push, nội dung của yêu cầu POST chỉ là các byte thô của thông báo đã mã hoá. Tuy nhiên, cho đến khi Chrome và Firebase Cloud Messaging hỗ trợ giao thức này, bạn có thể dễ dàng đưa dữ liệu vào tải trọng JSON hiện có như sau.
{
"registration_ids": [ "…" ],
"raw_data": "BIXzEKOFquzVlr/1tS1bhmobZ…"
}
Giá trị của thuộc tính rawData
phải là bản trình bày được mã hoá base64 của thông báo đã mã hoá.
Trình gỡ lỗi / trình xác minh
Peter Beverloo, một trong những kỹ sư Chrome đã triển khai tính năng này (cũng như là một trong những người đã làm việc trên thông số kỹ thuật), đã tạo một trình xác minh.
Bằng cách yêu cầu mã của bạn xuất ra 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.