Skip to content

web-push-libs/ecec

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ecec

GitHub version Build Status Coverage

ecec is a C implementation of HTTP Encrypted Content-Encoding. It's a port of the reference JavaScript implementation.

Encrypted content-coding is used to encrypt Web Push messages, and can be used standalone.

Table of Contents

Usage

Generating subscription keys

#include <ece.h>

int
main() {
  // The subscription private key. This key should never be sent to the app
  // server. It should be persisted with the endpoint and auth secret, and used
  // to decrypt all messages sent to the subscription.
  uint8_t rawRecvPrivKey[ECE_WEBPUSH_PRIVATE_KEY_LENGTH];

  // The subscription public key. This key should be sent to the app server,
  // and used to encrypt messages. The Push DOM API exposes the public key via
  // `pushSubscription.getKey("p256dh")`.
  uint8_t rawRecvPubKey[ECE_WEBPUSH_PUBLIC_KEY_LENGTH];

  // The shared auth secret. This secret should be persisted with the
  // subscription information, and sent to the app server. The DOM API exposes
  // the auth secret via `pushSubscription.getKey("auth")`.
  uint8_t authSecret[ECE_WEBPUSH_AUTH_SECRET_LENGTH];

  int err = ece_webpush_generate_keys(
    rawRecvPrivKey, ECE_WEBPUSH_PRIVATE_KEY_LENGTH, rawRecvPubKey,
    ECE_WEBPUSH_PUBLIC_KEY_LENGTH, authSecret, ECE_WEBPUSH_AUTH_SECRET_LENGTH);
  if (err) {
    return 1;
  }
  return 0;
}

aes128gcm

This is the scheme described in RFC 8188. It's supported in Firefox 55+ and Chrome 60+, and replaces the older aesgcm scheme from earlier drafts. This scheme includes the salt, record size, and sender public key in a binary header block in the payload.

Encryption

This example program writes an encrypted Web Push message to a file, then prints out a curl command to send the message.

#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include <ece.h>

int
main() {
  // The endpoint, public key, and auth secret for the push subscription. These
  // are exposed via `JSON.stringify(pushSubscription)` in the browser.
  const char* endpoint = "https://updates.push.services.mozilla.com/...";
  const char* p256dh = "BDwwYm4O5dZG9SO6Vaz168iDLGWMmitkj5LFvunvMfgmI2fZdAEaiHT"
                       "DfKR0fvr0D3V56cSGSeUwP0xNdrXho5k";
  const char* auth = "xcmQLthL5H2pJNuxrZO-qQ";

  // The message to encrypt.
  const void* plaintext = "I'm just like my country, I'm young, scrappy, and "
                          "hungry, and I'm not throwing away my shot.";
  size_t plaintextLen = strlen(plaintext);

  // How many bytes of padding to include in the encrypted message. Padding
  // obfuscates the plaintext length, making it harder to guess the contents
  // based on the encrypted payload length.
  size_t padLen = 0;

  // Base64url-decode the subscription public key and auth secret. `recv` is
  // short for "receiver", which, in our case, is the browser.
  uint8_t rawRecvPubKey[ECE_WEBPUSH_PUBLIC_KEY_LENGTH];
  size_t rawRecvPubKeyLen =
    ece_base64url_decode(p256dh, strlen(p256dh), ECE_BASE64URL_REJECT_PADDING,
                         rawRecvPubKey, ECE_WEBPUSH_PUBLIC_KEY_LENGTH);
  assert(rawRecvPubKeyLen > 0);
  uint8_t authSecret[ECE_WEBPUSH_AUTH_SECRET_LENGTH];
  size_t authSecretLen =
    ece_base64url_decode(auth, strlen(auth), ECE_BASE64URL_REJECT_PADDING,
                         authSecret, ECE_WEBPUSH_AUTH_SECRET_LENGTH);
  assert(authSecretLen > 0);

  // Allocate a buffer large enough to hold the encrypted payload. The payload
  // length depends on the record size, padding, and plaintext length, plus a
  // fixed-length header block. Smaller records and additional padding take
  // more space. The maximum payload length rounds up to the nearest whole
  // record, so the actual length after encryption might be smaller.
  size_t payloadLen = ece_aes128gcm_payload_max_length(ECE_WEBPUSH_DEFAULT_RS,
                                                       padLen, plaintextLen);
  assert(payloadLen > 0);
  uint8_t* payload = calloc(payloadLen, sizeof(uint8_t));
  assert(payload);

  // Encrypt the plaintext. `payload` holds the header block and ciphertext;
  // `payloadLen` is an in-out parameter set to the actual payload length.
  int err = ece_webpush_aes128gcm_encrypt(
    rawRecvPubKey, rawRecvPubKeyLen, authSecret, authSecretLen,
    ECE_WEBPUSH_DEFAULT_RS, padLen, plaintext, plaintextLen, payload,
    &payloadLen);
  assert(err == ECE_OK);

  // Write the payload out to a file.
  const char* filename = "aes128gcm.bin";
  FILE* payloadFile = fopen(filename, "wb");
  assert(payloadFile);
  size_t payloadFileLen =
    fwrite(payload, sizeof(uint8_t), payloadLen, payloadFile);
  assert(payloadLen == payloadFileLen);
  fclose(payloadFile);

  printf(
    "curl -v -X POST -H \"Content-Encoding: aes128gcm\" --data-binary @%s %s\n",
    filename, endpoint);

  free(payload);

  return 0;
}

