Skip to content

Commit b2db365

Browse files
committed
initial working implementation with tests from RFC example
1 parent 4c3cec1 commit b2db365

13 files changed

+543
-17
lines changed

CHANGELOG.md

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
## 0.1.0
2+
3+
* initial release
4+
* basic aes128gcm functionality
5+
* supports browsers, desktop and mobile:
6+
* apple safari
7+
* google chrome
8+
* mozilla firefox

README.md

+99-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# WebPush
22

3-
**TODO: Add description**
3+
This library implements RFC 8291 Message Encryption for Web Push.
4+
5+
It generates request details but does not make the HTTP POST request itself. The
6+
generated details should be enough to feed to your HTTP client of choice.
47

58
## Installation
69

@@ -15,6 +18,101 @@ def deps do
1518
end
1619
```
1720

21+
## Usage
22+
23+
`serviceWorker.js` example (abridged):
24+
25+
```javascript
26+
self.addEventListener('push', function(event) {
27+
event.waitUntil(
28+
self.clients.matchAll().then(function(_clientList) {
29+
const payload = event.data.json();
30+
return self.registration.showNotification(payload.title, { body: payload.body, });
31+
})
32+
);
33+
});
34+
```
35+
36+
`notify.js` example:
37+
38+
```javascript
39+
function postSubscription(subscription) {
40+
fetch("https://app.example.com/subscriptions", {
41+
body: JSON.stringify({ subscription }),
42+
method: "POST",
43+
headers: {
44+
"content-type": "application/json",
45+
},
46+
});
47+
}
48+
49+
document.getElementById("#request-permission").addEventListener("click", (_event) => {
50+
Notification.requestPermission()
51+
.then((permission) => {
52+
if (permission === "granted") {
53+
navigator.serviceWorker
54+
.register("https://app.example.com/serviceWorker.js", {scope: "https://app.example.com"})
55+
.then((registration) => {
56+
registration.pushManager.getSubscription()
57+
.then((subscription) => {
58+
if (subscription) {
59+
postSubscription(subscription);
60+
} else {
61+
document.getElementById("#subscribe").addEventListener("click", (_event) => {
62+
const subscribeOpts = {
63+
userVisibleOnly: true,
64+
applicationServerKey: "VAPID public key",
65+
};
66+
67+
registration.pushManager.subscribe(subscribeOpts)
68+
.then(postSubscription);
69+
});
70+
}
71+
});
72+
.catch(console.error);
73+
74+
})
75+
.catch(console.error);
76+
}
77+
})
78+
.catch(console.error);
79+
});
80+
```
81+
82+
83+
```elixir
84+
# create the struct from the subscription JSON data
85+
subscription =
86+
WebPush.Subscription.from_json("""
87+
{"endpoint":"https://push.example.com/123","keys":{"p256dh":"user_agent_public_key","auth":"auth_secret"}}
88+
""")
89+
90+
# structured message, see example serviceWorker.js above
91+
message = %{title: "Notification Title", body: "lorem ipsum etc"}
92+
93+
# generate request details
94+
%WebPush.Request{} = request = WebPush.request(subscription, :json.encode(message))
95+
96+
request.endpoint
97+
# => "https://push.example.com/123"
98+
99+
request.body
100+
# => binary data
101+
102+
request.headers
103+
# => %{
104+
# "Authorization" => "vapid t=..., k=...",
105+
# "Content-Encoding" => "aes128gcm",
106+
# "Content-Length" => "42",
107+
# "Content-Type" => "application/octet-stream",
108+
# "TTL" => "43200",
109+
# "Urgency" => "normal"
110+
# }
111+
112+
# send web push notification via http client e.g. tesla
113+
Tesla.post(request.endpoint, request.body, headers: Map.to_list(request.headers))
114+
```
115+
18116
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
19117
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
20118
be found at <https://hexdocs.pm/web_push>.

config/config.exs

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import Config
2+
3+
import_config "#{config_env()}.exs"

config/dev.exs

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import Config
2+
3+
# use OTP :json for development
4+
config :jose, :json_module, WebPush.JOSEjson
5+
6+
config :web_push, :vapid,
7+
public_key: "",
8+
private_key: "",
9+
subject: "[email protected]"

config/test.exs

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import Config
2+
3+
# use OTP :json for testing
4+
config :jose, :json_module, WebPush.JOSEjson
5+
6+
config :web_push, :vapid,
7+
public_key: "",
8+
private_key: "",
9+
subject: "[email protected]"

lib/web_push.ex

+191-7
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,202 @@
11
defmodule WebPush do
22
@moduledoc """
3-
Documentation for `WebPush`.
3+
Web Push notifications.
4+
5+
Implementation of RFC 8291, 8188, & 5689.
46
"""
57

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+
622
@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)
830

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
1044

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
1369

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
1474
"""
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()
17201
end
18202
end

lib/web_push/jose_json.ex

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
defmodule WebPush.JOSEjson do
2+
@moduledoc """
3+
:json wrapper for JOSE.
4+
"""
5+
6+
@behaviour :jose_json
7+
8+
@impl true
9+
def decode(binary), do: :json.decode(binary)
10+
11+
@impl true
12+
def encode(term) do
13+
term
14+
|> :json.encode()
15+
|> :erlang.iolist_to_binary()
16+
end
17+
end

0 commit comments

Comments
 (0)