|
1 | 1 | defmodule WebPush do
|
2 | 2 | @moduledoc """
|
3 |
| - Documentation for `WebPush`. |
| 3 | + Web Push notifications. |
| 4 | +
|
| 5 | + Implementation of RFC 8291, 8188, & 5689. |
4 | 6 | """
|
5 | 7 |
|
| 8 | + alias WebPush.{Request, Subscription} |
| 9 | + |
| 10 | + @base64_options [padding: false] |
| 11 | + @default_exp 12 * 60 * 60 |
| 12 | + @jwt_header %{"typ" => "JWT", "alg" => "ES256"} |
| 13 | + |
| 14 | + @type info :: binary() |
| 15 | + @type input_keying_material :: binary() |
| 16 | + @type length :: non_neg_integer() |
| 17 | + @type psuedo_random_key :: binary() |
| 18 | + @type salt :: binary() |
| 19 | + @type key :: binary() |
| 20 | + @type vapid_key :: :private_key | :public_key | :subject |
| 21 | + |
6 | 22 | @doc """
|
7 |
| - Hello world. |
| 23 | + Build and return a Request struct with details ready for sending. |
| 24 | + """ |
| 25 | + @spec request(Subscription.t(), String.t(), keyword()) :: Request.t() |
| 26 | + def request(%Subscription{} = subscription, message, options \\ []) do |
| 27 | + body = encrypt_payload(message, subscription, options) |
| 28 | + jwt = sign_jwt(subscription) |
| 29 | + vapid_public_key = fetch_vapid!(:public_key, false) |
8 | 30 |
|
9 |
| - ## Examples |
| 31 | + %Request{ |
| 32 | + body: body, |
| 33 | + endpoint: subscription.endpoint, |
| 34 | + headers: %{ |
| 35 | + "Authorization" => "vapid t=#{jwt}, k=#{vapid_public_key}", |
| 36 | + "Content-Encoding" => "aes128gcm", |
| 37 | + "Content-Length" => "#{byte_size(body)}", |
| 38 | + "Content-Type" => "application/octet-stream", |
| 39 | + "TTL" => to_string(@default_exp), |
| 40 | + "Urgency" => "normal" |
| 41 | + } |
| 42 | + } |
| 43 | + end |
10 | 44 |
|
11 |
| - iex> WebPush.hello() |
12 |
| - :world |
| 45 | + @spec sign_jwt(Subscription.t()) :: binary() |
| 46 | + defp sign_jwt(%Subscription{endpoint: %URI{} = endpoint}) do |
| 47 | + aud = %URI{scheme: endpoint.scheme, host: endpoint.host} |> URI.to_string() |
| 48 | + |
| 49 | + jwt = |
| 50 | + JOSE.JWT.from_map(%{aud: aud, exp: twelve_hours_from_now(), sub: fetch_vapid!(:subject)}) |
| 51 | + |
| 52 | + jwk = |
| 53 | + JOSE.JWK.from_key({ |
| 54 | + :ECPrivateKey, |
| 55 | + 1, |
| 56 | + fetch_vapid!(:private_key), |
| 57 | + {:namedCurve, {1, 2, 840, 10045, 3, 1, 7}}, |
| 58 | + fetch_vapid!(:public_key), |
| 59 | + nil |
| 60 | + }) |
| 61 | + |
| 62 | + {%{alg: :jose_jws_alg_ecdsa}, signed_jwt} = |
| 63 | + jwk |
| 64 | + |> JOSE.JWT.sign(@jwt_header, jwt) |
| 65 | + |> JOSE.JWS.compact() |
| 66 | + |
| 67 | + signed_jwt |
| 68 | + end |
13 | 69 |
|
| 70 | + @doc """ |
| 71 | + Encrypt a message according to RFCs 8291, 8188, 5869. |
| 72 | +
|
| 73 | + see: https://datatracker.ietf.org/doc/html/rfc8291/#section-3.1 |
14 | 74 | """
|
15 |
| - def hello do |
16 |
| - :world |
| 75 | + @spec encrypt_payload(String.t(), Subscription.t(), keyword()) :: binary() |
| 76 | + def encrypt_payload(payload, %Subscription{} = subscription, options \\ []) do |
| 77 | + # When sending a push message, the application server also generates a |
| 78 | + # new ECDH key pair on the same P-256 curve. |
| 79 | + {as_public, as_private} = |
| 80 | + options[:as_key_pair] || :crypto.generate_key(:ecdh, :prime256v1) |
| 81 | + |
| 82 | + # An application server combines its ECDH private key with the public |
| 83 | + # key provided by the user agent using the process described in [ECDH] |
| 84 | + # |
| 85 | + # ecdh_secret = ECDH(as_private, ua_public) |
| 86 | + # |
| 87 | + ua_public = decode(subscription.keys.p256dh) |
| 88 | + ecdh_secret = :crypto.compute_key(:ecdh, ua_public, as_private, :prime256v1) |
| 89 | + |
| 90 | + # Use HKDF to combine the ECDH and authentication secrets |
| 91 | + |
| 92 | + # key_info = "WebPush: info" || 0x00 || ua_public || as_public |
| 93 | + info = "WebPush: info" <> <<0>> <> ua_public <> as_public |
| 94 | + |
| 95 | + # # HKDF-Extract(salt=auth_secret, IKM=ecdh_secret) |
| 96 | + # PRK_key = HMAC-SHA-256(auth_secret, ecdh_secret) |
| 97 | + # # HKDF-Expand(PRK_key, key_info, L_key=32) |
| 98 | + # IKM = HMAC-SHA-256(PRK_key, key_info || 0x01) |
| 99 | + ikm = |
| 100 | + subscription.keys.auth |
| 101 | + |> decode() |
| 102 | + |> hkdf_extract(ecdh_secret) |
| 103 | + |> hkdf_expand(info, 32) |
| 104 | + |
| 105 | + # salt = random(16) |
| 106 | + salt = options[:salt] || :crypto.strong_rand_bytes(16) |
| 107 | + |
| 108 | + # ## HKDF calculations from RFC 8188 |
| 109 | + # # HKDF-Extract(salt, IKM) |
| 110 | + # PRK = HMAC-SHA-256(salt, IKM) |
| 111 | + prk = hkdf_extract(salt, ikm) |
| 112 | + |
| 113 | + # # HKDF-Expand(PRK, cek_info, L_cek=16) |
| 114 | + # cek_info = "Content-Encoding: aes128gcm" || 0x00 |
| 115 | + cek_info = "Content-Encoding: aes128gcm" <> <<0>> |
| 116 | + |
| 117 | + # CEK = HMAC-SHA-256(PRK, cek_info || 0x01)[0..15] |
| 118 | + cek = hkdf_expand(prk, cek_info, 16) |
| 119 | + |
| 120 | + # # HKDF-Expand(PRK, nonce_info, L_nonce=12) |
| 121 | + # nonce_info = "Content-Encoding: nonce" || 0x00 |
| 122 | + nonce_info = "Content-Encoding: nonce" <> <<0>> |
| 123 | + |
| 124 | + # NONCE = HMAC-SHA-256(PRK, nonce_info || 0x01)[0..11] |
| 125 | + nonce = hkdf_expand(prk, nonce_info, 12) |
| 126 | + |
| 127 | + # +-----------+--------+-----------+---------------+ |
| 128 | + # | salt (16) | rs (4) | idlen (1) | keyid (idlen) | |
| 129 | + # +-----------+--------+-----------+---------------+ |
| 130 | + header = |
| 131 | + salt <> |
| 132 | + <<4096::unsigned-big-integer-size(32)>> <> |
| 133 | + <<byte_size(as_public)>> <> |
| 134 | + as_public |
| 135 | + |
| 136 | + {out_crypto_text, out_cypto_tag} = |
| 137 | + :crypto.crypto_one_time_aead( |
| 138 | + :aes_128_gcm, |
| 139 | + cek, |
| 140 | + nonce, |
| 141 | + # Each record contains a single padding delimiter octet followed by any |
| 142 | + # number of zero octets. The last record uses a padding delimiter |
| 143 | + # octet set to the value 2, all other records have a padding delimiter |
| 144 | + # octet value of 1. |
| 145 | + payload <> <<2>>, |
| 146 | + # The additional data passed to each invocation of AEAD_AES_128_GCM is |
| 147 | + # a zero-length octet sequence. |
| 148 | + <<>>, |
| 149 | + true |
| 150 | + ) |
| 151 | + |
| 152 | + header <> out_crypto_text <> out_cypto_tag |
| 153 | + end |
| 154 | + |
| 155 | + @doc """ |
| 156 | + URL-safe Base64 encode a binary with @base64_options (padding: false). |
| 157 | + """ |
| 158 | + @spec encode(binary()) :: String.t() |
| 159 | + def encode(binary), do: Base.url_encode64(binary, @base64_options) |
| 160 | + |
| 161 | + @doc """ |
| 162 | + Decode a URL-safe Base64-encoded string with @base64_options (padding: false). |
| 163 | + """ |
| 164 | + @spec decode(String.t()) :: binary() |
| 165 | + def decode(string), do: Base.url_decode64!(string, @base64_options) |
| 166 | + |
| 167 | + @spec hkdf_extract(salt(), input_keying_material()) :: psuedo_random_key() |
| 168 | + defp hkdf_extract(salt, input_keying_material) do |
| 169 | + :crypto.mac_init(:hmac, :sha256, salt) |
| 170 | + |> :crypto.mac_update(input_keying_material) |
| 171 | + |> :crypto.mac_final() |
| 172 | + end |
| 173 | + |
| 174 | + @spec hkdf_expand(psuedo_random_key(), info(), length()) :: key() |
| 175 | + defp hkdf_expand(prk, info, length) do |
| 176 | + :crypto.mac_init(:hmac, :sha256, prk) |
| 177 | + |> :crypto.mac_update(info) |
| 178 | + |> :crypto.mac_update(<<1>>) |
| 179 | + |> :crypto.mac_final() |
| 180 | + |> :binary.part(0, length) |
| 181 | + end |
| 182 | + |
| 183 | + @spec fetch_vapid!(vapid_key(), boolean()) :: String.t() |
| 184 | + defp fetch_vapid!(key, decode \\ true) do |
| 185 | + :web_push |
| 186 | + |> Application.fetch_env!(:vapid) |
| 187 | + |> Keyword.fetch!(key) |
| 188 | + |> decode_if(key, decode) |
| 189 | + end |
| 190 | + |
| 191 | + @spec decode_if(String.t(), vapid_key(), boolean()) :: binary() | String.t() |
| 192 | + defp decode_if(value, :subject, _), do: value |
| 193 | + defp decode_if(value, _key, false), do: value |
| 194 | + defp decode_if(value, _, _), do: decode(value) |
| 195 | + |
| 196 | + @spec twelve_hours_from_now() :: integer() |
| 197 | + defp twelve_hours_from_now do |
| 198 | + DateTime.utc_now() |
| 199 | + |> DateTime.add(12, :hour) |
| 200 | + |> DateTime.to_unix() |
17 | 201 | end
|
18 | 202 | end
|
0 commit comments