Skip to content

Commit 52725de

Browse files
Add back support for legacy (RFC8291 draft 4) encryption (#2)
1 parent 127bc75 commit 52725de

3 files changed

Lines changed: 188 additions & 0 deletions

File tree

lib/webpush.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
require 'webpush/errors'
1111
require 'webpush/vapid_key'
1212
require 'webpush/encryption'
13+
require 'webpush/legacy/encryption'
1314
require 'webpush/request'
1415
require 'webpush/railtie' if defined?(Rails)
1516

lib/webpush/legacy/encryption.rb

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# frozen_string_literal: true
2+
3+
module Webpush
4+
module Legacy
5+
module Encryption
6+
# This implements RFC8291 draft 4:
7+
# https://datatracker.ietf.org/doc/html/draft-ietf-webpush-encryption-04
8+
9+
extend self
10+
11+
def encrypt(message, p256dh, auth)
12+
assert_arguments(message, p256dh, auth)
13+
14+
group_name = 'prime256v1'
15+
salt = Random.new.bytes(16)
16+
17+
server = OpenSSL::PKey::EC.generate(group_name)
18+
server_public_key_bn = server.public_key.to_bn
19+
20+
group = OpenSSL::PKey::EC::Group.new(group_name)
21+
client_public_key_bn = OpenSSL::BN.new(Webpush.decode64(p256dh), 2)
22+
client_public_key = OpenSSL::PKey::EC::Point.new(group, client_public_key_bn)
23+
24+
shared_secret = server.dh_compute_key(client_public_key)
25+
26+
client_auth_token = Webpush.decode64(auth)
27+
28+
prk = HKDF.new(shared_secret, salt: client_auth_token, algorithm: 'SHA256', info: "Content-Encoding: auth\0").next_bytes(32)
29+
30+
context = create_context(client_public_key_bn, server_public_key_bn)
31+
32+
content_encryption_key_info = create_info('aesgcm', context)
33+
content_encryption_key = HKDF.new(prk, salt: salt, info: content_encryption_key_info).next_bytes(16)
34+
35+
nonce_info = create_info('nonce', context)
36+
nonce = HKDF.new(prk, salt: salt, info: nonce_info).next_bytes(12)
37+
38+
ciphertext = encrypt_payload(message, content_encryption_key, nonce)
39+
40+
{
41+
ciphertext: ciphertext,
42+
salt: salt,
43+
server_public_key_bn: server_public_key_bn.to_s(2),
44+
server_public_key: server_public_key_bn.to_s(2),
45+
shared_secret: shared_secret
46+
}
47+
end
48+
49+
private
50+
51+
def assert_arguments(message, p256dh, auth)
52+
raise ArgumentError, 'message cannot be blank' if blank?(message)
53+
raise ArgumentError, 'p256dh cannot be blank' if blank?(p256dh)
54+
raise ArgumentError, 'auth cannot be blank' if blank?(auth)
55+
end
56+
57+
def blank?(value)
58+
value.nil? || value.empty?
59+
end
60+
61+
def create_context(client_public_key, server_public_key)
62+
c = client_public_key.to_s(2)
63+
s = server_public_key.to_s(2)
64+
"\0#{[c.bytesize].pack('n*')}#{c}#{[s.bytesize].pack('n*')}#{s}"
65+
end
66+
67+
def encrypt_payload(plaintext, content_encryption_key, nonce)
68+
cipher = OpenSSL::Cipher.new('aes-128-gcm')
69+
cipher.encrypt
70+
cipher.key = content_encryption_key
71+
cipher.iv = nonce
72+
padding = cipher.update("\0\0")
73+
text = cipher.update(plaintext)
74+
75+
e_text = padding + text + cipher.final
76+
e_tag = cipher.auth_tag
77+
78+
e_text + e_tag
79+
end
80+
81+
def create_info(type, context)
82+
"Content-Encoding: #{type}\0P-256#{context}"
83+
end
84+
end
85+
end
86+
end
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
require 'spec_helper'
2+
3+
describe Webpush::Legacy::Encryption do
4+
describe '#encrypt' do
5+
let(:curve) do
6+
group = 'prime256v1'
7+
OpenSSL::PKey::EC.generate(group)
8+
end
9+
10+
let(:p256dh) do
11+
ecdh_key = curve.public_key.to_bn.to_s(2)
12+
Base64.urlsafe_encode64(ecdh_key)
13+
end
14+
15+
let(:auth) { Base64.urlsafe_encode64(Random.new.bytes(16)) }
16+
17+
it 'returns ECDH encrypted cipher text, salt, and server_public_key' do
18+
payload = Webpush::Legacy::Encryption.encrypt('Hello World', p256dh, auth)
19+
expect(decrypt(payload)).to eq('Hello World')
20+
end
21+
22+
it 'returns error when message is blank' do
23+
expect { Webpush::Legacy::Encryption.encrypt(nil, p256dh, auth) }.to raise_error(ArgumentError)
24+
expect { Webpush::Legacy::Encryption.encrypt('', p256dh, auth) }.to raise_error(ArgumentError)
25+
end
26+
27+
it 'returns error when p256dh is blank' do
28+
expect { Webpush::Legacy::Encryption.encrypt('Hello world', nil, auth) }.to raise_error(ArgumentError)
29+
expect { Webpush::Legacy::Encryption.encrypt('Hello world', '', auth) }.to raise_error(ArgumentError)
30+
end
31+
32+
it 'returns error when auth is blank' do
33+
expect { Webpush::Legacy::Encryption.encrypt('Hello world', p256dh, '') }.to raise_error(ArgumentError)
34+
expect { Webpush::Legacy::Encryption.encrypt('Hello world', p256dh, nil) }.to raise_error(ArgumentError)
35+
end
36+
37+
# Bug fix for https://github.com/zaru/webpush/issues/22
38+
it 'handles unpadded base64 encoded subscription keys' do
39+
unpadded_p256dh = p256dh.gsub(/=*\Z/, '')
40+
unpadded_auth = auth.gsub(/=*\Z/, '')
41+
42+
payload = Webpush::Legacy::Encryption.encrypt('Hello World', unpadded_p256dh, unpadded_auth)
43+
expect(decrypt(payload)).to eq('Hello World')
44+
end
45+
46+
def decrypt(payload)
47+
salt = payload.fetch(:salt)
48+
serverkey16bn = payload.fetch(:server_public_key_bn)
49+
ciphertext = payload.fetch(:ciphertext)
50+
51+
group_name = 'prime256v1'
52+
group = OpenSSL::PKey::EC::Group.new(group_name)
53+
server_public_key_bn = OpenSSL::BN.new(serverkey16bn.unpack('H*').first, 16)
54+
server_public_key = OpenSSL::PKey::EC::Point.new(group, server_public_key_bn)
55+
shared_secret = curve.dh_compute_key(server_public_key)
56+
57+
client_public_key_bn = curve.public_key.to_bn
58+
client_auth_token = Webpush.decode64(auth)
59+
60+
info = "Content-Encoding: auth\0"
61+
context = create_context(curve.public_key, server_public_key)
62+
content_encryption_key_info = "Content-Encoding: aesgcm\0P-256#{context}"
63+
nonce_info = "Content-Encoding: nonce\0P-256#{context}"
64+
65+
prk = HKDF.new(shared_secret, salt: client_auth_token, algorithm: 'SHA256', info: info).next_bytes(32)
66+
67+
content_encryption_key = HKDF.new(prk, salt: salt, info: content_encryption_key_info).next_bytes(16)
68+
nonce = HKDF.new(prk, salt: salt, info: nonce_info).next_bytes(12)
69+
70+
decrypt_ciphertext(ciphertext, content_encryption_key, nonce)
71+
end
72+
73+
def create_context(client_public_key, server_public_key)
74+
c = client_public_key.to_bn.to_s(2)
75+
s = server_public_key.to_bn.to_s(2)
76+
context = "\0"
77+
context += [c.bytesize].pack("n*")
78+
context += c
79+
context += [s.bytesize].pack("n*")
80+
context += s
81+
context
82+
end
83+
84+
def decrypt_ciphertext(ciphertext, content_encryption_key, nonce)
85+
secret_data = ciphertext.byteslice(0, ciphertext.bytesize-16)
86+
auth = ciphertext.byteslice(ciphertext.bytesize-16, ciphertext.bytesize)
87+
decipher = OpenSSL::Cipher.new('aes-128-gcm')
88+
decipher.decrypt
89+
decipher.key = content_encryption_key
90+
decipher.iv = nonce
91+
decipher.auth_tag = auth
92+
93+
decrypted = decipher.update(secret_data) + decipher.final
94+
95+
e = decrypted.byteslice(0, 2)
96+
expect(e).to eq("\0\0")
97+
98+
decrypted.byteslice(2, decrypted.bytesize-2)
99+
end
100+
end
101+
end

0 commit comments

Comments
 (0)