Web Push Yükü Şifreleme

Mat Scales

Chrome 50'den önce push mesajları, yükü veri içeremezdi. Servis çalışanınızda "push" etkinliği tetiklendiğinde, sunucunun size bir şey söylemeye çalıştığından haberdar olurdunuz ancak ne olduğunu bilemezdiniz. Ardından sunucuya bir takip isteği göndermeniz ve gösterilecek bildirimin ayrıntılarını almanız gerekir. Bu işlem, kötü ağ koşullarında başarısız olabilir.

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

Yüklerin şifrelenmesi, web push'inin güvenlik hikayesinin önemli bir parçasıdır. HTTPS, sunucuya güvendiğinizden, tarayıcı ile kendi sunucunuz arasında iletişim kururken size güvenlik sağlar. Ancak tarayıcı, yükün gerçekten yayınlanması için hangi push sağlayıcının kullanılacağını seçer. Bu nedenle, uygulama geliştiricisi olarak bu konuda hiçbir kontrol sahibi değilsiniz.

Burada HTTPS yalnızca itme hizmeti sağlayıcıya aktarılan iletiyi kimsenin gözetleyemeyeceğini garanti edebilir. Aldıktan sonra, yükü üçüncü taraflara yeniden iletmek veya kötü amaçlı olarak başka bir şeye dönüştürmek de dahil olmak üzere istediklerini yapabilirler. Bu duruma karşı koruma sağlamak için, push hizmetlerinin aktarım sırasındaki yükleyicileri okuyamamasını veya bozamamasını sağlamak amacıyla şifreleme kullanırız.

İstemci tarafı değişiklikleri

Yük olmadan push bildirimleri uyguladıysanız istemci tarafında yapmanız gereken yalnızca iki küçük değişiklik vardır.

İlk olarak, abonelik bilgilerini arka uç sunucunuza gönderirken bazı ek bilgiler toplamanız gerekir. PushSubscription nesnesinde, sunucunuza göndermek için serileştirmek üzere zaten JSON.stringify() kullanıyorsanız herhangi bir değişiklik yapmanız gerekmez. Abonelik artık anahtarlar mülkünde bazı ek verilere sahip olacaktır.

> 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 Base64 varyantında kodlanmıştır.

Bunun yerine doğrudan baytlara erişmek istiyorsanız abonelikte, parametreyi ArrayBuffer olarak döndüren yeni getKey() yöntemini kullanabilirsiniz. İhtiyacınız olan iki parametre auth ve p256dh'dır.

> 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 tetiklendiğinde yeni bir veri mülkü olmasıdır. 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ı değişiklikleri

Sunucu tarafında durum biraz daha değişir. Temel işlem, istemciden aldığınız şifreleme anahtarı bilgilerini kullanarak yükü şifrelemek ve ardından bazı ek HTTP üstbilgileri ekleyerek bunu abonelikteki uç noktaya bir POST isteğinin gövdesi olarak göndermektir.

Ayrıntılar nispeten karmaşıktır ve şifrelemeyle ilgili her şeyde olduğu gibi, kendi kitaplığınızı oluşturmak yerine etkin bir şekilde geliştirilmiş bir kitaplık kullanmak daha iyidir. Chrome ekibi, Node.js için bir kitaplık yayınladı. Yakında daha fazla dil ve platform eklenecektir. Bu, hem şifrelemeyi hem de web push protokolünü yönetir. Böylece, Node.js sunucusundan push mesajı göndermek webpush.sendWebPush(message, subscription) kadar kolaydır.

Kitaplık kullanmanızı kesinlikle öneririz. Ancak bu yeni bir özellik olduğundan henüz kitaplığı olmayan birçok popüler dil vardır. Bunu kendiniz uygulamanız gerekiyorsa ayrıntıları aşağıda bulabilirsiniz.

Algoritmaları Node tarzı JavaScript kullanarak açıklayacağım ancak temel ilkeler her dilde aynı olmalıdır.

