web-push 1.0.0

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