웹 푸시 페이로드 암호화

매트 저울

Chrome 50 이전에는 푸시 메시지에 페이로드 데이터를 포함할 수 없었습니다. 서비스 워커에서 'push' 이벤트가 실행되었을 때는 서버가 무언가를 말하려고 한 것이 아니라 무언가를 전달하려고 한다는 것만 알고 있었습니다. 그런 다음 서버에 후속 요청을 보내고 표시할 알림의 세부정보를 가져와야 했으며 네트워크 상태가 좋지 않으면 실패할 수 있습니다.

이제 Chrome 50 (데스크톱의 Firefox 최신 버전)에서는 클라이언트에서 추가 요청을 하지 않도록 푸시와 함께 임의의 데이터를 전송할 수 있습니다. 그러나 강력한 힘에는 큰 책임이 따르기 때문에 모든 페이로드 데이터를 암호화해야 합니다.

페이로드 암호화는 웹 푸시의 보안 스토리에서 중요한 부분입니다. HTTPS는 브라우저를 신뢰하므로 브라우저와 자체 서버 간에 통신할 때 보안을 제공합니다. 하지만 실제로 페이로드를 전달하는 데 사용할 푸시 제공자는 브라우저에서 선택하므로 앱 개발자는 이를 제어할 수 없습니다.

여기서 HTTPS는 푸시 서비스 제공업체로 전송 중인 메시지를 스누핑할 수 없음을 보장할 수만 있습니다. 수신된 후에는 페이로드를 제3자에게 재전송하거나 악의적으로 다른 것으로 변경하는 등 원하는 작업을 할 수 있습니다. 이를 방지하기 위해 Google은 암호화를 사용하여 전송 중인 페이로드를 푸시 서비스가 읽거나 변조할 수 없도록 합니다.

클라이언트 측 변경사항

이미 페이로드 없이 푸시 알림을 구현한 경우 클라이언트 측에서 간단하게 두 가지만 변경하면 됩니다.

먼저 백엔드 서버로 정기 결제 정보를 전송할 때 몇 가지 추가 정보를 수집해야 한다는 의미입니다. PushSubscription 객체에서 이미 JSON.stringify()를 사용하여 서버로 전송하기 위해 객체를 직렬화하고 있다면 아무것도 변경할 필요가 없습니다. 이제 구독의 키 속성에 일부 추가 데이터가 포함됩니다.

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

두 값 p256dhauthURL-Safe Base64라고 하는 Base64의 변형으로 인코딩됩니다.

대신 바이트에서 바로 가져오려면 정기 결제에서 매개변수를 ArrayBuffer로 반환하는 새로운 getKey() 메서드를 사용하면 됩니다. 필요한 두 매개변수는 authp256dh입니다.

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

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

두 번째 변경사항은 push 이벤트 실행 시의 새로운 data 속성입니다. 여기에는 수신된 데이터를 파싱하는 다양한 동기 메서드(예: .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)만큼 쉽습니다.

라이브러리 사용을 확실하게 권장하지만 이는 새로운 기능이며 널리 사용되는 많은 언어에서는 아직 라이브러리가 없습니다. 이 작업을 직접 구현해야 하는 경우 아래에서 세부정보를 확인하세요.

노드 버전 자바스크립트를 사용하는 알고리즘을 설명하겠습니다. 기본 원칙은 모든 언어에서 동일해야 합니다.

입력

메시지를 암호화하려면 먼저 클라이언트로부터 수신한 구독 객체에서 두 가지를 가져와야 합니다. 클라이언트에서 JSON.stringify()를 사용하고 이를 서버로 전송한 경우 클라이언트의 공개 키는 keys.p256dh 필드에 저장되고 공유 인증 비밀번호는 keys.auth 필드에 저장됩니다. 둘 다 위에서 언급한 것처럼 URL 보안 Base64로 인코딩됩니다. 클라이언트 공개 키의 바이너리 형식은 압축되지 않은 P-256 타원 곡선 포인트입니다.

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

공개 키를 사용하면 클라이언트의 비공개 키로만 메시지를 복호화할 수 있도록 메시지를 암호화할 수 있습니다.

일반적으로 공개 키는 공개 키로 간주되므로 클라이언트가 신뢰할 수 있는 서버에서 보낸 메시지임을 인증할 수 있도록 Google에서는 인증 비밀번호도 사용합니다. 당연히 이 비밀번호는 비밀로 유지하고 메시지를 보내려는 애플리케이션 서버와만 공유하며 비밀번호처럼 취급해야 합니다.

새로운 데이터도 생성해야 합니다. 암호화 방식으로 안전한 16바이트 임의 솔트와 공개/비공개 타원 곡선 키가 필요합니다. 푸시 암호화 사양에 사용되는 곡선을 P-256 또는 prime256v1라고 합니다. 최상의 보안을 위해 메시지를 암호화할 때마다 키 쌍을 처음부터 새로 생성해야 하며 솔트를 재사용해서는 안 됩니다.

ECDH

잠시 타원 곡선 암호의 깔끔한 속성에 대해 이야기해 보겠습니다. 나의 비공개 키와 다른 사람의 공개 키를 결합하여 값을 얻는 프로세스는 비교적 간단합니다. 어떻게 해야 할까요? 상대방이 자신의 비공개 키와 공개 키를 취하면 정확히 같은 값을 얻게 됩니다.

이는 타원 곡선 디피-헬만 (ECDH) 키 계약 프로토콜의 기반으로, 양 당사자는 공개 키만 교환하더라도 동일한 공유 비밀번호를 가질 수 있습니다. 이 공유 보안 비밀을 실제 암호화 키의 기반으로 사용하겠습니다.

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

