Web Push Yükü Şifreleme

Mat Ölçekler

Chrome 50'den önce push mesajları herhangi bir yük verisi içeremez. Hizmet çalışanınızda 'push' etkinliği tetiklendiğinde bildiğiniz tek şey sunucunun size bir şey söylemeye çalıştığıydı, ancak ne olabileceğiydi. Ardından sunucuya bir takip isteği göndermeniz ve gösterilecek bildirimin ayrıntılarını almanız gerekiyordu. Bu da kötü ağ koşullarında başarısız olabilir.

Artık Chrome 50'de (ve masaüstü bilgisayarlardaki Firefox'un mevcut sürümünde), istemcinin fazladan istekte bulunmasını önlemek için aktarma işlemiyle birlikte rastgele bazı veriler gönderebilirsiniz. Ancak büyük güç, büyük sorumluluk beraberinde getirir. Bu nedenle tüm yük verileri şifrelenmelidir.

Yüklerin şifrelenmesi, web push'ler için güvenlik hikayesinin önemli bir parçasıdır. Sunucuya güvendiğinizden, HTTPS, tarayıcıyla kendi sunucunuz arasında iletişim kurarken size güvenlik sağlar. Bununla birlikte, yükü gerçekten iletmek için hangi aktarma sağlayıcısının kullanılacağını tarayıcı seçer. Bu nedenle, uygulama geliştirici olarak sizin bu yük üzerinde herhangi bir kontrolünüz olmaz.

Burada HTTPS yalnızca, push servis sağlayıcıya giden yolda hiç kimsenin mesajı gizlice okuyamayacağını garanti edebilir. Ürünü aldıktan sonra, istediklerini yapabilirler. Buna, yükü üçüncü taraflara yeniden iletmek veya kötü niyetli bir biçimde başka bir şeyle değiştirmek de dahildir. Buna karşı koruma sağlamak amacıyla, push hizmetlerinin geçiş halindeki yükleri okuyamaması veya bunlar üzerinde değişiklik yapamaması için şifreleme kullanırız.

İstemci tarafındaki değişiklikler

Push bildirimlerini zaten yük olmadan uyguladıysanız istemci tarafında yapmanız gereken yalnızca iki küçük değişiklik vardır.

Öncelikle, abonelik bilgilerini arka uç sunucunuza gönderirken bazı ek bilgiler toplamanız gerekir. Sunucunuza göndermek üzere seri hale getirmek için PushSubscription nesnesinde JSON.stringify() kullanıyorsanız herhangi bir değişiklik yapmanız gerekmez. Artık aboneliğin anahtar özelliğinde bazı ek veriler olacak.

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

p256dh ve auth değerleri, URL-Safe Base64 olarak adlandıracağım bir Base64 varyantında kodlanmış.

Bunun yerine doğrudan baytlara ulaşmak isterseniz abonelikte ArrayBuffer olarak parametre döndüren yeni getKey() yöntemini kullanabilirsiniz. İhtiyacınız olan iki parametre auth ve p256dh'dir.

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

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

İkinci değişiklik, push etkinliği etkinleştiğinde yeni bir data özelliğidir. Alınan verileri ayrıştırmak için .text(), .json(), .arrayBuffer() ve .blob() gibi çeşitli eşzamanlı yöntemlere sahiptir.

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

Sunucu tarafındaki değişiklikler

Sunucu tarafında ise işler biraz daha değişir. Temel işlem, yükü şifrelemek için istemciden aldığınız şifreleme anahtarı bilgilerini kullanmanız ve daha sonra bunu bir POST isteğinin gövdesi olarak abonelikteki uç noktaya birkaç ekstra HTTP üst bilgisi eklemek için göndermenizdir.

Ayrıntılar nispeten karmaşıktır ve şifrelemeyle ilgili her şeyde olduğu gibi, kendi kitaplığınızı yayınlamak yerine aktif olarak geliştirilmiş bir kitaplık kullanmak daha iyidir. Chrome ekibi, Node.js için bir kitaplık yayınladı. Bu kitaplık yakında daha fazla dil ve platform için kullanıma sunulacaktır. Bu işlem hem şifrelemeyi hem de web push protokolünü işleme alır. Böylece, Node.js sunucusundan push mesajı göndermek webpush.sendWebPush(message, subscription) kadar kolaydır.

Kitaplık kullanmanızı önemle tavsiye ederiz ancak bu yeni bir özelliktir ve henüz kitaplığı olmayan birçok popüler dil bulunmaktadır. Bunu kendiniz için uygulamanız gerekiyorsa ayrıntıları aşağıda bulabilirsiniz.

Algoritmaları Düğüm tatlı JavaScript kullanarak göstereceğim, ama temel ilkeler tüm dillerde aynı olmalıdır.

Girişler

