diff --git a/lib/webpush.rb b/lib/webpush.rb index b8681e0..057d959 100644 --- a/lib/webpush.rb +++ b/lib/webpush.rb @@ -1,10 +1,12 @@ -require 'webpush/version' require 'openssl' require 'base64' require 'hkdf' require 'net/http' require 'json' +require 'webpush/version' +require 'webpush/encryption' + module Webpush # It is temporary URL until supported by the GCM server. @@ -14,10 +16,8 @@ module Webpush class << self def payload_send(message:, endpoint:, p256dh:, auth:, api_key: "") endpoint = endpoint.gsub(GCM_URL, TEMP_GCM_URL) - p256dh = unescape_base64(p256dh) - auth = unescape_base64(auth) - payload = encrypt(message, p256dh, auth) + payload = Webpush::Encryption.encrypt(message, p256dh, auth) push_server_post(endpoint, payload, api_key) end @@ -45,84 +45,5 @@ def push_server_post(endpoint, payload, api_key = "") return false end end - - def encrypt(message, p256dh, auth) - group_name = "prime256v1" - salt = Random.new.bytes(16) - - server = OpenSSL::PKey::EC.new(group_name) - server.generate_key - server_public_key_bn = server.public_key.to_bn - - group = OpenSSL::PKey::EC::Group.new(group_name) - client_public_key_hex = Base64.decode64(p256dh).unpack("H*").first - client_public_key_bn = OpenSSL::BN.new(client_public_key_hex, 16) - client_public_key = OpenSSL::PKey::EC::Point.new(group, client_public_key_bn) - - shared_secret = server.dh_compute_key(client_public_key) - - clientAuthToken = Base64.decode64(auth) - - prk = HKDF.new(shared_secret, :salt => clientAuthToken, :algorithm => 'SHA256', :info => "Content-Encoding: auth\0").next_bytes(32) - - context = create_context(client_public_key_bn, server_public_key_bn) - - content_encryption_key_info = create_info('aesgcm', context) - content_encryption_key = HKDF.new(prk, :salt => salt, :info => content_encryption_key_info).next_bytes(16) - - nonce_info = create_info('nonce', context) - nonce = HKDF.new(prk, :salt => salt, :info => nonce_info).next_bytes(12) - - ciphertext = encrypt_payload(message, content_encryption_key, nonce) - - { - ciphertext: ciphertext, - salt: salt, - server_public_key_bn: convert16bit(server_public_key_bn) - } - end - - def create_context(clientPublicKey, serverPublicKey) - c = convert16bit(clientPublicKey) - s = convert16bit(serverPublicKey) - context = "\0" - context += [c.bytesize].pack("n*") - context += c - context += [s.bytesize].pack("n*") - context += s - context - end - - def encrypt_payload(plaintext, content_encryption_key, nonce) - cipher = OpenSSL::Cipher.new('aes-128-gcm') - cipher.encrypt - cipher.key = content_encryption_key - cipher.iv = nonce - padding = cipher.update("\0\0") - text = cipher.update(plaintext) - - e_text = padding + text + cipher.final - e_tag = cipher.auth_tag - - e_text + e_tag - end - - def create_info(type, context) - info = "Content-Encoding: " - info += type - info += "\0" - info += "P-256" - info += context - info - end - - def convert16bit(key) - [key.to_s(16)].pack("H*") - end - - def unescape_base64(base64) - base64.gsub(/_|\-/, "_" => "/", "-" => "+") - end end - end diff --git a/lib/webpush/encryption.rb b/lib/webpush/encryption.rb new file mode 100644 index 0000000..fe2033e --- /dev/null +++ b/lib/webpush/encryption.rb @@ -0,0 +1,81 @@ +module Webpush + module Encryption + extend self + + def encrypt(message, p256dh, auth) + group_name = "prime256v1" + salt = Random.new.bytes(16) + + server = OpenSSL::PKey::EC.new(group_name) + server.generate_key + server_public_key_bn = server.public_key.to_bn + + group = OpenSSL::PKey::EC::Group.new(group_name) + client_public_key_bn = OpenSSL::BN.new(Base64.urlsafe_decode64(p256dh), 2) + client_public_key = OpenSSL::PKey::EC::Point.new(group, client_public_key_bn) + + shared_secret = server.dh_compute_key(client_public_key) + + client_auth_token = Base64.urlsafe_decode64(auth) + + prk = HKDF.new(shared_secret, :salt => client_auth_token, :algorithm => 'SHA256', :info => "Content-Encoding: auth\0").next_bytes(32) + + context = create_context(client_public_key_bn, server_public_key_bn) + + content_encryption_key_info = create_info('aesgcm', context) + content_encryption_key = HKDF.new(prk, :salt => salt, :info => content_encryption_key_info).next_bytes(16) + + nonce_info = create_info('nonce', context) + nonce = HKDF.new(prk, :salt => salt, :info => nonce_info).next_bytes(12) + + ciphertext = encrypt_payload(message, content_encryption_key, nonce) + + { + ciphertext: ciphertext, + salt: salt, + server_public_key_bn: convert16bit(server_public_key_bn), + shared_secret: shared_secret + } + end + + private + + def create_context(clientPublicKey, serverPublicKey) + c = convert16bit(clientPublicKey) + s = convert16bit(serverPublicKey) + context = "\0" + context += [c.bytesize].pack("n*") + context += c + context += [s.bytesize].pack("n*") + context += s + context + end + + def encrypt_payload(plaintext, content_encryption_key, nonce) + cipher = OpenSSL::Cipher.new('aes-128-gcm') + cipher.encrypt + cipher.key = content_encryption_key + cipher.iv = nonce + padding = cipher.update("\0\0") + text = cipher.update(plaintext) + + e_text = padding + text + cipher.final + e_tag = cipher.auth_tag + + e_text + e_tag + end + + def create_info(type, context) + info = "Content-Encoding: " + info += type + info += "\0" + info += "P-256" + info += context + info + end + + def convert16bit(key) + [key.to_s(16)].pack("H*") + end + end +end diff --git a/spec/webpush/encryption_spec.rb b/spec/webpush/encryption_spec.rb new file mode 100644 index 0000000..68fb754 --- /dev/null +++ b/spec/webpush/encryption_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' +require 'ece' + +describe Webpush::Encryption do + describe "#encrypt" do + let(:p256dh) do + encode64(generate_ecdh_key) + end + + let(:auth) { encode64(Random.new.bytes(16)) } + + it "returns ECDH encrypted cipher text, salt, and server_public_key" do + payload = Webpush::Encryption.encrypt("Hello World", p256dh, auth) + + encrypted = payload.fetch(:ciphertext) + + decrypted_data = ECE.decrypt(encrypted, + key: payload.fetch(:shared_secret), + salt: payload.fetch(:salt), + server_public_key: payload.fetch(:server_public_key_bn), + user_public_key: decode64(p256dh), + auth: decode64(auth)) + + expect(decrypted_data).to eq("Hello World") + end + + def generate_ecdh_key + group = "prime256v1" + curve = OpenSSL::PKey::EC.new(group) + curve.generate_key + curve.public_key.to_bn.to_s(2) + end + + def encode64(bytes) + Base64.urlsafe_encode64(bytes) + end + + def decode64(bytes) + Base64.urlsafe_decode64(bytes) + end + end +end diff --git a/spec/webpush_spec.rb b/spec/webpush_spec.rb index ee6af8d..f86032a 100644 --- a/spec/webpush_spec.rb +++ b/spec/webpush_spec.rb @@ -4,8 +4,4 @@ it 'has a version number' do expect(Webpush::VERSION).not_to be nil end - - it 'does something useful' do - expect(false).to eq(true) - end end diff --git a/webpush.gemspec b/webpush.gemspec index 192c7d7..921c020 100644 --- a/webpush.gemspec +++ b/webpush.gemspec @@ -22,4 +22,5 @@ Gem::Specification.new do |spec| spec.add_development_dependency "bundler", "~> 1.11" spec.add_development_dependency "rake", "~> 10.0" spec.add_development_dependency "rspec", "~> 3.0" + spec.add_development_dependency "ece", "~> 0.2" end