Decryption

// Assume `rawSubPrivKey` and `authSecret` contain the subscription private key
// and auth secret.
uint8_t rawSubPrivKey[ECE_WEBPUSH_PRIVATE_KEY_LENGTH] = {0};
uint8_t authSecret[ECE_WEBPUSH_AUTH_SECRET_LENGTH] = {0};

// Assume `payload` points to the contents of the encrypted payload, and
// `payloadLen` specifies the length.
uint8_t* payload = NULL;
size_t payloadLen = 0;

size_t plaintextLen = ece_aes128gcm_plaintext_max_length(payload, payloadLen);
assert(plaintextLen > 0);
uint8_t* plaintext = calloc(plaintextLen, sizeof(uint8_t));
assert(plaintext);

int err =
  ece_webpush_aes128gcm_decrypt(rawSubPrivKey, ECE_WEBPUSH_PRIVATE_KEY_LENGTH,
                                authSecret, ECE_WEBPUSH_AUTH_SECRET_LENGTH,
                                payload, payloadLen, plaintext, &plaintextLen);
assert(err == ECE_OK);

// `plaintext[0..plaintextLen]` contains the decrypted message.

free(plaintext);

aesgcm

All Web Push libraries support the "aesgcm" scheme, as well as Firefox 46+ and Chrome 50+. The app server includes its public key in the Crypto-Key HTTP header, the salt and record size in the Encryption header, and the encrypted payload in the body of the POST request.

  • The Crypto-Key header comprises one or more comma-delimited parameters. The first parameter must include a dh name-value pair, containing the sender's Base64url-encoded public key.
  • The Encryption header must include a salt name-value pair containing the sender's Base64url-encoded salt, and an optional rs pair specifying the record size.

If the Crypto-Key header contains multiple keys, the sender must also include a keyid to match the encryption parameters to the key. The drafts have examples for a single key without a keyid, and multiple keys with keyids.

Encryption

#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include <ece.h>