Bir iletiyi şifrelemek için öncelikle, istemciden aldığımız abonelik nesnesinden iki şey almamız gerekir. İstemcide JSON.stringify() kullanıp bunu sunucunuza ilettiyseniz istemcinin ortak anahtarı keys.p256dh alanında, paylaşılan kimlik doğrulama gizli anahtarı ise keys.auth alanında saklanır. Bunların ikisi de yukarıda belirtildiği gibi URL için güvenli Base64 olarak kodlanır. İstemci ortak anahtarının ikili biçimi, sıkıştırılmamış P-256 elips biçimli eğri noktasıdır.

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

Ortak anahtar, mesajı şifrelememize olanak tanır. Böylece mesajın şifresi yalnızca istemcinin özel anahtarı kullanılarak çözülebilir.

Genel anahtarlar genellikle herkese açık olarak kabul edilir. Bu nedenle, istemcinin iletinin güvenilir bir sunucu tarafından gönderildiğini doğrulamasına olanak tanımak için kimlik doğrulama gizli anahtarını da kullanırız. Beklenmedik şekilde bu gizli tutulmalı, yalnızca size ileti göndermek istediğiniz uygulama sunucusuyla paylaşılmalı ve bir şifre gibi ele alınmalıdır.

Bazı yeni veriler de oluşturmamız gerekiyor. 16 baytlık kriptografik olarak güvenli rastgele bir tuz ve herkese açık/özel bir elips biçimli eğri anahtar çiftine ihtiyacımız vardır. Push şifreleme spesifikasyonu tarafından kullanılan belirli eğri P-256 veya prime256v1 olarak adlandırılır. Bir iletiyi her şifrelediğinizde anahtar çiftinin en iyi güvenlik için sıfırdan oluşturulması gerekir. Ayrıca takviye bir şifreyi asla yeniden kullanmamalısınız.

ECDH

Şimdi de elips biçimli eğri kriptografinin düzgün bir özelliğinden bahsedelim. Bir değer elde etmek için sizin özel anahtarınızı başka birinin ortak anahtarıyla birleştiren nispeten basit bir işlem vardır. Peki bu nedir? Diğer taraf kendi özel anahtarını ve sizin ortak anahtarınızı alırsa tam olarak aynı değeri elde eder!

Bu, sadece genel anahtarları takas etmelerine rağmen her iki tarafın da aynı paylaşılan gizli anahtara sahip olmasına olanak tanıyan eliptik eğri Diffie-Hellman (ECDH) anahtar sözleşmesi protokolünün temelini oluşturur. Bu paylaşılan sırrı gerçek şifreleme anahtarımız için temel olarak kullanacağız.

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

Bunu bir kenara bırakmanın vakti geldi. Şifreleme anahtarı olarak kullanmak istediğiniz bazı gizli verileriniz olduğunu ancak bu verilerin kriptografik olarak yeterince güvenli olmadığını varsayalım. Düşük güvenlikli bir gizli anahtarı yüksek güvenlikli bir gizli anahtara dönüştürmek için HMAC tabanlı Anahtar Türetme İşlevi'ni (HKDF) kullanabilirsiniz.

Çalışma şeklinin bir sonucu, herhangi bir karma oluşturma algoritması tarafından üretilen bir karma oluşturma kadar sayıda bitten bir gizli anahtar almanıza ve 255 kata kadar herhangi bir boyutta başka bir gizli anahtar oluşturmanıza olanak tanımasıdır. Push için spesifikasyon, 32 bayt (256 bit) karma uzunluğuna sahip SHA-256 kullanmamızı gerektirir.

Sonuçta en fazla 32 bayt boyutunda anahtarlar oluşturmamız gerektiğini biliyoruz. Bu, algoritmanın daha büyük çıktı boyutlarını işleyemeyen basitleştirilmiş bir sürümünü kullanabileceğimiz anlamına gelir.

Düğüm sürümünün kodunu aşağıya ekledim ancak nasıl çalıştığını RFC 5869'dan öğrenebilirsiniz.

HKDF'nin girişleri bir takviye değer, bazı başlangıç anahtarlama materyalleri (ikm), mevcut kullanım alanına özel isteğe bağlı bir yapılandırılmış veri parçası (bilgi) ve istenen çıkış anahtarının bayt cinsinden uzunluğudur.

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

Şifreleme parametrelerini türetme

Artık elimizdeki verileri gerçek şifrelemenin parametrelerine dönüştürmek için HKDF'yi kullanıyoruz.

Yaptığımız ilk şey, istemci kimlik doğrulama gizli anahtarıyla paylaşılan gizli anahtarı daha uzun ve kriptografik açıdan daha güvenli bir gizli anahtarda karıştırmak için HKDF'yi kullanmaktır. Spesifikasyonda buna Sahte-Rastgele Anahtar (PRK) adı verilir. Ben burada bunu kullanacağım, ancak kriptografi uzmanları bunun tam olarak bir PRK olmadığını belirtebilir.

