Skip to content

Commit ff658db

Browse files
committed
[test] WebPush gem-like integration test for EC + AES GCM
1 parent 8b4566b commit ff658db

File tree

3 files changed

+320
-0
lines changed

3 files changed

+320
-0
lines changed

src/test/ruby/ec/ece.rb

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
class ECE
2+
3+
KEY_LENGTH=16
4+
TAG_LENGTH=16
5+
NONCE_LENGTH=12
6+
SHA256_LENGTH=32
7+
8+
def self.hmac_hash(key, input)
9+
digest = OpenSSL::Digest.new('sha256')
10+
OpenSSL::HMAC.digest(digest, key, input)
11+
end
12+
13+
def self.hkdf_extract(salt, ikm) #ikm stays for input keying material
14+
hmac_hash(salt,ikm)
15+
end
16+
17+
def self.get_info(type, client_public, server_public)
18+
cl_len_no = [client_public.size].pack('n')
19+
sv_len_no = [server_public.size].pack('n')
20+
"Content-Encoding: #{type}\x00P-256\x00#{cl_len_no}#{client_public}#{sv_len_no}#{server_public}"
21+
end
22+
23+
def self.extract_key(params)
24+
raise "Salt must be 16-bytes long" unless params[:salt].length==16
25+
26+
input_key = params[:key]
27+
auth = false
28+
if params.has_key?(:auth) # Encrypted Content Encoding, March 11 2016, http://httpwg.org/http-extensions/draft-ietf-httpbis-encryption-encoding.html
29+
auth = true
30+
input = HKDF.new(input_key, {salt: params[:auth] , algorithm: 'sha256', info: "Content-Encoding: auth\x00"})
31+
input_key = input.next_bytes(SHA256_LENGTH)
32+
secret = HKDF.new(input_key, {salt: params[:salt], algorithm: 'sha256', info: get_info("aesgcm", params[:user_public_key], params[:server_public_key])})
33+
nonce = HKDF.new(input_key, salt: params[:salt], algorithm: 'sha256', info: get_info("nonce", params[:user_public_key], params[:server_public_key]))
34+
else
35+
secret = HKDF.new(input_key, {salt: params[:salt], algorithm: 'sha256', info: "Content-Encoding: aesgcm128"})
36+
nonce = HKDF.new(input_key, salt: params[:salt], algorithm: 'sha256', info: "Content-Encoding: nonce")
37+
end
38+
39+
{key: secret.next_bytes(KEY_LENGTH), nonce: nonce.next_bytes(NONCE_LENGTH), auth: auth}
40+
end
41+
42+
def self.generate_nonce(nonce, counter)
43+
raise "Nonce must be #{NONCE_LENGTH} bytes long." unless nonce.length == NONCE_LENGTH
44+
output = nonce.dup
45+
integer = nonce[-6..-1].unpack('B*')[0].to_i(2) #taking last 6 bytes, treating as integer
46+
x = ((integer ^ counter) & 0xffffff) + ((((integer / 0x1000000) ^ (counter / 0x1000000)) & 0xffffff) * 0x1000000)
47+
bytestring = x.to_s(16).length < 12 ? "0"*(12-x.to_s(16).length)+x.to_s(16) : x.to_s(16) #it's for correct handling of cases when generated integer is less than 6 bytes
48+
output[-6..-1] = [bytestring].pack('H*') #without it packing would produce less than 6 bytes
49+
output #I didn't find pack directive for such usage, so there is a such solution
50+
end
51+
52+
def self.encrypt(data, params)
53+
key = extract_key(params)
54+
rs = params[:rs] ? params [:rs] : 4096
55+
padsize = params[:padsize] ? params [:padsize] : 0
56+
raise "The rs parameter must be greater than 1." if rs <= 1
57+
rs -=1 #this ensures encrypted data cannot be truncated
58+
result = ""
59+
pad_bytes = 1
60+
if params[:auth] # old spec used 1 byte for padding, latest one always uses 2 bytes
61+
pad_bytes = 2
62+
end
63+
64+
counter = 0
65+
(0..data.length).step(rs-pad_bytes+1) do |i|
66+
block = encrypt_record(key, counter, data[i..i+rs-pad_bytes], padsize)
67+
result += block
68+
counter +=1
69+
end
70+
result
71+
end
72+
73+
def self.decrypt(data, params)
74+
key = extract_key(params)
75+
rs = params[:rs] ? params [:rs] : 4096
76+
raise "The rs parameter must be greater than 1." if rs <= 1
77+
rs += TAG_LENGTH
78+
raise "Message is truncated" if data.length % rs == 0
79+
result = ""
80+
counter = 0
81+
(0..data.length).step(rs) do |i|
82+
block = decrypt_record(key, counter, data[i..i+rs-1])
83+
result += block
84+
counter +=1
85+
end
86+
result
87+
end
88+
89+
def self.decrypt_record(params, counter, buffer, pad=0)
90+
gcm = OpenSSL::Cipher.new('aes-128-gcm')
91+
gcm.decrypt
92+
gcm.key = params[:key]
93+
gcm.iv = generate_nonce(params[:nonce], counter)
94+
pad_bytes = 1
95+
if params[:auth] # old spec used 1 byte for padding, latest one always uses 2 bytes
96+
pad_bytes = 2
97+
end
98+
raise "Block is too small" if buffer.length <= TAG_LENGTH+pad_bytes
99+
gcm.auth_tag = buffer[-TAG_LENGTH..-1]
100+
decrypted = gcm.update(buffer[0..-TAG_LENGTH-1]) + gcm.final
101+
102+
if params[:auth]
103+
padding_length = decrypted[0..1].unpack("n")[0]
104+
raise "Padding is too big" if padding_length+2 > decrypted.length
105+
padding = decrypted[2..padding_length]
106+
raise "Wrong padding" unless padding = "\x00"*padding_length
107+
return decrypted[2+padding_length..-1]
108+
else
109+
padding_length = decrypted[0].unpack("C")[0]
110+
raise "Padding is too big" if padding_length+1 > decrypted.length
111+
padding = decrypted[1..padding_length]
112+
raise "Wrong padding" unless padding = "\x00"*padding_length
113+
return decrypted[1..-1]
114+
end
115+
end
116+
117+
def self.encrypt_record(params, counter, buffer, pad=0)
118+
gcm = OpenSSL::Cipher.new('aes-128-gcm')
119+
gcm.encrypt
120+
gcm.key = params[:key]
121+
gcm.iv = generate_nonce(params[:nonce], counter)
122+
gcm.auth_data = ""
123+
padding = ""
124+
if params[:auth]
125+
padding = [pad].pack('n') + "\x00"*pad # 2 bytes, big endian, then n zero bytes of padding
126+
buf = padding+buffer
127+
record = gcm.update(buf)
128+
else
129+
record = gcm.update("\x00"+buffer) # 1 padding byte, not fully implemented
130+
end
131+
enc = record + gcm.final + gcm.auth_tag
132+
enc
133+
end
134+
135+
136+
end