int
main() {
  const char* endpoint = "https://updates.push.services.mozilla.com/...";
  const char* p256dh = "BDwwYm4O5dZG9SO6Vaz168iDLGWMmitkj5LFvunvMfgmI2fZdAEaiHT"
                       "DfKR0fvr0D3V56cSGSeUwP0xNdrXho5k";
  const char* auth = "xcmQLthL5H2pJNuxrZO-qQ";

  const void* plaintext = "I'm just like my country, I'm young, scrappy, and "
                          "hungry, and I'm not throwing away my shot.";
  size_t plaintextLen = strlen(plaintext);

  size_t padLen = 0;

  uint8_t rawRecvPubKey[ECE_WEBPUSH_PUBLIC_KEY_LENGTH];
  size_t rawRecvPubKeyLen =
    ece_base64url_decode(p256dh, strlen(p256dh), ECE_BASE64URL_REJECT_PADDING,
                         rawRecvPubKey, ECE_WEBPUSH_PUBLIC_KEY_LENGTH);
  assert(rawRecvPubKeyLen > 0);
  uint8_t authSecret[ECE_WEBPUSH_AUTH_SECRET_LENGTH];
  size_t authSecretLen =
    ece_base64url_decode(auth, strlen(auth), ECE_BASE64URL_REJECT_PADDING,
                         authSecret, ECE_WEBPUSH_AUTH_SECRET_LENGTH);
  assert(authSecretLen > 0);

  size_t ciphertextLen = ece_aesgcm_ciphertext_max_length(
    ECE_WEBPUSH_DEFAULT_RS, padLen, plaintextLen);
  assert(ciphertextLen > 0);
  uint8_t* ciphertext = calloc(ciphertextLen, sizeof(uint8_t));
  assert(ciphertext);

  // Encrypt the plaintext and fetch encryption parameters for the headers.
  // `salt` holds the encryption salt, which we include in the `Encryption`
  // header. `rawSenderPubKey` holds the ephemeral sender, or app server,
  // public key, which we include as the `dh` parameter in the `Crypto-Key`
  // header. `ciphertextLen` is an in-out parameter set to the actual ciphertext
  // length.
  uint8_t salt[ECE_SALT_LENGTH];
  uint8_t rawSenderPubKey[ECE_WEBPUSH_PUBLIC_KEY_LENGTH];
  int err = ece_webpush_aesgcm_encrypt(
    rawRecvPubKey, rawRecvPubKeyLen, authSecret, authSecretLen,
    ECE_WEBPUSH_DEFAULT_RS, padLen, plaintext, plaintextLen, salt,
    ECE_SALT_LENGTH, rawSenderPubKey, ECE_WEBPUSH_PUBLIC_KEY_LENGTH, ciphertext,
    &ciphertextLen);
  assert(err == ECE_OK);

  // Build the `Crypto-Key` and `Encryption` HTTP headers. First, we pass
  // `NULL`s for `cryptoKeyHeader` and `encryptionHeader`, and 0 for their
  // lengths, to calculate the lengths of the buffers we need. Then, we
  // allocate, write out, and null-terminate the headers.
  size_t cryptoKeyHeaderLen = 0;
  size_t encryptionHeaderLen = 0;
  err = ece_webpush_aesgcm_headers_from_params(
    salt, ECE_SALT_LENGTH, rawSenderPubKey, ECE_WEBPUSH_PUBLIC_KEY_LENGTH,
    ECE_WEBPUSH_DEFAULT_RS, NULL, &cryptoKeyHeaderLen, NULL,
    &encryptionHeaderLen);
  assert(err == ECE_OK);
  // Allocate an extra byte for the null terminator.
  char* cryptoKeyHeader = calloc(cryptoKeyHeaderLen + 1, 1);
  assert(cryptoKeyHeader);
  char* encryptionHeader = calloc(encryptionHeaderLen + 1, 1);
  assert(encryptionHeader);
  err = ece_webpush_aesgcm_headers_from_params(
    salt, ECE_SALT_LENGTH, rawSenderPubKey, ECE_WEBPUSH_PUBLIC_KEY_LENGTH,
    ECE_WEBPUSH_DEFAULT_RS, cryptoKeyHeader, &cryptoKeyHeaderLen,
    encryptionHeader, &encryptionHeaderLen);
  assert(err == ECE_OK);
  cryptoKeyHeader[cryptoKeyHeaderLen] = '\0';
  encryptionHeader[encryptionHeaderLen] = '\0';

  const char* filename = "aesgcm.bin";
  FILE* ciphertextFile = fopen(filename, "wb");
  assert(ciphertextFile);
  size_t ciphertextFileLen =
    fwrite(ciphertext, sizeof(uint8_t), ciphertextLen, ciphertextFile);
  assert(ciphertextLen == ciphertextFileLen);
  fclose(ciphertextFile);

  printf("curl -v -X POST -H \"Content-Encoding: aesgcm\" -H \"Crypto-Key: "
         "%s\" -H \"Encryption: %s\" --data-binary @%s %s\n",
         cryptoKeyHeader, encryptionHeader, filename, endpoint);

  free(ciphertext);
  free(cryptoKeyHeader);
  free(encryptionHeader);

  return 0;
}

