Skip to content

Commit 152c11f

Browse files
authored
Merge pull request #84 from mohamedhafez/aes128gcm
switch to aes128gcm encoding
2 parents 80ff28a + 897a9cf commit 152c11f

File tree

6 files changed

+105
-158
lines changed

6 files changed

+105
-158
lines changed

lib/webpush/encryption.rb

+14-35
Original file line numberDiff line numberDiff line change
@@ -23,64 +23,43 @@ def encrypt(message, p256dh, auth)
2323

2424
client_auth_token = Webpush.decode64(auth)
2525

26-
prk = HKDF.new(shared_secret, salt: client_auth_token, algorithm: 'SHA256', info: "Content-Encoding: auth\0").next_bytes(32)
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"
2729

28-
context = create_context(client_public_key_bn, server_public_key_bn)
30+
prk = HKDF.new(shared_secret, salt: client_auth_token, algorithm: 'SHA256', info: info).next_bytes(32)
2931

30-
content_encryption_key_info = create_info('aesgcm', context)
3132
content_encryption_key = HKDF.new(prk, salt: salt, info: content_encryption_key_info).next_bytes(16)
3233

33-
nonce_info = create_info('nonce', context)
3434
nonce = HKDF.new(prk, salt: salt, info: nonce_info).next_bytes(12)
3535

3636
ciphertext = encrypt_payload(message, content_encryption_key, nonce)
3737

38-
{
39-
ciphertext: ciphertext,
40-
salt: salt,
41-
server_public_key_bn: convert16bit(server_public_key_bn),
42-
server_public_key: server_public_key_bn.to_s(2),
43-
shared_secret: shared_secret
44-
}
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
4545
end
4646
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
4747

4848
private
4949

50-
def create_context(client_public_key, server_public_key)
51-
c = convert16bit(client_public_key)
52-
s = convert16bit(server_public_key)
53-
context = "\0"
54-
context += [c.bytesize].pack('n*')
55-
context += c
56-
context += [s.bytesize].pack('n*')
57-
context += s
58-
context
59-
end
60-
6150
def encrypt_payload(plaintext, content_encryption_key, nonce)
6251
cipher = OpenSSL::Cipher.new('aes-128-gcm')
6352
cipher.encrypt
6453
cipher.key = content_encryption_key
6554
cipher.iv = nonce
66-
padding = cipher.update("\0\0")
6755
text = cipher.update(plaintext)
68-
69-
e_text = padding + text + cipher.final
56+
padding = cipher.update("\2\0")
57+
e_text = text + padding + cipher.final
7058
e_tag = cipher.auth_tag
7159

7260
e_text + e_tag
7361
end
7462

75-
def create_info(type, context)
76-
info = 'Content-Encoding: '
77-
info += type
78-
info += "\0"
79-
info += 'P-256'
80-
info += context
81-
info
82-
end
83-
8463
def convert16bit(key)
8564
[key.to_s(16)].pack('H*')
8665
end
@@ -95,4 +74,4 @@ def blank?(value)
9574
value.nil? || value.empty?
9675
end
9776
end
98-
end
77+
end

lib/webpush/request.rb

+11-23
Original file line numberDiff line numberDiff line change
@@ -42,37 +42,33 @@ def headers
4242
headers['Ttl'] = ttl
4343
headers['Urgency'] = urgency
4444

45-
if @payload.key?(:server_public_key)
46-
headers['Content-Encoding'] = 'aesgcm'
47-
headers['Encryption'] = "salt=#{salt_param}"
48-
headers['Crypto-Key'] = "dh=#{dh_param}"
45+
if @payload
46+
headers['Content-Encoding'] = 'aes128gcm'
47+
headers["Content-Length"] = @payload.length.to_s
4948
end
5049

5150
if api_key?
5251
headers['Authorization'] = "key=#{api_key}"
5352
elsif vapid?
54-
vapid_headers = build_vapid_headers
55-
headers['Authorization'] = vapid_headers['Authorization']
56-
headers['Crypto-Key'] = [headers['Crypto-Key'], vapid_headers['Crypto-Key']].compact.join(';')
53+
headers["Authorization"] = build_vapid_header
5754
end
5855

5956
headers
6057
end
6158
# rubocop:enable Metrics/MethodLength
6259

63-
def build_vapid_headers
60+
def build_vapid_header
61+
# https://tools.ietf.org/id/draft-ietf-webpush-vapid-03.html
62+
6463
vapid_key = vapid_pem ? VapidKey.from_pem(vapid_pem) : VapidKey.from_keys(vapid_public_key, vapid_private_key)
6564
jwt = JWT.encode(jwt_payload, vapid_key.curve, 'ES256', jwt_header_fields)
6665
p256ecdsa = vapid_key.public_key_for_push_header
6766

