-
Notifications
You must be signed in to change notification settings - Fork 75
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
Changes from 5 commits
289ecb6
e1c326f
d664790
53a54cc
0961b6f
6375293
0071838
5f652f8
9b1a78e
50bbd01
9bce0ff
be4fc16
e4f5b1e
c802fe7
2b9ceef
b89112a
d4d5a6b
fd4f283
de87c4c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,6 @@ | ||
require 'jwt' | ||
require 'base64' | ||
|
||
module Webpush | ||
|
||
class ResponseError < RuntimeError | ||
|
@@ -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 | ||
|
@@ -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" | ||
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}" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. keyid is no longer needed here There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(";") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
@@ -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 | ||
|
@@ -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 |
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 |
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
https://tools.ietf.org/html/draft-ietf-webpush-encryption-03 says "aesgcm128"
There was a problem hiding this comment.
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