Girişler

Bir mesajı şifrelemek için öncelikle istemciden aldığımız abonelik nesnesinden iki şey almamız gerekir. İstemcide JSON.stringify() kullandıysanız ve bunu sunucunuza ilettiyseniz istemcinin ortak anahtarı keys.p256dh alanında, paylaşılan kimlik doğrulama gizli anahtarı ise keys.auth alanında depolanır. Bunların her 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, iletiyi şifrelememize olanak tanır. Böylece iletinin şifresi yalnızca istemcinin özel anahtarı kullanılarak çözülebilir.

Ortak anahtarlar genellikle herkese açık kabul edilir. Bu nedenle, istemcinin iletinin güvenilir bir sunucu tarafından gönderildiğini doğrulamasına izin vermek için kimlik doğrulama sırrını da kullanırız. Bu adresin gizli tutulması, yalnızca size mesaj göndermesini istediğiniz uygulama sunucusuyla paylaşılması ve şifre gibi ele alınması şaşırtıcı değildir.

Ayrıca yeni veriler de oluşturmamız gerekiyor. 16 baytlık kriptografik olarak güvenli rastgele bir salt ve ortak/özel bir elips biçimli eğri anahtar çiftine ihtiyacımız var. Push şifreleme spesifikasyonu tarafından kullanılan özel eğri P-256 veya prime256v1 olarak adlandırılır. En iyi güvenlik için anahtar çifti, bir mesajı her şifrelediğinizde Scratch'tan oluşturulmalıdır ve hiçbir zaman bir takviyeyi yeniden kullanmamalısınız.

ECDH

Elips biçimli eğri kriptografinin sorunsuz bir özelliğinden bahsedelim. Bir değer elde etmek için kendi özel anahtarınızı başka birinin herkese açık anahtarıyla birleştiren nispeten basit bir işlem vardır. Peki bu durumda ne olur? Diğer taraf kendi özel anahtarını ve sizin ortak anahtarınızı alırsa tam olarak aynı değeri elde eder.

Bu, yalnızca ortak anahtarlar değiş tokuş edilmiş olsa bile her iki tarafın da aynı paylaşılan gizli anahtara sahip olmasını sağlayan eliptik eğri Diffie-Hellman (ECDH) anahtar sözleşmesi protokolünün temelini oluşturur. Gerçek şifreleme anahtarımızın temeli olarak bu paylaşılan gizli bilgiyi kullanırı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

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

Bu yöntemin bir sonucu, kullandığınız karma oluşturma algoritması tarafından oluşturulan karma değeri kadar 255 kat daha uzun olan 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'yı kullanmamızı gerektirir.

32 bayttan büyük anahtarlar oluşturmamıza gerek olmadığını biliyoruz. Bu, algoritmanın daha büyük çıkış boyutlarını işleyemeyen basitleştirilmiş bir sürümünü kullanabileceğimiz anlamına gelir.

Aşağıda bir Node sürümünün kodunu ekledim ancak bunun gerçekte nasıl çalıştığını RFC 5869'da bulabilirsiniz.

HKDF'ye yapılan girişler 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 şifreleme parametrelerine dönüştürmek için HKDF'yi kullanıyoruz.

İlk olarak, istemci kimlik doğrulama gizli anahtarını ve paylaşılan gizli anahtarı daha uzun ve kriptografik olarak daha güvenli bir gizli anahtar oluşturacak şekilde karıştırmak için HKDF'yi kullanırız. Spesifikasyonda bu, sözde rastgele anahtar (PRK) olarak adlandırılır. Bu nedenle, burada da bu adı kullanacağım. Ancak kriptografi uzmanları bunun tam olarak bir PRK olmadığını belirtebilir.