src/test/ruby/ec/hkdf.rb

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
require 'stringio'
2+
3+
class HKDF
4+
DefaultAlgorithm = 'SHA256'
5+
DefaultReadSize = 512 * 1024
6+
7+
def initialize(source, options = {})
8+
source = StringIO.new(source) if source.is_a?(String)
9+
10+
algorithm = options.fetch(:algorithm, DefaultAlgorithm)
11+
@digest = OpenSSL::Digest.new(algorithm)
12+
@info = options.fetch(:info, '')
13+
14+
salt = options[:salt]
15+
salt = 0.chr * @digest.digest_length if salt.nil? or salt.empty?
16+
read_size = options.fetch(:read_size, DefaultReadSize)
17+
18+
@prk = _generate_prk(salt, source, read_size)
19+
@position = 0
20+
@blocks = []
21+
@blocks << ''
22+
end
23+
24+
def algorithm
25+
@digest.name
26+
end
27+
28+
def max_length
29+
@max_length ||= @digest.digest_length * 255
30+
end
31+
32+
def seek(position)
33+
raise RangeError.new("cannot seek past #{max_length}") if position > max_length
34+
35+
@position = position
36+
end
37+
38+
def rewind
39+
seek(0)
40+
end
41+
42+
def next_bytes(length)
43+
new_position = length + @position
44+
raise RangeError.new("requested #{length} bytes, only #{max_length} available") if new_position > max_length
45+
46+
_generate_blocks(new_position)
47+
48+
start = @position
49+
@position = new_position
50+
51+
@blocks.join('').slice(start, length)
52+
end
53+
54+
def next_hex_bytes(length)
55+
next_bytes(length).unpack('H*').first
56+
end
57+
58+
def _generate_prk(salt, source, read_size)
59+
hmac = OpenSSL::HMAC.new(salt, @digest)
60+
while block = source.read(read_size)
61+
hmac.update(block)
62+
end
63+
hmac.digest
64+
end
65+
66+
def _generate_blocks(length)
67+
start = @blocks.size
68+
block_count = (length.to_f / @digest.digest_length).ceil
69+
start.upto(block_count) do |n|
70+
@blocks << OpenSSL::HMAC.digest(@digest, @prk, @blocks[n - 1] + @info + n.chr)
71+
end
72+
end
73+
end
74+

