Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement VAPID authorization #26

Merged
merged 19 commits into from
Oct 14, 2016
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,20 @@ message = {
}

Webpush.payload_send(
endpoint: "https://android.googleapis.com/gcm/send/eah7hak....",
endpoint: "https://fcm.googleapis.com/gcm/send/eah7hak....",
message: JSON.generate(message),
p256dh: "BO/aG9nYXNkZmFkc2ZmZHNmYWRzZmFl...",
auth: "aW1hcmthcmFpa3V6ZQ==",
ttl: 600, #optional, ttl in seconds, defaults to 2419200 (4 weeks)
api_key: "[GoogleDeveloper APIKEY]" # optional, not used in Firefox.
ttl: 600 #optional, ttl in seconds, defaults to 2419200 (4 weeks)
)
```

### not use the payload

```ruby
Webpush.payload_send(
endpoint: "https://android.googleapis.com/gcm/send/eah7hak....",
ttl: 600, #optional, ttl in seconds, defaults to 2419200 (4 weeks)
api_key: "[GoogleDeveloper APIKEY]" # optional, not used in Firefox.
endpoint: "https://fcm.googleapis.com/gcm/send/eah7hak....",
ttl: 600 #optional, ttl in seconds, defaults to 2419200 (4 weeks)
)
```

Expand Down
40 changes: 22 additions & 18 deletions lib/webpush.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,12 @@
require 'json'

require 'webpush/version'
require 'webpush/urlsafe'
require 'webpush/vapid'
require 'webpush/encryption'
require 'webpush/request'

module Webpush

# It is temporary URL until supported by the GCM server.
GCM_URL = 'https://android.googleapis.com/gcm/send'
TEMP_GCM_URL = 'https://gcm-http.googleapis.com/gcm'

class << self
# Deliver the payload to the required endpoint given by the JavaScript
# PushSubscription. Including an optional message requires p256dh and
Expand All @@ -24,22 +21,29 @@ class << self
# @param p256dh [String] the user's public ECDH key given by the PushSubscription
# @param auth [String] the user's private ECDH key given by the PushSubscription
# @param options [Hash<Symbol,String>] additional options for the notification
# @option options [String] :api_key required for Google, omit for Firefox
# @option options [#to_s] :ttl Time-to-live in seconds
def payload_send(endpoint:, message: "", p256dh: "", auth: "", **options)
endpoint = endpoint.gsub(GCM_URL, TEMP_GCM_URL)

payload = build_payload(message, p256dh, auth)

Webpush::Request.new(endpoint, options.merge(payload: payload)).perform
def payload_send(message: "", endpoint:, p256dh: "", auth: "", vapid:, **options)
subscription = {
endpoint: endpoint,
keys: {
p256dh: p256dh,
auth: auth
}
}
Webpush::Request.new(
message: message,
subscription: subscription,
vapid: vapid,
**options
).perform
end

private

def build_payload(message, p256dh, auth)
return {} if message.nil? || message.empty?

Webpush::Encryption.encrypt(message, p256dh, auth)
# public_key: vapid_key.public_key.to_bn.to_s(2)
# private_key: vapid_key.private_key.to_s(2)
def generate_key
key = OpenSSL::PKey::EC.new('prime256v1')
key.generate_key
key
end
end
end
1 change: 1 addition & 0 deletions lib/webpush/encryption.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def encrypt(message, p256dh, auth)
ciphertext: ciphertext,
salt: salt,
server_public_key_bn: convert16bit(server_public_key_bn),
server_public_key: server_public_key_bn.to_s(2),
shared_secret: shared_secret
}
end
Expand Down
56 changes: 35 additions & 21 deletions lib/webpush/request.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
require 'jwt'
require 'base64'

module Webpush

class ResponseError < RuntimeError
Expand All @@ -7,10 +10,15 @@ class InvalidSubscription < ResponseError
end

class Request
def initialize(endpoint, options = {})
@endpoint = endpoint
include Urlsafe

def initialize(message: "", subscription:, vapid:, **options)
@endpoint = subscription.fetch(:endpoint)
@vapid = vapid

@payload = build_payload(message, subscription)

@options = default_options.merge(options)
@payload = @options.delete(:payload) || {}
end

def perform
Expand All @@ -36,17 +44,26 @@ def headers
headers["Content-Type"] = "application/octet-stream"
headers["Ttl"] = ttl

if encrypted_payload?
if @payload.has_key?(:server_public_key)
headers["Content-Encoding"] = "aesgcm"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for checking, made the change

headers["Encryption"] = "salt=#{salt_param}"
headers["Crypto-Key"] = "dh=#{dh_param}"
headers["Encryption"] = "keyid=p256dh;salt=#{salt_param}"
headers["Crypto-Key"] = "keyid=p256dh;dh=#{dh_param}"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keyid is no longer needed here

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, removed!

end

headers["Authorization"] = "key=#{api_key}" if api_key?
vapid_headers = build_vapid_headers
headers["Authorization"] = vapid_headers["Authorization"]
Copy link

@comp615 comp615 Oct 9, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this still needs the previous case matching statement, or something to the effect of:

if (@endpoint =~ /\Ahttps:\/\/(android|gcm-http)\.googleapis\.com/) {
  vapid_headers = api_key? ? {"Authorization": "key=#{api_key}" } : {}
} else {
  vapid_headers = build_vapid_headers
}

Reason being that for backwards compatibility, people may still have old tokens saved. Basically iff the subscription is created with a VAPID public key in JS on the client, an FCM endpoint will be returned. Otherwise, you still get the old style and wouldn't be able to perform VAPID with it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. It was perhaps aggressive of me to remove this functionality so I've added an item to my checklist to reinstate.

headers["Crypto-Key"] = [
headers["Crypto-Key"],
vapid_headers["Crypto-Key"]
].compact.join(";")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Word is the ';' join was a bug in Chrome 52, should be using ',' here now I think :-?


headers
end

def build_vapid_headers
Vapid.headers(@vapid)
end

def body
@payload.fetch(:ciphertext, "")
end
Expand All @@ -57,20 +74,8 @@ def ttl
@options.fetch(:ttl).to_s
end

def api_key
@options.fetch(:api_key, nil)
end

def api_key?
!(api_key.nil? || api_key.empty?) && @endpoint =~ /\Ahttps:\/\/(android|gcm-http)\.googleapis\.com/
end

def encrypted_payload?
[:ciphertext, :server_public_key_bn, :salt].all? { |key| @payload.has_key?(key) }
end

def dh_param
Base64.urlsafe_encode64(@payload.fetch(:server_public_key_bn)).delete('=')
Base64.urlsafe_encode64(@payload.fetch(:server_public_key)).delete('=')
end

def salt_param
Expand All @@ -79,9 +84,18 @@ def salt_param

def default_options
{
api_key: nil,
ttl: 60*60*24*7*4 # 4 weeks
}
end

def build_payload(message, subscription)
return {} if message.nil? || message.empty?

encrypt_payload(message, subscription.fetch(:keys))
end

def encrypt_payload(message, p256dh:, auth:)
Webpush::Encryption.encrypt(message, p256dh, auth)
end
end
end
11 changes: 11 additions & 0 deletions lib/webpush/urlsafe.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module Webpush
module Urlsafe
def urlsafe_encode64(key)
Base64.urlsafe_encode64(key).delete('=')
end

def urlsafe_decode64(key)
Base64.urlsafe_decode64(key)
end
end
end
50 changes: 50 additions & 0 deletions lib/webpush/vapid.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
module Webpush
class Vapid
include Urlsafe

def self.headers(options)
new(options).headers
end

def initialize(public_key:, private_key:, audience:, subject:, expiration: 24*60*60)
@public_key = public_key
@private_key = private_key
@audience = audience
@subject = subject
@expiration = expiration
end

def headers
vapid_key = generate_vapid_key
jwt = JWT.encode(jwt_payload, vapid_key, 'ES256')
p256ecdsa = urlsafe_encode64(vapid_key.public_key.to_bn.to_s(2))

{
'Authorization' => 'WebPush ' + jwt,
'Crypto-Key' => 'p256ecdsa=' + p256ecdsa,
}
end

private

def jwt_payload
{
aud: @audience,
exp: Time.now.to_i + @expiration,
sub: @subject,
}
end

def generate_vapid_key
public_key_bn = OpenSSL::BN.new(urlsafe_decode64(@public_key), 2)
private_key_bn = OpenSSL::BN.new(urlsafe_decode64(@private_key), 2)

vapid_key = Webpush.generate_key
vapid_key.public_key = OpenSSL::PKey::EC::Point.new(vapid_key.group, public_key_bn)
vapid_key.private_key = private_key_bn

vapid_key
end
end

end
9 changes: 9 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,12 @@
require 'webpush'
require 'webmock/rspec'
WebMock.disable_net_connect!(allow_localhost: true)

def vapid_options
{
audience: "http://example.com",
subject: "mailto:[email protected]",
public_key: "BB9KQDaypj3mJCyrFbF5EDm-UrfnIGeomy0kYL56Mddi3LG6AFEMB_DnWUXSAmNFNOaIgTlXrT3dk2krmp9SPyg=",
private_key: "JYQ5wbkNfJ2b1Kv_t58cUJJENBIIboVv5Ijzk6a5yH8="
}
end
74 changes: 24 additions & 50 deletions spec/webpush/request_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,70 +2,31 @@

describe Webpush::Request do
describe '#headers' do
let(:request) { Webpush::Request.new("endpoint") }
let(:request) { build_request("endpoint", vapid: vapid_options) }

it { expect(request.headers['Content-Type']).to eq('application/octet-stream') }
it { expect(request.headers['Ttl']).to eq('2419200') }

describe 'from :payload' do
describe 'from :message' do
it 'inserts encryption headers for valid payload' do
payload = {
ciphertext: "ciphertext",
server_public_key_bn:
"server_public_key_bn",
salt: "salt"
}
request = Webpush::Request.new("endpoint", payload: payload)
allow(Webpush::Encryption).to receive(:encrypt).and_return(ciphertext: 'encrypted', server_public_key: 'server_public_key', salt: 'salt')
request = build_request("endpoint", message: "Hello")

expect(request.headers['Content-Encoding']).to eq("aesgcm")
expect(request.headers['Encryption']).to eq("salt=c2FsdA")
expect(request.headers['Crypto-Key']).to eq("dh=c2VydmVyX3B1YmxpY19rZXlfYm4")
end
end

describe 'from :api_key' do
it 'inserts Authorization header when api_key present, and endpoint is for Chrome\'s non-standards-compliant GCM endpoints' do
request = Webpush::Request.new('https://gcm-http.googleapis.com/gcm/xyz', api_key: "api_key")

expect(request.headers['Authorization']).to eq("key=api_key")
end

it 'does not insert Authorization header for Chrome\'s new standards-compliant endpoints, even if api_key is present' do
request = Webpush::Request.new('https://fcm.googleapis.com/fcm/send/ABCD1234', api_key: "api_key")

expect(request.headers['Authorization']).to be_nil
end

it 'does not insert Authorization header when endpoint is not for Chrome, even if api_key is present' do
request = Webpush::Request.new('https://some.random.endpoint.com/xyz', api_key: "api_key")

expect(request.headers['Authorization']).to be_nil
end

it 'does not insert Authorization header when api_key blank' do
request = Webpush::Request.new("endpoint", api_key: nil)

expect(request.headers['Authorization']).to be_nil

request = Webpush::Request.new("endpoint", api_key: "")

expect(request.headers['Authorization']).to be_nil

request = Webpush::Request.new("endpoint")

expect(request.headers['Authorization']).to be_nil
expect(request.headers['Encryption']).to eq("keyid=p256dh;salt=c2FsdA")
expect(request.headers['Crypto-Key']).to eq("keyid=p256dh;dh=c2VydmVyX3B1YmxpY19rZXk;p256ecdsa="+vapid_options[:public_key].delete('='))
end
end

describe 'from :ttl' do
it 'can override Ttl with :ttl option with string' do
request = Webpush::Request.new("endpoint", ttl: '300')
request = build_request("endpoint", ttl: '300', vapid: vapid_options)

expect(request.headers['Ttl']).to eq('300')
end

it 'can override Ttl with :ttl option with fixnum' do
request = Webpush::Request.new("endpoint", ttl: 60 * 5)
request = build_request("endpoint", ttl: 60 * 5)

expect(request.headers['Ttl']).to eq('300')
end
Expand All @@ -74,21 +35,34 @@

describe '#body' do
it 'extracts :ciphertext from the :payload argument' do
request = Webpush::Request.new('endpoint', payload: { ciphertext: 'encrypted' })
allow(Webpush::Encryption).to receive(:encrypt).and_return(ciphertext: 'encrypted')

request = build_request('endpoint', message: 'Hello', vapid: vapid_options)

expect(request.body).to eq('encrypted')
end

it 'is empty string when no :ciphertext' do
request = Webpush::Request.new('endpoint', payload: {})
request = build_request('endpoint', payload: {})

expect(request.body).to eq('')
end

it 'is empty string when no :payload' do
request = Webpush::Request.new('endpoint')
request = build_request('endpoint')

expect(request.body).to eq('')
end
end

def build_request(endpoint, options = {})
subscription = {
endpoint: endpoint,
keys: {
p256dh: 'p256dh',
auth: 'auth'
}
}
Webpush::Request.new(message: "", subscription: subscription, vapid: vapid_options, **options)
end
end
Loading