Decryption

uint8_t rawSubPrivKey[ECE_WEBPUSH_PRIVATE_KEY_LENGTH] = {0};
uint8_t authSecret[ECE_WEBPUSH_AUTH_SECRET_LENGTH] = {0};

const char* cryptoKeyHeader = "dh=...";
const char* encryptionHeader = "salt=...; rs=...";

uint8_t* ciphertext = NULL;
size_t ciphertextLen = 0;

uint8_t salt[ECE_SALT_LENGTH];
uint8_t rawSenderPubKey[ECE_WEBPUSH_PUBLIC_KEY_LENGTH];
uint32_t rs = 0;
int err =
  ece_webpush_aesgcm_headers_extract_params(cryptoKeyHeader, encryptionHeader,
                                            salt, ECE_SALT_LENGTH,
                                            rawSenderPubKey,
                                            ECE_WEBPUSH_PUBLIC_KEY_LENGTH, &rs);
assert(err == ECE_OK);

size_t plaintextLen = ece_aesgcm_plaintext_max_length(rs, ciphertextLen);
assert(plaintextLen > 0);
uint8_t* plaintext = calloc(plaintextLen, sizeof(uint8_t));
assert(plaintext);

err = ece_webpush_aesgcm_decrypt(
  rawSubPrivKey, ECE_WEBPUSH_PRIVATE_KEY_LENGTH, authSecret,
  ECE_WEBPUSH_AUTH_SECRET_LENGTH, salt, ECE_SALT_LENGTH, rawSenderPubKey,
  ECE_WEBPUSH_PUBLIC_KEY_LENGTH, rs, ciphertext, ciphertextLen, plaintext,
  &plaintextLen);
assert(err == ECE_OK);

// `plaintext[0..plaintextLen]` contains the decrypted message.

free(plaintext);

Building

Dependencies

macOS and *nix

OpenSSL 1.1.0 is new, and backward-incompatible with 1.0.x. If your package manager (MacPorts, Homebrew, APT, DNF, yum) doesn't have 1.1.0 yet, you'll need to compile it yourself. ecec does this to run its tests on Travis CI; please see .travis/install.sh for the commands.

In particular, you'll need to set the OPENSSL_ROOT_DIR cache entry for CMake to find your compiled version. To build the library:

> mkdir build
> cd build
> cmake -DOPENSSL_ROOT_DIR=/usr/local ..
> make

To build the decryption tool:

> make ece-decrypt
> ./ece-decrypt

To run the tests:

> make check

Windows

Shining Light provides OpenSSL binaries for Windows. The installer will ask if you want to copy the OpenSSL DLLs into the system directory, or the OpenSSL binaries directory. If you choose the binaries directory, you'll need to add it to your Path.

To do so, right-click the Start button, navigate to "System" > "Advanced system settings" > "Environment Variables...", find Path under "System variables", click "Edit" > "New", and enter the directory name. This will be C:\OpenSSL-Win64\bin if you've installed the 64-bit version in the default location.

You can then build the library like so:

> mkdir build
> cd build
> cmake -G "Visual Studio 14 2015 Win64" -DOPENSSL_ROOT_DIR=C:\OpenSSL-Win64 ..
> cmake --build . [--config Debug|Release]

To build the decryption tool:

> cmake --build . --target ece-decrypt [--config Debug|Release]
> .\[Debug|Release]\ece-decrypt

To run the tests:

> cmake --build . --target check [--config Debug|Release]

What is encrypted content-coding?

Like TLS, encrypted content-coding uses Diffie-Hellman key exchange to derive a shared secret, which, in turn, is used to derive a symmetric encryption key for a block cipher. This encoding uses ECDH for key exchange, and AES GCM for the block cipher.