src/test/ruby/ec/test_ec.rb

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,116 @@ def test_encrypt
8181
assert_equal expected, server.dh_compute_key(client_public_key)
8282
end
8383

84+
def test_encrypt_integration # inspired by WebPush
85+
require File.expand_path('ece.rb', File.dirname(__FILE__)) unless defined? ECE
86+
require File.expand_path('hkdf.rb', File.dirname(__FILE__)) unless defined? HKDF
87+
88+
p256dh = Base64.urlsafe_encode64 generate_ecdh_key
89+
auth = Base64.urlsafe_encode64 Random.new.bytes(16)
90+
91+
payload = Encryption.encrypt("Hello World", p256dh, auth)
92+
93+
encrypted = payload.fetch(:ciphertext)
94+
95+
decrypted_data = ECE.decrypt(encrypted,
96+
:key => payload.fetch(:shared_secret),
97+
:salt => payload.fetch(:salt),
98+
:server_public_key => payload.fetch(:server_public_key_bn),
99+
:user_public_key => Base64.urlsafe_decode64(p256dh),
100+
:auth => Base64.urlsafe_decode64(auth))
101+
102+
assert_equal "Hello World", decrypted_data
103+
end
104+
105+
def generate_ecdh_key(group = 'prime256v1')
106+
curve = OpenSSL::PKey::EC.new(group)
107+
curve.generate_key
108+
str = curve.public_key.to_bn.to_s(2)
109+
puts "curve.public_key.to_bn.to_s(2): #{str.inspect}" if $VERBOSE
110+
str
111+
end
112+
private :generate_ecdh_key
113+
114+
module Encryption # EC + (symmetric) AES GCM AAED encryption
115+
extend self
116+
117+
def encrypt(message, p256dh, auth)
118+
119+
group_name = "prime256v1"
120+
salt = Random.new.bytes(16)
121+
122+
server = OpenSSL::PKey::EC.new(group_name)
123+
server.generate_key
124+
server_public_key_bn = server.public_key.to_bn
125+
126+
group = OpenSSL::PKey::EC::Group.new(group_name)
127+
client_public_key_bn = OpenSSL::BN.new(Base64.urlsafe_decode64(p256dh), 2)
128+
129+
#puts client_public_key_bn.to_s if $VERBOSE
130+
131+
client_public_key = OpenSSL::PKey::EC::Point.new(group, client_public_key_bn)
132+
133+
shared_secret = server.dh_compute_key(client_public_key)
134+
135+
client_auth_token = Base64.urlsafe_decode64(auth)
136+
137+
prk = HKDF.new(shared_secret, :salt => client_auth_token, :algorithm => 'SHA256', :info => "Content-Encoding: auth\0").next_bytes(32)
138+
139+
context = create_context(client_public_key_bn, server_public_key_bn)
140+
141+
content_encryption_key_info = create_info('aesgcm', context)
142+
content_encryption_key = HKDF.new(prk, :salt => salt, :info => content_encryption_key_info).next_bytes(16)
143+
144+
nonce_info = create_info('nonce', context)
145+
nonce = HKDF.new(prk, :salt => salt, :info => nonce_info).next_bytes(12)
146+
147+
ciphertext = encrypt_payload(message, content_encryption_key, nonce)
148+
149+
{
150+
:ciphertext => ciphertext, :salt => salt, :shared_secret => shared_secret,
151+
:server_public_key_bn => convert16bit(server_public_key_bn)
152+
}
153+
end
154+
155+
private
156+
157+
def create_context(client_public_key, server_public_key)
158+
c = convert16bit(client_public_key)
159+
s = convert16bit(server_public_key)
160+
context = "\0"
161+
context += [c.bytesize].pack("n*")
162+
context += c
163+
context += [s.bytesize].pack("n*")
164+
context += s
165+
context
166+
end
167+
168+
def encrypt_payload(plaintext, content_encryption_key, nonce)
169+
cipher = OpenSSL::Cipher.new('aes-128-gcm')
170+
cipher.encrypt
171+
cipher.key = content_encryption_key
172+
cipher.iv = nonce
173+
padding = cipher.update("\0\0")
174+
text = cipher.update(plaintext)
175+
176+
e_text = padding + text + cipher.final
177+
e_tag = cipher.auth_tag
178+
179+
e_text + e_tag
180+
end
181+
182+
def create_info(type, context)
183+
info = "Content-Encoding: "
184+
info += type; info += "\0"; info += "P-256"; info += context
185+
info
186+
end
187+
188+
def convert16bit(key)
189+
[key.to_s(16)].pack("H*")
190+
end
191+
192+
end
193+
84194
def setup
85195
super
86196
self.class.disable_security_restrictions!

0 commit comments

Comments
 (0)