25홍콩

이미 또 다른 건 아니에요. 암호화 키로 사용할 보안 비밀 데이터가 있지만 암호화 보안 수준이 충분하지 않다고 가정해 보겠습니다. HMAC 기반 키 파생 함수(HKDF)를 사용하여 보안이 낮은 보안 비밀을 높은 보안 비밀로 변환할 수 있습니다.

이 방법을 사용하면 여러 비트의 보안 비밀을 가져와 사용하는 해싱 알고리즘에 의해 생성되는 해시만큼 최대 255배의 다른 보안 비밀을 생성할 수 있습니다. 푸시의 경우 사양에 따라 해시 길이가 32바이트 (256비트)인 SHA-256을 사용해야 합니다.

그러면 최대 32바이트 크기의 키만 생성하면 됩니다. 즉, 더 큰 출력 크기를 처리할 수 없는 단순화된 버전의 알고리즘을 사용할 수 있습니다.

아래에 노드 버전의 코드가 나와 있지만 실제로 어떻게 작동하는지는 RFC 5869에서 확인할 수 있습니다.

HKDF의 입력은 솔트, 일부 초기 키 지정 자료(ikm), 현재 사용 사례(정보)와 관련된 구조화된 데이터 조각(선택사항), 원하는 출력 키의 길이(바이트)입니다.

// 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를 사용하여 클라이언트 인증 비밀번호와 공유 비밀번호를 더 길고 더 안전한 보안 비밀로 혼합합니다. 사양에서는 이를 PRK (의사 랜덤 키)라고 부르기 때문에 여기서는 이를 PRK라고 부르겠습니다. 하지만 암호화 애호가는 이것이 PRK가 아니라는 것을 알 수 있습니다.

이제 암호화에 전달할 최종 콘텐츠 암호화 키와 nonce를 만듭니다. 이는 메시지 소스를 추가로 확인하기 위해 타원 곡선, 정보의 발신자, 수신자와 관련된 정보가 포함된 각 정보의 간단한 데이터 구조(사양에서 정보라고 함)를 만들어 생성됩니다. 그런 다음 HKDF를 PRK, 솔트 및 정보와 함께 사용하여 올바른 크기의 키와 nonce를 가져옵니다.

콘텐츠 암호화의 정보 유형은 푸시 암호화에 사용되는 암호화의 이름인 '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);

패딩

어리석고 교활한 예나 상사에게 몇 분마다 회사 주가가 포함된 푸시 메시지를 보내는 서버가 있다고 가정해 보겠습니다. 이에 대한 일반 메시지는 항상 센트 단위의 값이 포함된 32비트 정수입니다. 케이터링 직원과도 교묘한 거래를 하고 있습니다. 즉, 실제로 배달되기 5분 전에 '휴게실에 있는 도넛'이라는 문자열을 보낼 수 있습니다. 그러면 케이터링 직원이 도착하면 '우연히' 그곳에 도착하여 제일 좋은 도넛을 찾을 수 있습니다.

웹 푸시에 사용되는 암호화는 암호화되지 않은 입력보다 정확히 16바이트 길이의 암호화된 값을 생성합니다. '휴게실의 도넛'은 32비트 주가보다 더 길기 때문에 모든 스누핑 직원은 메시지를 복호화하지 않고도 데이터의 길이만으로 도넛이 도착하는 시점을 알 수 있습니다.

따라서 웹 푸시 프로토콜을 사용하면 데이터 시작 부분에 패딩을 추가할 수 있습니다. 사용하는 방법은 애플리케이션에 따라 다르지만 위의 예에서 모든 메시지를 정확히 32바이트가 되도록 패딩할 수 있으므로 길이만으로는 메시지를 구별할 수 없습니다.

패딩 값은 패딩 길이에 이은 숫자 NUL바이트의 패딩을 지정하는 16비트 big-endian 정수입니다. 따라서 최소 패딩은 2바이트, 즉 16비트로 인코딩된 숫자 0입니다.

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

푸시 메시지가 클라이언트에 도착하면 브라우저에서 패딩을 자동으로 제거할 수 있으므로 클라이언트 코드는 패딩되지 않은 메시지만 수신합니다.

암호화

이제 마침내 암호화를 위해 모든 작업을 준비했습니다. 웹 푸시에 필요한 암호화는 GCM을 사용하는 AES128입니다. 콘텐츠 암호화 키를 키로, 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()]);

웹 푸시

휴! 이제 암호화된 페이로드가 있으므로 사용자의 구독에서 지정한 엔드포인트에 비교적 간단한 HTTP POST 요청을 전송하기만 하면 됩니다.

3개의 헤더를 설정해야 합니다.

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

<SALT><PUBLICKEY>는 암호화에 사용되는 솔트 및 서버 공개 키이며 URL 보안 Base64로 인코딩됩니다.

웹 푸시 프로토콜을 사용하는 경우 POST 본문은 암호화된 메시지의 원시 바이트입니다. 그러나 Chrome 및 Firebase 클라우드 메시징이 프로토콜을 지원할 때까지는 다음과 같이 쉽게 기존 JSON 페이로드에 데이터를 포함할 수 있습니다.

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

rawData 속성의 값은 암호화된 메시지를 base64로 인코딩한 표현이어야 합니다.

디버깅 / 확인기

이 기능을 구현한 Chrome 엔지니어 중 한 명인 피터 베벌루는 사양 작업을 수행한 사람 중 한 명이며 인증자를 만들었습니다.

암호화의 각 중간 값을 출력하는 코드를 가져와 인증 도구에 붙여넣고 올바르게 진행하고 있는지 확인할 수 있습니다.