Ardından, nihai içerik şifreleme anahtarını ve şifreye iletilecek bir nonce oluştururuz. Bunlar, her biri için basit bir veri yapısı oluşturularak oluşturulur. Bu veri yapısı, özellikte bilgi olarak adlandırılır. Mesajın kaynağını daha da doğrulamak için eliptik eğriye, gönderene ve bilginin alıcısına özgü bilgileri içerir. Ardından, doğru boyuttaki anahtarı ve tek seferlik sayıyı elde etmek için PRK, tuzumuz ve bilgilerle birlikte HKDF'yi kullanırız.

İçerik şifrelemenin bilgi türü, push şifreleme için kullanılan şifrenin adı olan "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 diğer konu da saçma ve yapmacık bir örnek vermek. Patronunuzun, şirket hisse fiyatını birkaç dakikada bir push mesajı olarak gönderen bir sunucusu olduğunu varsayalım. Bunun için düz mesaj, her zaman değeri sent cinsinden olan 32 bitlik bir tam sayı olacaktır. Ayrıca, yemek servisi personeliyle gizli bir anlaşma yapmış. Bu anlaşmaya göre, donutlar servis edilmeden 5 dakika önce kendisine "dinlenme odasında donutlar" mesajı gönderiliyor. Böylece, donutlar geldiğinde "tesadüfen" orada olup en iyisini kapabiliyor.

Web Push tarafından kullanılan şifre, şifrelenmemiş girişten tam olarak 16 bayt daha uzun olan şifrelenmiş değerler oluşturur. "Dinleme odasındaki çörekler", 32 bitlik hisse senedi fiyatından daha uzun olduğundan, meraklı çalışanlar çöreklerin geldiği zamanı mesajların şifresini çözmeden sadece veri 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 doldurabilirsiniz. Bu durumda mesajları yalnızca uzunluğa göre ayırt etmek imkansızdır.

Dolgu değeri, dolgu uzunluğunu belirten 16 bitlik büyük endian bir tam sayıdır. Bu tam sayının ardından, dolgu uzunluğu kadar NUL baytlık dolgu gelir. Bu nedenle, minimum dolgu iki bayttır (16 bitlik 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 dolguları otomatik olarak kaldırabilir. Böylece, istemci kodunuz yalnızca doldurulmamış mesajı alır.

Şifreleme

Artık şifrelemeyi yapmak için gereken her şeye sahibiz. Web Push için gereken şifreleme yöntemi, GCM kullanılarak AES128'dir. Anahtar olarak içerik şifreleme anahtarımızı, başlatma vektörü (IV) olarak da tek seferlik anahtarı kullanırız.

Bu örnekte verilerimiz bir dizedir ancak herhangi bir ikili veri olabilir. Gönderi başına en fazla 4078 bayt (şifreleme bilgileri için 16 bayt ve dolgu için en az 2 bayt) olmak üzere 4078 bayttan 4096 bayta kadar 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

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

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

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

<SALT> ve <PUBLICKEY>, şifreleme işleminde kullanılan tuz ve sunucu ortak anahtarıdır ve URL için güvenli Base64 olarak kodlanmıştır.

Web Push protokolü kullanıldığında POST'un gövdesi, şifrelenmiş mesajın ham baytları olur. Ancak Chrome ve Firebase Cloud Mesajlaşma protokolü desteklenene kadar verileri mevcut JSON yükünüze aşağıdaki gibi kolayca ekleyebilirsiniz.

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

rawData mülkünün değeri, şifrelenmiş mesajın base64 kodlu temsili olmalıdır.

Hata ayıklama / doğrulayıcı

Özelliği uygulayan Chrome mühendislerinden (ayrıca spesifikasyon üzerinde çalışanlardan) biri olan Peter Beverloo, doğrulayıcı oluşturdu.

Kodunuzu, şifrelemenin ara değerlerinin her birini döndürecek şekilde ayarlayarak bunları doğrulayıcıya yapıştırabilir ve doğru yolda olup olmadığınızı kontrol edebilirsiniz.