web-push 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|