web-push 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.rspec +2 -0
- data/.rubocop.yml +30 -0
- data/.travis.yml +24 -0
- data/CHANGELOG.md +183 -0
- data/Gemfile +4 -0
- data/LICENSE +21 -0
- data/README.md +356 -0
- data/Rakefile +8 -0
- data/bin/console +14 -0
- data/bin/rake +16 -0
- data/bin/rspec +16 -0
- data/bin/setup +8 -0
- data/lib/tasks/web_push.rake +14 -0
- data/lib/web-push.rb +3 -0
- data/lib/web_push/encryption.rb +77 -0
- data/lib/web_push/errors.rb +29 -0
- data/lib/web_push/railtie.rb +9 -0
- data/lib/web_push/request.rb +189 -0
- data/lib/web_push/vapid_key.rb +105 -0
- data/lib/web_push/version.rb +5 -0
- data/lib/web_push.rb +80 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/web_push/encryption_spec.rb +95 -0
- data/spec/web_push/request_spec.rb +162 -0
- data/spec/web_push/vapid_key_spec.rb +66 -0
- data/spec/web_push_spec.rb +253 -0
- data/web-push.gemspec +25 -0
- metadata +183 -0
data/bin/rake
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
#
|
3
|
+
# This file was generated by Bundler.
|
4
|
+
#
|
5
|
+
# The application 'rake' is installed as part of a gem, and
|
6
|
+
# this file is here to facilitate running it.
|
7
|
+
#
|
8
|
+
|
9
|
+
require "pathname"
|
10
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
|
11
|
+
Pathname.new(__FILE__).realpath)
|
12
|
+
|
13
|
+
require "rubygems"
|
14
|
+
require "bundler/setup"
|
15
|
+
|
16
|
+
load Gem.bin_path("rake", "rake")
|
data/bin/rspec
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
#
|
3
|
+
# This file was generated by Bundler.
|
4
|
+
#
|
5
|
+
# The application 'rspec' is installed as part of a gem, and
|
6
|
+
# this file is here to facilitate running it.
|
7
|
+
#
|
8
|
+
|
9
|
+
require "pathname"
|
10
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
|
11
|
+
Pathname.new(__FILE__).realpath)
|
12
|
+
|
13
|
+
require "rubygems"
|
14
|
+
require "bundler/setup"
|
15
|
+
|
16
|
+
load Gem.bin_path("rspec-core", "rspec")
|
data/bin/setup
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
namespace :web_push do
|
2
|
+
desc 'Generate VAPID public/private key pair'
|
3
|
+
task :generate_keys do
|
4
|
+
require 'web_push'
|
5
|
+
|
6
|
+
WebPush.generate_key.tap do |keypair|
|
7
|
+
puts <<-KEYS
|
8
|
+
Generated VAPID keypair:
|
9
|
+
Public -> #{keypair.public_key}
|
10
|
+
Private -> #{keypair.private_key}
|
11
|
+
KEYS
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
data/lib/web-push.rb
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WebPush
|
4
|
+
module Encryption
|
5
|
+
extend self
|
6
|
+
|
7
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
8
|
+
def encrypt(message, p256dh, auth)
|
9
|
+
assert_arguments(message, p256dh, auth)
|
10
|
+
|
11
|
+
group_name = 'prime256v1'
|
12
|
+
salt = Random.new.bytes(16)
|
13
|
+
|
14
|
+
server = OpenSSL::PKey::EC.new(group_name)
|
15
|
+
server.generate_key
|
16
|
+
server_public_key_bn = server.public_key.to_bn
|
17
|
+
|
18
|
+
group = OpenSSL::PKey::EC::Group.new(group_name)
|
19
|
+
client_public_key_bn = OpenSSL::BN.new(WebPush.decode64(p256dh), 2)
|
20
|
+
client_public_key = OpenSSL::PKey::EC::Point.new(group, client_public_key_bn)
|
21
|
+
|
22
|
+
shared_secret = server.dh_compute_key(client_public_key)
|
23
|
+
|
24
|
+
client_auth_token = WebPush.decode64(auth)
|
25
|
+
|
26
|
+
info = "WebPush: info\0" + client_public_key_bn.to_s(2) + server_public_key_bn.to_s(2)
|
27
|
+
content_encryption_key_info = "Content-Encoding: aes128gcm\0"
|
28
|
+
nonce_info = "Content-Encoding: nonce\0"
|
29
|
+
|
30
|
+
prk = HKDF.new(shared_secret, salt: client_auth_token, algorithm: 'SHA256', info: info).next_bytes(32)
|
31
|
+
|
32
|
+
content_encryption_key = HKDF.new(prk, salt: salt, info: content_encryption_key_info).next_bytes(16)
|
33
|
+
|
34
|
+
nonce = HKDF.new(prk, salt: salt, info: nonce_info).next_bytes(12)
|
35
|
+
|
36
|
+
ciphertext = encrypt_payload(message, content_encryption_key, nonce)
|
37
|
+
|
38
|
+
serverkey16bn = convert16bit(server_public_key_bn)
|
39
|
+
rs = ciphertext.bytesize
|
40
|
+
raise ArgumentError, "encrypted payload is too big" if rs > 4096
|
41
|
+
|
42
|
+
aes128gcmheader = "#{salt}" + [rs].pack('N*') + [serverkey16bn.bytesize].pack('C*') + serverkey16bn
|
43
|
+
|
44
|
+
aes128gcmheader + ciphertext
|
45
|
+
end
|
46
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def encrypt_payload(plaintext, content_encryption_key, nonce)
|
51
|
+
cipher = OpenSSL::Cipher.new('aes-128-gcm')
|
52
|
+
cipher.encrypt
|
53
|
+
cipher.key = content_encryption_key
|
54
|
+
cipher.iv = nonce
|
55
|
+
text = cipher.update(plaintext)
|
56
|
+
padding = cipher.update("\2\0")
|
57
|
+
e_text = text + padding + cipher.final
|
58
|
+
e_tag = cipher.auth_tag
|
59
|
+
|
60
|
+
e_text + e_tag
|
61
|
+
end
|
62
|
+
|
63
|
+
def convert16bit(key)
|
64
|
+
[key.to_s(16)].pack('H*')
|
65
|
+
end
|
66
|
+
|
67
|
+
def assert_arguments(message, p256dh, auth)
|
68
|
+
raise ArgumentError, 'message cannot be blank' if blank?(message)
|
69
|
+
raise ArgumentError, 'p256dh cannot be blank' if blank?(p256dh)
|
70
|
+
raise ArgumentError, 'auth cannot be blank' if blank?(auth)
|
71
|
+
end
|
72
|
+
|
73
|
+
def blank?(value)
|
74
|
+
value.nil? || value.empty?
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WebPush
|
4
|
+
class Error < RuntimeError; end
|
5
|
+
|
6
|
+
class ConfigurationError < Error; end
|
7
|
+
|
8
|
+
class ResponseError < Error
|
9
|
+
attr_reader :response, :host
|
10
|
+
|
11
|
+
def initialize(response, host)
|
12
|
+
@response = response
|
13
|
+
@host = host
|
14
|
+
super "host: #{host}, #{@response.inspect}\nbody:\n#{@response.body}"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
class InvalidSubscription < ResponseError; end
|
19
|
+
|
20
|
+
class ExpiredSubscription < ResponseError; end
|
21
|
+
|
22
|
+
class Unauthorized < ResponseError; end
|
23
|
+
|
24
|
+
class PayloadTooLarge < ResponseError; end
|
25
|
+
|
26
|
+
class TooManyRequests < ResponseError; end
|
27
|
+
|
28
|
+
class PushServiceError < ResponseError; end
|
29
|
+
end
|
@@ -0,0 +1,189 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'uri'
|
4
|
+
require 'jwt'
|
5
|
+
require 'base64'
|
6
|
+
|
7
|
+
module WebPush
|
8
|
+
# It is temporary URL until supported by the GCM server.
|
9
|
+
GCM_URL = 'https://android.googleapis.com/gcm/send'.freeze
|
10
|
+
TEMP_GCM_URL = 'https://fcm.googleapis.com/fcm'.freeze
|
11
|
+
|
12
|
+
# rubocop:disable Metrics/ClassLength
|
13
|
+
class Request
|
14
|
+
def initialize(message: '', subscription:, vapid:, **options)
|
15
|
+
endpoint = subscription.fetch(:endpoint)
|
16
|
+
@endpoint = endpoint.gsub(GCM_URL, TEMP_GCM_URL)
|
17
|
+
@payload = build_payload(message, subscription)
|
18
|
+
@vapid_options = vapid
|
19
|
+
@options = default_options.merge(options)
|
20
|
+
end
|
21
|
+
|
22
|
+
# rubocop:disable Metrics/AbcSize
|
23
|
+
def perform
|
24
|
+
http = Net::HTTP.new(uri.host, uri.port, *proxy_options)
|
25
|
+
http.use_ssl = true
|
26
|
+
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
|
27
|
+
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
|
28
|
+
http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
|
29
|
+
|
30
|
+
req = Net::HTTP::Post.new(uri.request_uri, headers)
|
31
|
+
req.body = body
|
32
|
+
|
33
|
+
resp = http.request(req)
|
34
|
+
verify_response(resp)
|
35
|
+
|
36
|
+
resp
|
37
|
+
end
|
38
|
+
# rubocop:enable Metrics/AbcSize
|
39
|
+
|
40
|
+
def proxy_options
|
41
|
+
return [] unless @options[:proxy]
|
42
|
+
|
43
|
+
proxy_uri = URI.parse(@options[:proxy])
|
44
|
+
|
45
|
+
[proxy_uri.host, proxy_uri.port, proxy_uri.user, proxy_uri.password]
|
46
|
+
end
|
47
|
+
|
48
|
+
# rubocop:disable Metrics/MethodLength
|
49
|
+
def headers
|
50
|
+
headers = {}
|
51
|
+
headers['Content-Type'] = 'application/octet-stream'
|
52
|
+
headers['Ttl'] = ttl
|
53
|
+
headers['Urgency'] = urgency
|
54
|
+
|
55
|
+
if @payload
|
56
|
+
headers['Content-Encoding'] = 'aes128gcm'
|
57
|
+
headers["Content-Length"] = @payload.length.to_s
|
58
|
+
end
|
59
|
+
|
60
|
+
if api_key?
|
61
|
+
headers['Authorization'] = "key=#{api_key}"
|
62
|
+
elsif vapid?
|
63
|
+
headers["Authorization"] = build_vapid_header
|
64
|
+
end
|
65
|
+
|
66
|
+
headers
|
67
|
+
end
|
68
|
+
# rubocop:enable Metrics/MethodLength
|
69
|
+
|
70
|
+
def build_vapid_header
|
71
|
+
# https://tools.ietf.org/id/draft-ietf-webpush-vapid-03.html
|
72
|
+
|
73
|
+
vapid_key = vapid_pem ? VapidKey.from_pem(vapid_pem) : VapidKey.from_keys(vapid_public_key, vapid_private_key)
|
74
|
+
jwt = JWT.encode(jwt_payload, vapid_key.curve, 'ES256', jwt_header_fields)
|
75
|
+
p256ecdsa = vapid_key.public_key_for_push_header
|
76
|
+
|
77
|
+
"vapid t=#{jwt},k=#{p256ecdsa}"
|
78
|
+
end
|
79
|
+
|
80
|
+
def body
|
81
|
+
@payload || ''
|
82
|
+
end
|
83
|
+
|
84
|
+
private
|
85
|
+
|
86
|
+
def uri
|
87
|
+
@uri ||= URI.parse(@endpoint)
|
88
|
+
end
|
89
|
+
|
90
|
+
def ttl
|
91
|
+
@options.fetch(:ttl).to_s
|
92
|
+
end
|
93
|
+
|
94
|
+
def urgency
|
95
|
+
@options.fetch(:urgency).to_s
|
96
|
+
end
|
97
|
+
|
98
|
+
def jwt_payload
|
99
|
+
{
|
100
|
+
aud: audience,
|
101
|
+
exp: Time.now.to_i + expiration,
|
102
|
+
sub: subject
|
103
|
+
}
|
104
|
+
end
|
105
|
+
|
106
|
+
def jwt_header_fields
|
107
|
+
{ "typ": "JWT", "alg": "ES256" }
|
108
|
+
end
|
109
|
+
|
110
|
+
def audience
|
111
|
+
uri.scheme + '://' + uri.host
|
112
|
+
end
|
113
|
+
|
114
|
+
def expiration
|
115
|
+
@vapid_options.fetch(:expiration, 24 * 60 * 60)
|
116
|
+
end
|
117
|
+
|
118
|
+
def subject
|
119
|
+
@vapid_options.fetch(:subject, 'sender@example.com')
|
120
|
+
end
|
121
|
+
|
122
|
+
def vapid_public_key
|
123
|
+
@vapid_options.fetch(:public_key, nil)
|
124
|
+
end
|
125
|
+
|
126
|
+
def vapid_private_key
|
127
|
+
@vapid_options.fetch(:private_key, nil)
|
128
|
+
end
|
129
|
+
|
130
|
+
def vapid_pem
|
131
|
+
@vapid_options.fetch(:pem, nil)
|
132
|
+
end
|
133
|
+
|
134
|
+
def default_options
|
135
|
+
{
|
136
|
+
ttl: 60 * 60 * 24 * 7 * 4, # 4 weeks
|
137
|
+
urgency: 'normal'
|
138
|
+
}
|
139
|
+
end
|
140
|
+
|
141
|
+
def build_payload(message, subscription)
|
142
|
+
return nil if message.nil? || message.empty?
|
143
|
+
|
144
|
+
encrypt_payload(message, **subscription.fetch(:keys))
|
145
|
+
end
|
146
|
+
|
147
|
+
def encrypt_payload(message, p256dh:, auth:)
|
148
|
+
Encryption.encrypt(message, p256dh, auth)
|
149
|
+
end
|
150
|
+
|
151
|
+
def api_key
|
152
|
+
@options.fetch(:api_key, nil)
|
153
|
+
end
|
154
|
+
|
155
|
+
def api_key?
|
156
|
+
!(api_key.nil? || api_key.empty?) && @endpoint =~ %r{\Ahttps://(android|gcm-http|fcm)\.googleapis\.com}
|
157
|
+
end
|
158
|
+
|
159
|
+
def vapid?
|
160
|
+
@vapid_options.any?
|
161
|
+
end
|
162
|
+
|
163
|
+
def trim_encode64(bin)
|
164
|
+
WebPush.encode64(bin).delete('=')
|
165
|
+
end
|
166
|
+
|
167
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Style/GuardClause
|
168
|
+
def verify_response(resp)
|
169
|
+
if resp.is_a?(Net::HTTPGone) # 410
|
170
|
+
raise ExpiredSubscription.new(resp, uri.host)
|
171
|
+
elsif resp.is_a?(Net::HTTPNotFound) # 404
|
172
|
+
raise InvalidSubscription.new(resp, uri.host)
|
173
|
+
elsif resp.is_a?(Net::HTTPUnauthorized) || resp.is_a?(Net::HTTPForbidden) || # 401, 403
|
174
|
+
resp.is_a?(Net::HTTPBadRequest) && resp.message == 'UnauthorizedRegistration' # 400, Google FCM
|
175
|
+
raise Unauthorized.new(resp, uri.host)
|
176
|
+
elsif resp.is_a?(Net::HTTPRequestEntityTooLarge) # 413
|
177
|
+
raise PayloadTooLarge.new(resp, uri.host)
|
178
|
+
elsif resp.is_a?(Net::HTTPTooManyRequests) # 429, try again later!
|
179
|
+
raise TooManyRequests.new(resp, uri.host)
|
180
|
+
elsif resp.is_a?(Net::HTTPServerError) # 5xx
|
181
|
+
raise PushServiceError.new(resp, uri.host)
|
182
|
+
elsif !resp.is_a?(Net::HTTPSuccess) # unknown/unhandled response error
|
183
|
+
raise ResponseError.new(resp, uri.host)
|
184
|
+
end
|
185
|
+
end
|
186
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Style/GuardClause
|
187
|
+
end
|
188
|
+
# rubocop:enable Metrics/ClassLength
|
189
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WebPush
|
4
|
+
# Class for abstracting the generation and encoding of elliptic curve public and private keys for use with the VAPID protocol
|
5
|
+
#
|
6
|
+
# @attr_reader [OpenSSL::PKey::EC] :curve the OpenSSL elliptic curve instance
|
7
|
+
class VapidKey
|
8
|
+
# Create a VapidKey instance from encoded elliptic curve public and private keys
|
9
|
+
#
|
10
|
+
# @return [WebPush::VapidKey] a VapidKey instance for the given public and private keys
|
11
|
+
def self.from_keys(public_key, private_key)
|
12
|
+
key = new
|
13
|
+
key.public_key = public_key
|
14
|
+
key.private_key = private_key
|
15
|
+
|
16
|
+
key
|
17
|
+
end
|
18
|
+
|
19
|
+
# Create a VapidKey instance from pem encoded elliptic curve public and private keys
|
20
|
+
#
|
21
|
+
# @return [WebPush::VapidKey] a VapidKey instance for the given public and private keys
|
22
|
+
def self.from_pem(pem)
|
23
|
+
key = new
|
24
|
+
src = OpenSSL::PKey.read pem
|
25
|
+
key.curve.public_key = src.public_key
|
26
|
+
key.curve.private_key = src.private_key
|
27
|
+
|
28
|
+
key
|
29
|
+
end
|
30
|
+
|
31
|
+
attr_reader :curve
|
32
|
+
|
33
|
+
def initialize
|
34
|
+
@curve = OpenSSL::PKey::EC.new('prime256v1')
|
35
|
+
@curve.generate_key
|
36
|
+
end
|
37
|
+
|
38
|
+
# Retrieve the encoded elliptic curve public key for VAPID protocol
|
39
|
+
#
|
40
|
+
# @return [String] encoded binary representation of 65-byte VAPID public key
|
41
|
+
def public_key
|
42
|
+
encode64(curve.public_key.to_bn.to_s(2))
|
43
|
+
end
|
44
|
+
|
45
|
+
# Retrieve the encoded elliptic curve public key suitable for the Web Push request
|
46
|
+
#
|
47
|
+
# @return [String] the encoded VAPID public key for us in 'Encryption' header
|
48
|
+
def public_key_for_push_header
|
49
|
+
trim_encode64(curve.public_key.to_bn.to_s(2))
|
50
|
+
end
|
51
|
+
|
52
|
+
# Retrive the encoded elliptic curve private key for VAPID protocol
|
53
|
+
#
|
54
|
+
# @return [String] base64 urlsafe-encoded binary representation of 32-byte VAPID private key
|
55
|
+
def private_key
|
56
|
+
encode64(curve.private_key.to_s(2))
|
57
|
+
end
|
58
|
+
|
59
|
+
def public_key=(key)
|
60
|
+
curve.public_key = OpenSSL::PKey::EC::Point.new(group, to_big_num(key))
|
61
|
+
end
|
62
|
+
|
63
|
+
def private_key=(key)
|
64
|
+
curve.private_key = to_big_num(key)
|
65
|
+
end
|
66
|
+
|
67
|
+
def curve_name
|
68
|
+
group.curve_name
|
69
|
+
end
|
70
|
+
|
71
|
+
def group
|
72
|
+
curve.group
|
73
|
+
end
|
74
|
+
|
75
|
+
def to_h
|
76
|
+
{ public_key: public_key, private_key: private_key }
|
77
|
+
end
|
78
|
+
alias to_hash to_h
|
79
|
+
|
80
|
+
def to_pem
|
81
|
+
public_key = OpenSSL::PKey::EC.new curve
|
82
|
+
public_key.private_key = nil
|
83
|
+
|
84
|
+
curve.to_pem + public_key.to_pem
|
85
|
+
end
|
86
|
+
|
87
|
+
def inspect
|
88
|
+
"#<#{self.class}:#{object_id.to_s(16)} #{to_h.map { |k, v| ":#{k}=#{v}" }.join(' ')}>"
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
def to_big_num(key)
|
94
|
+
OpenSSL::BN.new(WebPush.decode64(key), 2)
|
95
|
+
end
|
96
|
+
|
97
|
+
def encode64(bin)
|
98
|
+
WebPush.encode64(bin)
|
99
|
+
end
|
100
|
+
|
101
|
+
def trim_encode64(bin)
|
102
|
+
encode64(bin).delete('=')
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
data/lib/web_push.rb
ADDED
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'openssl'
|
4
|
+
require 'base64'
|
5
|
+
require 'hkdf'
|
6
|
+
require 'net/http'
|
7
|
+
require 'json'
|
8
|
+
|
9
|
+
require 'web_push/version'
|
10
|
+
require 'web_push/errors'
|
11
|
+
require 'web_push/vapid_key'
|
12
|
+
require 'web_push/encryption'
|
13
|
+
require 'web_push/request'
|
14
|
+
require 'web_push/railtie' if defined?(Rails)
|
15
|
+
|
16
|
+
# Push API implementation
|
17
|
+
#
|
18
|
+
# https://tools.ietf.org/html/rfc8030
|
19
|
+
# https://www.w3.org/TR/push-api/
|
20
|
+
module WebPush
|
21
|
+
class << self
|
22
|
+
# Deliver the payload to the required endpoint given by the JavaScript
|
23
|
+
# PushSubscription. Including an optional message requires p256dh and
|
24
|
+
# auth keys from the PushSubscription.
|
25
|
+
#
|
26
|
+
# @param endpoint [String] the required PushSubscription url
|
27
|
+
# @param message [String] the optional payload
|
28
|
+
# @param p256dh [String] the user's public ECDH key given by the PushSubscription
|
29
|
+
# @param auth [String] the user's private ECDH key given by the PushSubscription
|
30
|
+
# @param vapid [Hash<Symbol,String>] options for VAPID
|
31
|
+
# @option vapid [String] :subject contact URI for the app server as a "mailto:" or an "https:"
|
32
|
+
# @option vapid [String] :public_key the VAPID public key
|
33
|
+
# @option vapid [String] :private_key the VAPID private key
|
34
|
+
# @param options [Hash<Symbol,String>] additional options for the notification
|
35
|
+
# @option options [#to_s] :ttl Time-to-live in seconds
|
36
|
+
# @option options [#to_s] :urgency Urgency can be very-low, low, normal, high
|
37
|
+
# rubocop:disable Metrics/ParameterLists
|
38
|
+
def payload_send(message: '', endpoint:, p256dh: '', auth: '', vapid: {}, **options)
|
39
|
+
WebPush::Request.new(
|
40
|
+
message: message,
|
41
|
+
subscription: subscription(endpoint, p256dh, auth),
|
42
|
+
vapid: vapid,
|
43
|
+
**options
|
44
|
+
).perform
|
45
|
+
end
|
46
|
+
# rubocop:enable Metrics/ParameterLists
|
47
|
+
|
48
|
+
# Generate a VapidKey instance to obtain base64 encoded public and private keys
|
49
|
+
# suitable for VAPID protocol JSON web token signing
|
50
|
+
#
|
51
|
+
# @return [WebPush::VapidKey] a new VapidKey instance
|
52
|
+
def generate_key
|
53
|
+
VapidKey.new
|
54
|
+
end
|
55
|
+
|
56
|
+
def encode64(bytes)
|
57
|
+
Base64.urlsafe_encode64(bytes)
|
58
|
+
end
|
59
|
+
|
60
|
+
def decode64(str)
|
61
|
+
# For Ruby < 2.3, Base64.urlsafe_decode64 strict decodes and will raise errors if encoded value is not properly padded
|
62
|
+
# Implementation: http://ruby-doc.org/stdlib-2.3.0/libdoc/base64/rdoc/Base64.html#method-i-urlsafe_decode64
|
63
|
+
str = str.ljust((str.length + 3) & ~3, '=') if !str.end_with?('=') && str.length % 4 != 0
|
64
|
+
|
65
|
+
Base64.urlsafe_decode64(str)
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def subscription(endpoint, p256dh, auth)
|
71
|
+
{
|
72
|
+
endpoint: endpoint,
|
73
|
+
keys: {
|
74
|
+
p256dh: p256dh,
|
75
|
+
auth: auth
|
76
|
+
}
|
77
|
+
}
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
2
|
+
require 'pry'
|
3
|
+
require 'web_push'
|
4
|
+
require 'webmock/rspec'
|
5
|
+
require 'simplecov'
|
6
|
+
WebMock.disable_net_connect!(allow_localhost: true)
|
7
|
+
SimpleCov.start
|
8
|
+
|
9
|
+
def vapid_options
|
10
|
+
{
|
11
|
+
subject: 'mailto:sender@example.com',
|
12
|
+
public_key: vapid_public_key,
|
13
|
+
private_key: vapid_private_key
|
14
|
+
}
|
15
|
+
end
|
16
|
+
|
17
|
+
def vapid_public_key
|
18
|
+
'BB37UCyc8LLX4PNQSe-04vSFvpUWGrENubUaslVFM_l5TxcGVMY0C3RXPeUJAQHKYlcOM2P4vTYmkoo0VZGZTM4='
|
19
|
+
end
|
20
|
+
|
21
|
+
def vapid_private_key
|
22
|
+
'OPrw1Sum3gRoL4-DXfSCC266r-qfFSRZrnj8MgIhRHg='
|
23
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe WebPush::Encryption do
|
4
|
+
describe '#encrypt' do
|
5
|
+
let(:curve) do
|
6
|
+
group = 'prime256v1'
|
7
|
+
curve = OpenSSL::PKey::EC.new(group)
|
8
|
+
curve.generate_key
|
9
|
+
curve
|
10
|
+
end
|
11
|
+
|
12
|
+
let(:p256dh) do
|
13
|
+
ecdh_key = curve.public_key.to_bn.to_s(2)
|
14
|
+
Base64.urlsafe_encode64(ecdh_key)
|
15
|
+
end
|
16
|
+
|
17
|
+
let(:auth) { Base64.urlsafe_encode64(Random.new.bytes(16)) }
|
18
|
+
|
19
|
+
it 'returns ECDH encrypted cipher text, salt, and server_public_key' do
|
20
|
+
payload = WebPush::Encryption.encrypt('Hello World', p256dh, auth)
|
21
|
+
expect(decrypt(payload)).to eq('Hello World')
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'returns error when message is blank' do
|
25
|
+
expect { WebPush::Encryption.encrypt(nil, p256dh, auth) }.to raise_error(ArgumentError)
|
26
|
+
expect { WebPush::Encryption.encrypt('', p256dh, auth) }.to raise_error(ArgumentError)
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'returns error when p256dh is blank' do
|
30
|
+
expect { WebPush::Encryption.encrypt('Hello world', nil, auth) }.to raise_error(ArgumentError)
|
31
|
+
expect { WebPush::Encryption.encrypt('Hello world', '', auth) }.to raise_error(ArgumentError)
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'returns error when auth is blank' do
|
35
|
+
expect { WebPush::Encryption.encrypt('Hello world', p256dh, '') }.to raise_error(ArgumentError)
|
36
|
+
expect { WebPush::Encryption.encrypt('Hello world', p256dh, nil) }.to raise_error(ArgumentError)
|
37
|
+
end
|
38
|
+
|
39
|
+
# Bug fix for https://github.com/zaru/webpush/issues/22
|
40
|
+
it 'handles unpadded base64 encoded subscription keys' do
|
41
|
+
unpadded_p256dh = p256dh.gsub(/=*\Z/, '')
|
42
|
+
unpadded_auth = auth.gsub(/=*\Z/, '')
|
43
|
+
|
44
|
+
payload = WebPush::Encryption.encrypt('Hello World', unpadded_p256dh, unpadded_auth)
|
45
|
+
expect(decrypt(payload)).to eq('Hello World')
|
46
|
+
end
|
47
|
+
|
48
|
+
def decrypt payload
|
49
|
+
salt = payload.byteslice(0, 16)
|
50
|
+
rs = payload.byteslice(16, 4).unpack("N*").first
|
51
|
+
idlen = payload.byteslice(20).unpack("C*").first
|
52
|
+
serverkey16bn = payload.byteslice(21, idlen)
|
53
|
+
ciphertext = payload.byteslice(21 + idlen, rs)
|
54
|
+
|
55
|
+
expect(payload.bytesize).to eq(21 + idlen + rs)
|
56
|
+
|
57
|
+
group_name = 'prime256v1'
|
58
|
+
group = OpenSSL::PKey::EC::Group.new(group_name)
|
59
|
+
server_public_key_bn = OpenSSL::BN.new(serverkey16bn.unpack('H*').first, 16)
|
60
|
+
server_public_key = OpenSSL::PKey::EC::Point.new(group, server_public_key_bn)
|
61
|
+
shared_secret = curve.dh_compute_key(server_public_key)
|
62
|
+
|
63
|
+
client_public_key_bn = curve.public_key.to_bn
|
64
|
+
client_auth_token = WebPush.decode64(auth)
|
65
|
+
|
66
|
+
info = "WebPush: info\0" + client_public_key_bn.to_s(2) + server_public_key_bn.to_s(2)
|
67
|
+
content_encryption_key_info = "Content-Encoding: aes128gcm\0"
|
68
|
+
nonce_info = "Content-Encoding: nonce\0"
|
69
|
+
|
70
|
+
prk = HKDF.new(shared_secret, salt: client_auth_token, algorithm: 'SHA256', info: info).next_bytes(32)
|
71
|
+
|
72
|
+
content_encryption_key = HKDF.new(prk, salt: salt, info: content_encryption_key_info).next_bytes(16)
|
73
|
+
nonce = HKDF.new(prk, salt: salt, info: nonce_info).next_bytes(12)
|
74
|
+
|
75
|
+
decrypt_ciphertext(ciphertext, content_encryption_key, nonce)
|
76
|
+
end
|
77
|
+
|
78
|
+
def decrypt_ciphertext(ciphertext, content_encryption_key, nonce)
|
79
|
+
secret_data = ciphertext.byteslice(0, ciphertext.bytesize-16)
|
80
|
+
auth = ciphertext.byteslice(ciphertext.bytesize-16, ciphertext.bytesize)
|
81
|
+
decipher = OpenSSL::Cipher.new('aes-128-gcm')
|
82
|
+
decipher.decrypt
|
83
|
+
decipher.key = content_encryption_key
|
84
|
+
decipher.iv = nonce
|
85
|
+
decipher.auth_tag = auth
|
86
|
+
|
87
|
+
decrypted = decipher.update(secret_data) + decipher.final
|
88
|
+
|
89
|
+
e = decrypted.byteslice(-2, decrypted.bytesize)
|
90
|
+
expect(e).to eq("\2\0")
|
91
|
+
|
92
|
+
decrypted.byteslice(0, decrypted.bytesize-2)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|