68-
{
69-
'Authorization' => 'WebPush ' + jwt,
70-
'Crypto-Key' => 'p256ecdsa=' + p256ecdsa
71-
}
67+
"vapid t=#{jwt},k=#{p256ecdsa}"
7268
end
7369

7470
def body
75-
@payload.fetch(:ciphertext, '')
71+
@payload || ''
7672
end
7773

7874
private
@@ -89,14 +85,6 @@ def urgency
8985
@options.fetch(:urgency).to_s
9086
end
9187

92-
def dh_param
93-
trim_encode64(@payload.fetch(:server_public_key))
94-
end
95-
96-
def salt_param
97-
trim_encode64(@payload.fetch(:salt))
98-
end
99-
10088
def jwt_payload
10189
{
10290
aud: audience,
@@ -106,7 +94,7 @@ def jwt_payload
10694
end
10795

10896
def jwt_header_fields
109-
{ 'typ' => 'JWT' }
97+
{ "typ": "JWT", "alg": "ES256" }
11098
end
11199

112100
def audience
@@ -141,7 +129,7 @@ def default_options
141129
end
142130

143131
def build_payload(message, subscription)
144-
return {} if message.nil? || message.empty?
132+
return nil if message.nil? || message.empty?
145133

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

spec/webpush/encryption_spec.rb

+54-39
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,24 @@
11
require 'spec_helper'
2-
require 'ece'
32

43
describe Webpush::Encryption do
54
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+
612
let(:p256dh) do
7-
encode64(generate_ecdh_key)
13+
ecdh_key = curve.public_key.to_bn.to_s(2)
14+
Base64.urlsafe_encode64(ecdh_key)
815
end
916

10-
let(:auth) { encode64(Random.new.bytes(16)) }
17+
let(:auth) { Base64.urlsafe_encode64(Random.new.bytes(16)) }
1118

1219
it 'returns ECDH encrypted cipher text, salt, and server_public_key' do
1320
payload = Webpush::Encryption.encrypt('Hello World', p256dh, auth)
14-
15-
encrypted = payload.fetch(:ciphertext)
16-
17-
decrypted_data = ECE.decrypt(encrypted,
18-
key: payload.fetch(:shared_secret),
19-
salt: payload.fetch(:salt),
20-
server_public_key: payload.fetch(:server_public_key_bn),
21-
user_public_key: decode64(p256dh),
22-
auth: decode64(auth))
23-
24-
expect(decrypted_data).to eq('Hello World')
21+
expect(decrypt(payload)).to eq('Hello World')
2522
end
2623

2724
it 'returns error when message is blank' do
@@ -41,40 +38,58 @@
4138

4239
# Bug fix for https://github.com/zaru/webpush/issues/22
4340
it 'handles unpadded base64 encoded subscription keys' do
44-
unpadded_p256dh = 'BK74n-ZA6kfMDEuCFbQH1Y5T33p39PvnzNeuD5LqTs8cF-uaQFUHn_v5kwV6dYIIL4nFabxghQNF_vlnAXX7OiU'
45-
unpadded_auth = '1C1PBkJQsVwD9tkuLR1x5A'
41+
unpadded_p256dh = p256dh.gsub(/=*\Z/, '')
42+
unpadded_auth = auth.gsub(/=*\Z/, '')
4643

4744
payload = Webpush::Encryption.encrypt('Hello World', unpadded_p256dh, unpadded_auth)
48-
encrypted = payload.fetch(:ciphertext)
45+
expect(decrypt(payload)).to eq('Hello World')
46+
end
4947

50-
decrypted_data = ECE.decrypt(encrypted,
51-
key: payload.fetch(:shared_secret),
52-
salt: payload.fetch(:salt),
53-
server_public_key: payload.fetch(:server_public_key_bn),
54-
user_public_key: decode64(pad64(unpadded_p256dh)),
55-
auth: decode64(pad64(unpadded_auth)))
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)
5654

57-
expect(decrypted_data).to eq('Hello World')
58-
end
55+
expect(payload.bytesize).to eq(21 + idlen + rs)
5956

60-
def generate_ecdh_key
61-
group = 'prime256v1'
62-
curve = OpenSSL::PKey::EC.new(group)
63-
curve.generate_key
64-
curve.public_key.to_bn.to_s(2)
65-
end
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)
6662

67-
def encode64(bytes)
68-
Base64.urlsafe_encode64(bytes)
69-
end
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"
7069

71-
def decode64(str)
72-
Base64.urlsafe_decode64(str)
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)
7376
end
7477

75-
def pad64(str)
76-
str = str.ljust((str.length + 3) & ~3, '=') if !str.end_with?('=') && str.length % 4 != 0
77-
str
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)
7893
end
7994
end
8095
end

0 commit comments

Comments
 (0)