웹 푸시 페이로드 암호화

Mat Scales

Chrome 50 이전에는 푸시 메시지에 페이로드 데이터가 포함될 수 없습니다. 서비스 워커에서 'push' 이벤트가 발생할 때는 서버가 뭔가를 알려주려고 했지만 실제로는 무엇인지 알지 못했다는 것만 알고 있었습니다. 그런 다음 서버에 후속 요청을 보내 표시할 알림의 세부정보를 가져와야 했는데, 네트워크 상태가 좋지 않으면 실패할 수 있습니다.

이제 Chrome 50 (및 데스크톱의 현재 버전 Firefox)에서는 클라이언트가 추가 요청을 하지 않도록 푸시와 함께 임의의 데이터를 전송할 수 있습니다. 하지만 큰 힘에는 큰 책임이 따르므로 모든 페이로드 데이터를 암호화해야 합니다.

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

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

클라이언트 측 변경사항

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

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

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

두 값 p256dhauthURL 안전 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());
  }
});

서버 측 변경

서버 측에서는 상황이 조금 더 달라집니다. 기본 프로세스는 클라이언트에서 가져온 암호화 키 정보를 사용하여 페이로드를 암호화한 다음 이를 추가 HTTP 헤더를 추가하여 정기 결제의 엔드포인트로 POST 요청의 본문으로 전송하는 것입니다.

세부정보는 상대적으로 복잡하며 암호화와 관련된 모든 것과 마찬가지로 직접 개발하기보다는 활발히 개발된 라이브러리를 사용하는 것이 좋습니다. Chrome팀은 Node.js용 라이브러리를 게시했으며 더 많은 언어와 플랫폼이 곧 제공될 예정입니다. 이렇게 하면 암호화와 웹 푸시 프로토콜이 모두 처리되므로 Node.js 서버에서 푸시 메시지를 보내는 것이 webpush.sendWebPush(message, subscription)만큼 쉽습니다.

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

노드 기반 JavaScript를 사용하는 알고리즘을 설명하겠지만 기본 원칙은 모든 언어에서 동일해야 합니다.

입력

메시지를 암호화하려면 먼저 클라이언트로부터 수신한 구독 객체에서 두 가지 항목을 가져와야 합니다. 클라이언트에서 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');

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

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

또한 새 데이터를 생성해야 합니다. 암호화적으로 안전한 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);

HKDF

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

작동 방식의 한 가지 결과는 비트 수에 관계없이 비밀을 가져와서 사용하는 해싱 알고리즘에서 생성된 해시의 최대 255배 길이의 다른 크기의 비밀을 생성할 수 있다는 것입니다. 푸시의 경우 사양에 따라 해시 길이가 32바이트 (256비트)인 SHA-256을 사용해야 합니다.

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

아래에 Node 버전의 코드를 포함했지만 RFC 5869에서 실제로 작동하는 방식을 확인할 수 있습니다.

HKDF의 입력은 솔트, 일부 초기 키 자료(ikm), 현재 사용 사례와 관련된 선택적 정형 데이터(info) 및 원하는 출력 키의 길이(바이트)입니다.

// 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를 만듭니다. 이는 메시지 소스를 추가로 확인하기 위해 타원 곡선, 정보의 발신자, 수신자에 관한 정보를 포함하는 간단한 데이터 구조(사양에서 정보로 참조)를 각각 만들어 생성됩니다. 그런 다음 PRK, 솔트, 정보와 함께 HKDF를 사용하여 올바른 크기의 키와 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바이트로 패딩하여 길이만으로 메시지를 구분할 수 없도록 할 수 있습니다.

패딩 값은 패딩 길이를 지정하는 16비트 big-endian 정수이며 그 뒤에 패딩 바이트 NUL개가 옵니다. 따라서 최소 패딩은 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 Cloud Messaging에서 프로토콜을 지원할 때까지는 다음과 같이 기존 JSON 페이로드에 데이터를 쉽게 포함할 수 있습니다.

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

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

디버깅 / 검증자

이 기능을 구현한 Chrome 엔지니어이자 사양 작업을 진행한 사람 중 한 명인 피터 베벌루는 인증자를 생성했습니다.

코드가 암호화의 각 중간 값을 출력하도록 하면 이를 검증자에 붙여넣고 올바른 방향인지 확인할 수 있습니다.