Şimdi son içerik şifreleme anahtarını ve şifreye iletilecek bir tek seferlik tek seferlik oluşturuyoruz. Bunlar, mesajın kaynağını daha ayrıntılı bir şekilde doğrulamak amacıyla elips biçimli eğriye, bilgiyi gönderen ve alan kişiye özel bilgiler içeren, her biri için spesifikasyonda bilgi olarak belirtilen basit bir veri yapısı oluşturularak oluşturulur. Daha sonra, doğru boyutta anahtar ve tek seferlik rastgele elde etmek için PRK, takviye değer ve bilgiler ile HKDF'yi kullanırız.

İçerik şifrelemenin bilgi türü, push şifreleme için kullanılan şifrenin adıdır. Bu da "aesgcm"dir.

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

Dolgu

Bir kenara atıp saçma ve komik bir örnek verme zamanı geldi. Patronunuzun, birkaç dakikada bir şirketin hisse senedi fiyatını içeren bir push mesajı gönderen bir sunucusu olduğunu varsayalım. Bunun düz mesajı her zaman sent cinsinden değeri olan 32 bitlik bir tam sayı olacaktır. Yemek servisi personeliyle kurnaz bir anlaşma yaptı. Bu sayede, "dinlenme salonundaki çörekler" dizesini gerçekten teslim edilmeden 5 dakika önce ona gönderebilirler. Böylece, "tesadüfen" oraya vardıklarında en iyi çörekleri yakalayabilirler.

Web Push tarafından kullanılan şifre, şifrelenmemiş girişten tam olarak 16 bayt daha uzun şifrelenmiş değerler oluşturur. "Dinlenme odasındaki çörekler" 32 bitlik hisse senedi fiyatından daha uzun olduğu için, merak eden herhangi bir çalışan, mesajların şifresini çözmeden çöreklerin ne zaman geldiğini yalnızca verilerin uzunluğuna bakarak anlayabilir.

Bu nedenle, web push protokolü verilerin başına dolgu eklemenize olanak tanır. Bunu nasıl kullanacağınız uygulamanıza bağlıdır, ancak yukarıdaki örnekte tüm mesajları tam olarak 32 bayt olacak şekilde yerleştirebilirsiniz. Bu durumda mesajları sadece uzunluğa göre ayırt etmek imkansızdır.

Dolgu değeri, dolgu uzunluğunu belirten ve ardından bu NUL baytlık dolguyu belirten 16 bitlik bir büyük uçlu tam sayıdır. Yani minimum dolgu iki bayttır. Yani 16 bit olarak kodlanmış sıfır sayısı.

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

Push mesajınız istemciye ulaştığında, tarayıcı tüm dolguyu otomatik olarak kaldırabilir. Böylece, istemci kodunuz yalnızca doldurulmamış mesajı alır.

Şifreleme

Artık şifreleme ile ilgili yapılacak her şeye sahibiz. Web Push için gerekli şifre, GCM kullanılarak AES128'dir. İçerik şifreleme anahtarımızı anahtar olarak, tek seferlik rakamı ise başlatma vektörü (IV) olarak kullanırız.

Bu örnekte, verilerimiz bir dizedir, ancak herhangi bir ikili veri de olabilir. Yayın başına maksimum 4.096 bayt olmak üzere, şifreleme bilgileri için 16 bayt ve dolgu için en az 2 bayt olmak üzere 4.078 bayta kadar (gönderi başına maksimum 4.096 bayt) yük gönderebilirsiniz.

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

Web push bildirimi

Bora Artık şifrelenmiş bir yükünüz olduğuna göre, kullanıcının aboneliği tarafından belirtilen uç noktaya görece basit bir HTTP POST isteği göndermeniz gerekir.

Üç başlık ayarlamanız gerekiyor.

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

<SALT> ve <PUBLICKEY>, şifrelemede kullanılan ve URL için güvenli Base64 olarak kodlanan takviye ve sunucu ortak anahtarıdır.

Web Push protokolü kullanılırken POST'un gövdesi, şifrelenmiş mesajın yalnızca ham baytlarından oluşur. Ancak Chrome ve Firebase Cloud Messaging protokolü destekleyene kadar, verileri mevcut JSON yükünüze aşağıdaki gibi kolayca ekleyebilirsiniz.

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

rawData özelliğinin değeri, şifrelenmiş mesajın base64 kodlanmış gösterimi olmalıdır.

Hata ayıklama / doğrulayıcı

Bu özelliği uygulayan Chrome mühendislerinden (ve spesifikasyon üzerinde çalışan kişilerden biri) Peter Beverloo, bir doğrulayıcı oluşturdu.

Şifrelemenin her bir ara değerinin çıkışını almak için kodunuzu alarak bunları doğrulayıcıya yapıştırabilir ve doğru yolda olup olmadığınızı kontrol edebilirsiniz.