Key exchange is a process where a sender and a receiver generate public-private key pairs, then exchange public keys. The sender combines the receiver's public key with its own private key to obtain a secret. Meanwhile, the receiver combines the sender's public key with its private key to obtain the same secret. Wikipedia has a good visual explanation.

The shared ECDH secret isn't directly usable as an encryption key. Instead, both the sender and receiver combine the shared ECDH secret with an authentication secret, to produce a 32-byte pseudorandom key (PRK). The auth secret is a random 16-byte array generated by the receiver, and shared with the sender along with the receiver's public key. Both parties use HKDF to derive the PRK from the ECDH secret, using the formula PRK = HKDF-Expand(HKDF-Extract(authSecret, sharedSecret), prkInfo, 32). RFC 5869 describes the inputs to HKDF-Expand and HKDF-Extract, and how they work. prkInfo is different depending on the encryption scheme used; more on that later.

Next, the sender and receiver combine the PRK with a random 16-byte salt. The salt is generated by the sender, and shared with the receiver as part of the message payload. The PRK undergoes two rounds of HKDF to derive the symmetric key and nonce: key = HKDF-Expand(HKDF-Extract(salt, PRK), keyInfo, 16), and nonce = HKDF-Expand(HKDF-Extract(salt, PRK), nonceInfo, 12). As with prkInfo above, keyInfo and nonceInfo are different depending on the exact scheme.

Finally, the sender chunks the plaintext into fixed-size records, and includes this size in the message payload as the rs. The chunks are numbered 0 to N; this is called the sequence number (SEQ), and is used to derive the IV. All chunks should be rs bytes long, but the final chunk can be smaller if needed.

Each plaintext chunk is padded, then encrypted with AES using the 16-byte symmetric key and a 12-byte IV. The IV is generated from the nonce by XOR-ing the last 6 bytes of the 12-byte nonce with the sequence number. Afterward, the sender appends the GCM authentication tag to the encrypted chunk, producing the final encrypted record.

To decrypt the message, the receiver chunks the ciphertext into N encrypted records, decrypts each chunk, validates the auth tag, and removes the padding.

Web Push

In Web Push, the app server is the sender, and the browser ("user agent") is the receiver. The browser generates a public-private ECDH key pair and 16-byte auth secret for each push subscription. These keys are static; they're used to decrypt all messages sent to this subscription. The browser exposes the subscription endpoint, public key, and auth secret to the web app via the Push DOM API. The web app then delivers the endpoint and keys to the app server.

When the app server wants to send a push message, it generates its own public-private key pair, and computes the shared ECDH secret using the subscription public key. This key pair is ephemeral: it should be discarded after the message is sent, and a new key pair used for the next message. The app server encrypts the payload using the process outlined above, and includes the salt, sender public key, and ciphertext in a POST request to the endpoint. The push endpoint relays the encrypted payload to the browser. Finally, the browser decrypts the payload with the subscription private key, and delivers the plaintext to the web app. Because the endpoint doesn't know the private key, it can't decrypt or tamper with the message.

aes128gcm

  • prkInfo is the string "WebPush: info\0", followed by the receiver and sender public keys in uncompressed form. Unlike aesgcm, these are not length-prefixed.
  • keyInfo is the static string "Content-Encoding: aes128gcm\0".
  • nonceInfo is the static string "Content-Encoding: nonce\0".
  • Padding is at the end of each plaintext chunk. The padding block comprises the delimiter, which is 0x02 for the last chunk, and 0x01 for the other chunks. Up to rs - 16 bytes of 0x0 padding can follow the delimiter.

aesgcm

  • prkInfo is the static string "Content-Encoding: auth\0".
  • keyInfo is "Content-Encoding: aesgcm\0P-256\0", followed by the length-prefixed (unsigned 16-bit integers) receiver and sender public keys in uncompressed form.
  • nonceInfo is "Content-Encoding: nonce\0P-256\0", followed by the length-prefixed public keys in the same form as keyInfo.
  • Padding is at the beginning of each plaintext chunk. The padding block comprises the number (unsigned 16-bit integer) of padding bytes, followed by that many 0x0-valued bytes.

License

MIT.