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.
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,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'web_push'
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebPush
4
+ class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ load 'tasks/web_push.rake'
7
+ end
8
+ end
9
+ 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebPush
4
+ VERSION = '1.0.0'.freeze
5
+ 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
@@ -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