hpke 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c0ff20003913867f648466cb8136e73218c7dd917087c98f7d86ef8fd2049a11
4
+ data.tar.gz: 47901efa463ad51eebe04bface377e9dda4c24497a4b12039c64bc966b98c2d9
5
+ SHA512:
6
+ metadata.gz: 1bbd503dd86c43bcb19fc188338cc9119b35a2bc5e6812a1404c19f8e198b7c71c0a18d3e1ff81d9ee3658575044995dba5ac092229b61298c7e79ed2e679faf
7
+ data.tar.gz: ac0ac0b079d9a8b8c3d7e60d6d90e0109d31150ae44f433b815d1a62fce9b3bb70dc707112c7a7155f94f3e7df171a11fa3ef97ba4fccbf509a7d911866b170b
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in hpke.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
9
+
10
+ gem "rspec", "~> 3.0"
data/Gemfile.lock ADDED
@@ -0,0 +1,37 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ hpke (0.1.0)
5
+ openssl (~> 3.0.0)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ diff-lcs (1.5.0)
11
+ openssl (3.0.2)
12
+ rake (13.0.6)
13
+ rspec (3.12.0)
14
+ rspec-core (~> 3.12.0)
15
+ rspec-expectations (~> 3.12.0)
16
+ rspec-mocks (~> 3.12.0)
17
+ rspec-core (3.12.2)
18
+ rspec-support (~> 3.12.0)
19
+ rspec-expectations (3.12.3)
20
+ diff-lcs (>= 1.2.0, < 2.0)
21
+ rspec-support (~> 3.12.0)
22
+ rspec-mocks (3.12.6)
23
+ diff-lcs (>= 1.2.0, < 2.0)
24
+ rspec-support (~> 3.12.0)
25
+ rspec-support (3.12.1)
26
+
27
+ PLATFORMS
28
+ arm64-darwin-22
29
+ x86_64-linux
30
+
31
+ DEPENDENCIES
32
+ hpke!
33
+ rake (~> 13.0)
34
+ rspec (~> 3.0)
35
+
36
+ BUNDLED WITH
37
+ 2.4.10
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Ryo Kajiwara
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # hpke-rb
2
+
3
+ Hybrid Public Key Encryption (HPKE; [RFC 9180](https://datatracker.ietf.org/doc/html/rfc9180)) in Ruby
4
+
5
+ ## Note
6
+
7
+ This is still in very early development, so:
8
+
9
+ - APIs are subject to change
10
+ - Especially the instantation interface of KEM and HPKE suite
11
+ - This is tested against test vectors supplied by the authors of the RFC, but is not formally audited for security. Please be aware of this when using in production.
12
+
13
+ ## Supported Features
14
+
15
+ Supports all modes, KEMs, AEAD functions in RFC 9180.
16
+
17
+ - HPKE Modes
18
+ - Base
19
+ - PSK
20
+ - Auth
21
+ - AuthPSK
22
+ - Key Encapsulation Mechanisms (KEMs)
23
+ - DHKEM(P-256, HKDF-SHA256)
24
+ - DHKEM(P-384, HKDF-SHA384)
25
+ - DHKEM(P-521, HKDF-SHA512)
26
+ - DHKEM(X25519, HKDF-SHA256)
27
+ - DHKEM(X448, HKDF-SHA512)
28
+ - Key Derivation Functions (KDFs)
29
+ - HKDF-SHA256
30
+ - HKDF-SHA384
31
+ - HKDF-SHA512
32
+ - AEAD Functions
33
+ - AES-128-GCM
34
+ - AES-256-GCM
35
+ - ChaCha20-Poly1305
36
+ - Export Only
37
+
38
+ ## Supported Environments
39
+
40
+ - OpenSSL 3.0 or higher
41
+ - This is due to the changes in instantiation of public/private key pairs from OpenSSL 1.1 series to OpenSSL 3.0 series
42
+ - Ruby 3.1 or higher
43
+ - Ruby 3.1 comes with OpenSSL 3.0 support
44
+
45
+ ## Installation
46
+
47
+ Install the gem and add to the application's Gemfile by executing:
48
+
49
+ $ bundle add hpke
50
+
51
+ If bundler is not being used to manage dependencies, install the gem by executing:
52
+
53
+ $ gem install hpke
54
+
55
+ ## Usage
56
+
57
+ (example shows Base mode)
58
+
59
+ ```ruby
60
+ # instantiate HPKE suite
61
+ # first 2 parameters specify the curve and hash to be used in the KEM,
62
+ # third parameter specifies the hash to be used in the KDF (of HPKE suite),
63
+ # fourth parameter specifies the AEAD function
64
+
65
+ # we will generate a different instance just for demonstration to show that nothing secret is stored in the HPKE suite instance
66
+ hpke_s = HPKE.new(:x25519, :sha256, :sha256, :aes_128_gcm)
67
+ hpke_r = HPKE.new(:x25519, :sha256, :sha256, :aes_128_gcm)
68
+
69
+ # get a OpenSSL::PKey::PKey instance by either generating a key or loading a key from a PEM
70
+ # see https://ruby-doc.org/3.2.2/exts/openssl/OpenSSL/PKey/PKey.html
71
+ # on the sender's end
72
+ sender_key_pair = OpenSSL::PKey.generate_key('X25519')
73
+ receiver_key_pair = OpenSSL::PKey.generate_key('X25519')
74
+
75
+ # Sender setup
76
+ # Sender knows the receiver's public key (in PEM format, in most cases), so load that into a PKey
77
+ receiver_public_key = OpenSSL::PKey.read(receiver_key_pair.public_to_pem)
78
+ encap_result = hpke_s.setup_base_s(receiver_public_key, 'info')
79
+ # This returns a hash where :enc key contains the key encapsulation,
80
+ # and :context_s contains a HPKE::ContextS instance, which is used for encryption later on.
81
+ context_s = encap_result[:context_s]
82
+ # Note that :enc contains raw bytes, so when passing to the receiver, it is advised to pass the encapsulation using Base64-encoded values
83
+ enc_base64 = Base64.encode64(encap_result[:enc])
84
+
85
+ # Then on the receiver's end
86
+ # decode the encapsulated value
87
+ enc = Base64.decode64(enc_base64)
88
+ # then use that value to generate a HPKE::ContextR instance to use for decryption
89
+ context_r = hpke_r.setup_base_r(enc, receiver_key_pair, 'info')
90
+
91
+ # sender encrypts a message
92
+ # note that the "sequence number" is incremented each time `seal` and `open` is used
93
+ ciphertext = context_s.seal('authentication_associated_data', 'plaintext')
94
+ # this is also in raw bytes, so when sending, encoding with Base64 is advised
95
+
96
+ # then receiver decrypts the ciphertext
97
+ context_r.open('authentication_associated_data', ciphertext)
98
+ ```
99
+
100
+ - Curve names (parameter 1)
101
+ - `:p_256`, `:p_384`, `:p_521`, `:x25519`, `:x448`
102
+ - Note: `:p_256` corresponds to `prime256v1`, `:p_384` corresponds to `secp384r1`, and `:p_521` corresponds to `secp521r1` in OpenSSL
103
+ - Hash names (parameter 2 and 3)
104
+ - `:sha256`, `:sha384`, `:sha512`
105
+ - AEAD function names (parameter 4)
106
+ - `:aes_128_gcm`, `:aes_256_gcm`, `:chacha20_poly1305`, `:`
107
+
108
+ ## Development
109
+
110
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
111
+
112
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
113
+
114
+ ## Contributing
115
+
116
+ Bug reports and pull requests are welcome on GitHub at https://github.com/sylph01/hpke-rb.
117
+
118
+ ## License
119
+
120
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/lib/hpke/dhkem.rb ADDED
@@ -0,0 +1,368 @@
1
+ require 'openssl'
2
+ require 'securerandom'
3
+ require_relative 'hkdf'
4
+ require_relative 'util'
5
+
6
+ class HPKE::DHKEM
7
+ include HPKE::Util
8
+
9
+ def initialize(hash_name)
10
+ @hkdf = HPKE::HKDF.new(hash_name)
11
+ end
12
+
13
+ def encap(pk_r)
14
+ pkey_e = generate_key_pair()
15
+ dh = pkey_e.derive(pk_r)
16
+ enc = serialize_public_key(pkey_e)
17
+
18
+ pkrm = serialize_public_key(pk_r)
19
+ kem_context = enc + pkrm
20
+
21
+ shared_secret = extract_and_expand(dh, kem_context, kem_suite_id)
22
+ {
23
+ shared_secret: shared_secret,
24
+ enc: enc
25
+ }
26
+ end
27
+
28
+ def auth_encap(pk_r, sk_s)
29
+ pkey_e = generate_key_pair()
30
+ dh = pkey_e.derive(pk_r) + sk_s.derive(pk_r)
31
+ enc = serialize_public_key(pkey_e)
32
+
33
+ pkrm = serialize_public_key(pk_r)
34
+ pksm = serialize_public_key(sk_s)
35
+ kem_context = enc + pkrm + pksm
36
+
37
+ shared_secret = extract_and_expand(dh, kem_context, kem_suite_id)
38
+ {
39
+ shared_secret: shared_secret,
40
+ enc: enc
41
+ }
42
+ end
43
+
44
+ def decap(enc, sk_r)
45
+ pk_e = deserialize_public_key(enc)
46
+ dh = sk_r.derive(pk_e)
47
+
48
+ pkrm = serialize_public_key(sk_r)
49
+ kem_context = enc + pkrm
50
+
51
+ shared_secret = extract_and_expand(dh, kem_context, kem_suite_id)
52
+ shared_secret
53
+ end
54
+
55
+ def auth_decap(enc, sk_r, pk_s)
56
+ pk_e = deserialize_public_key(enc)
57
+ dh = sk_r.derive(pk_e) + sk_r.derive(pk_s)
58
+
59
+ pkrm = serialize_public_key(sk_r)
60
+ pksm = serialize_public_key(pk_s)
61
+ kem_context = enc + pkrm + pksm
62
+
63
+ shared_secret = extract_and_expand(dh, kem_context, kem_suite_id)
64
+ shared_secret
65
+ end
66
+
67
+ def encap_fixed(pk_r, ikm_e)
68
+ pkey_e = derive_key_pair(ikm_e)
69
+ dh = pkey_e.derive(pk_r)
70
+ enc = serialize_public_key(pkey_e)
71
+
72
+ pkrm = serialize_public_key(pk_r)
73
+ kem_context = enc + pkrm
74
+
75
+ shared_secret = extract_and_expand(dh, kem_context, kem_suite_id)
76
+ {
77
+ shared_secret: shared_secret,
78
+ enc: enc
79
+ }
80
+ end
81
+
82
+ def auth_encap_fixed(pk_r, sk_s, ikm_e)
83
+ pkey_e = derive_key_pair(ikm_e)
84
+ dh = pkey_e.derive(pk_r) + sk_s.derive(pk_r)
85
+ enc = serialize_public_key(pkey_e)
86
+
87
+ pkrm = serialize_public_key(pk_r)
88
+ pksm = serialize_public_key(sk_s)
89
+ kem_context = enc + pkrm + pksm
90
+
91
+ shared_secret = extract_and_expand(dh, kem_context, kem_suite_id)
92
+ {
93
+ shared_secret: shared_secret,
94
+ enc: enc
95
+ }
96
+ end
97
+
98
+ def generate_key_pair
99
+ derive_key_pair(SecureRandom.random_bytes(n_sk))
100
+ end
101
+
102
+ # ---- functions for Edwards curves (X25519, X448) ----
103
+ def derive_key_pair(ikm)
104
+ dkp_prk = @hkdf.labeled_extract('', 'dkp_prk', ikm, kem_suite_id)
105
+ sk = @hkdf.labeled_expand(dkp_prk, 'sk', '', n_sk, kem_suite_id)
106
+
107
+ create_key_pair_from_secret(sk)
108
+ end
109
+
110
+ def serialize_public_key(pk)
111
+ pk.public_to_der[-n_pk, n_pk]
112
+ end
113
+
114
+ def deserialize_public_key(serialized_pk)
115
+ asn1_seq_pub = OpenSSL::ASN1.Sequence([
116
+ OpenSSL::ASN1.Sequence([
117
+ OpenSSL::ASN1.ObjectId(asn1_oid)
118
+ ]),
119
+ OpenSSL::ASN1.BitString(serialized_pk)
120
+ ])
121
+
122
+ OpenSSL::PKey.read(asn1_seq_pub.to_der)
123
+ end
124
+
125
+ private
126
+
127
+ def kem_suite_id
128
+ 'KEM' + i2osp(kem_id, 2)
129
+ end
130
+
131
+ def extract_and_expand(dh, kem_context, suite_id)
132
+ eae_prk = @hkdf.labeled_extract('', 'eae_prk', dh, suite_id)
133
+
134
+ @hkdf.labeled_expand(eae_prk, 'shared_secret', kem_context, n_secret, suite_id)
135
+ end
136
+ end
137
+
138
+ class HPKE::DHKEM::EC < HPKE::DHKEM
139
+ def derive_key_pair(ikm)
140
+ dkp_prk = @hkdf.labeled_extract('', 'dkp_prk', ikm, kem_suite_id)
141
+ sk = 0
142
+ counter = 0
143
+ while sk == 0 || sk >= order do
144
+ raise Exception.new('DeriveKeyPairError') if counter > 255
145
+
146
+ bytes = @hkdf.labeled_expand(dkp_prk, 'candidate', i2osp(counter, 1), n_sk, kem_suite_id)
147
+ bytes[0] = (bytes[0].ord & bitmask).chr
148
+ sk = os2ip(bytes)
149
+ counter += 1
150
+ end
151
+
152
+ create_key_pair_from_secret(bytes)
153
+ end
154
+
155
+ def create_key_pair_from_secret(secret)
156
+ asn1_seq = OpenSSL::ASN1.Sequence([
157
+ OpenSSL::ASN1.Integer(1),
158
+ OpenSSL::ASN1.OctetString(secret),
159
+ OpenSSL::ASN1.ObjectId(curve_name, 0, :EXPLICIT)
160
+ ])
161
+
162
+ OpenSSL::PKey.read(asn1_seq.to_der)
163
+ end
164
+
165
+ def serialize_public_key(pk)
166
+ pk.public_key.to_bn.to_s(2)
167
+ end
168
+
169
+ def deserialize_public_key(serialized_pk)
170
+ asn1_seq = OpenSSL::ASN1.Sequence([
171
+ OpenSSL::ASN1.Sequence([
172
+ OpenSSL::ASN1.ObjectId("id-ecPublicKey"),
173
+ OpenSSL::ASN1.ObjectId(curve_name)
174
+ ]),
175
+ OpenSSL::ASN1.BitString(serialized_pk)
176
+ ])
177
+
178
+ OpenSSL::PKey.read(asn1_seq.to_der)
179
+ end
180
+ end
181
+
182
+ class HPKE::DHKEM::EC::P_256 < HPKE::DHKEM::EC
183
+ def kem_id
184
+ 0x0010
185
+ end
186
+
187
+ private
188
+
189
+ def n_secret
190
+ 32
191
+ end
192
+
193
+ def n_enc
194
+ 65
195
+ end
196
+
197
+ def n_pk
198
+ 65
199
+ end
200
+
201
+ def n_sk
202
+ 32
203
+ end
204
+
205
+ def order
206
+ 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551
207
+ end
208
+
209
+ def curve_name
210
+ 'prime256v1'
211
+ end
212
+
213
+ def bitmask
214
+ 0xff
215
+ end
216
+ end
217
+
218
+ class HPKE::DHKEM::EC::P_384 < HPKE::DHKEM::EC
219
+ def kem_id
220
+ 0x0011
221
+ end
222
+
223
+ private
224
+
225
+ def n_secret
226
+ 48
227
+ end
228
+
229
+ def n_enc
230
+ 97
231
+ end
232
+
233
+ def n_pk
234
+ 97
235
+ end
236
+
237
+ def n_sk
238
+ 48
239
+ end
240
+
241
+ def order
242
+ 0xffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf581a0db248b0a77aecec196accc52973
243
+ end
244
+
245
+ def curve_name
246
+ 'secp384r1'
247
+ end
248
+
249
+ def bitmask
250
+ 0xff
251
+ end
252
+ end
253
+
254
+ class HPKE::DHKEM::EC::P_521 < HPKE::DHKEM::EC
255
+ def kem_id
256
+ 0x0012
257
+ end
258
+
259
+ private
260
+
261
+ def n_secret
262
+ 64
263
+ end
264
+
265
+ def n_enc
266
+ 133
267
+ end
268
+
269
+ def n_pk
270
+ 133
271
+ end
272
+
273
+ def n_sk
274
+ 66
275
+ end
276
+
277
+ def order
278
+ 0x01fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa51868783bf2f966b7fcc0148f709a5d03bb5c9b8899c47aebb6fb71e91386409
279
+ end
280
+
281
+ def curve_name
282
+ 'secp521r1'
283
+ end
284
+
285
+ def bitmask
286
+ 0x01
287
+ end
288
+ end
289
+
290
+ class HPKE::DHKEM::X25519 < HPKE::DHKEM
291
+ def kem_id
292
+ 0x0020
293
+ end
294
+
295
+ def create_key_pair_from_secret(secret)
296
+ asn1_seq = OpenSSL::ASN1.Sequence([
297
+ OpenSSL::ASN1.Integer(0),
298
+ OpenSSL::ASN1.Sequence([
299
+ OpenSSL::ASN1.ObjectId(asn1_oid)
300
+ ]),
301
+ OpenSSL::ASN1.OctetString("\x04\x20" + secret)
302
+ ])
303
+
304
+ OpenSSL::PKey.read(asn1_seq.to_der)
305
+ end
306
+
307
+ private
308
+
309
+ def n_secret
310
+ 32
311
+ end
312
+
313
+ def n_enc
314
+ 32
315
+ end
316
+
317
+ def n_pk
318
+ 32
319
+ end
320
+
321
+ def n_sk
322
+ 32
323
+ end
324
+
325
+ def asn1_oid
326
+ '1.3.101.110'
327
+ end
328
+ end
329
+
330
+ class HPKE::DHKEM::X448 < HPKE::DHKEM
331
+ def kem_id
332
+ 0x0021
333
+ end
334
+
335
+ def create_key_pair_from_secret(secret)
336
+ asn1_seq = OpenSSL::ASN1.Sequence([
337
+ OpenSSL::ASN1.Integer(0),
338
+ OpenSSL::ASN1.Sequence([
339
+ OpenSSL::ASN1.ObjectId(asn1_oid)
340
+ ]),
341
+ OpenSSL::ASN1.OctetString("\x04\x38" + secret)
342
+ ])
343
+
344
+ OpenSSL::PKey.read(asn1_seq.to_der)
345
+ end
346
+
347
+ private
348
+
349
+ def n_secret
350
+ 64
351
+ end
352
+
353
+ def n_enc
354
+ 56
355
+ end
356
+
357
+ def n_pk
358
+ 56
359
+ end
360
+
361
+ def n_sk
362
+ 56
363
+ end
364
+
365
+ def asn1_oid
366
+ '1.3.101.111'
367
+ end
368
+ end
data/lib/hpke/hkdf.rb ADDED
@@ -0,0 +1,100 @@
1
+ require 'openssl'
2
+ require_relative 'util'
3
+
4
+ class HPKE::HKDF
5
+ include HPKE::Util
6
+
7
+ attr_reader :kdf_id
8
+
9
+ ALGORITHMS = {
10
+ sha256: {
11
+ name: 'SHA256',
12
+ kdf_id: 1
13
+ },
14
+ sha384: {
15
+ name: 'SHA384',
16
+ kdf_id: 2
17
+ },
18
+ sha512: {
19
+ name: 'SHA512',
20
+ kdf_id: 3
21
+ }
22
+ }
23
+
24
+ def n_h
25
+ @digest.digest_length
26
+ end
27
+
28
+ def initialize(alg_name)
29
+ if algorithm = ALGORITHMS[alg_name]
30
+ @digest = OpenSSL::Digest.new(algorithm[:name])
31
+ @kdf_id = algorithm[:kdf_id]
32
+ else
33
+ raise Exception.new('Unknown hash algorithm')
34
+ end
35
+ end
36
+
37
+ def hmac(key, data)
38
+ OpenSSL::HMAC.digest(@digest, key, data)
39
+ end
40
+
41
+ def extract(salt, ikm)
42
+ hmac(salt, ikm)
43
+ end
44
+
45
+ def expand(prk, info, len)
46
+ n = (len.to_f / @digest.digest_length).ceil
47
+ t = ['']
48
+ for i in 0..n do
49
+ t << hmac(prk, t[i] + info + (i + 1).chr)
50
+ end
51
+ t_concat = t.join
52
+ t_concat[0..(len - 1)]
53
+ end
54
+
55
+ def labeled_extract(salt, label, ikm, suite_id)
56
+ labeled_ikm = 'HPKE-v1' + suite_id + label + ikm
57
+ extract(salt, labeled_ikm)
58
+ end
59
+
60
+ def labeled_expand(prk, label, info, l, suite_id)
61
+ labeled_info = i2osp(l, 2) + 'HPKE-v1' + suite_id + label + info
62
+ expand(prk, labeled_info, l)
63
+ end
64
+ end
65
+
66
+ class HPKE::HKDF::HMAC_SHA256 < HPKE::HKDF
67
+ private
68
+
69
+ def digest_algorithm
70
+ 'SHA256'
71
+ end
72
+
73
+ def kdf_id
74
+ 1
75
+ end
76
+ end
77
+
78
+ class HPKE::HKDF::HMAC_SHA384 < HPKE::HKDF
79
+ private
80
+
81
+ def digest_algorithm
82
+ 'SHA384'
83
+ end
84
+
85
+ def kdf_id
86
+ 2
87
+ end
88
+ end
89
+
90
+ class HPKE::HKDF::HMAC_SHA512 < HPKE::HKDF
91
+ private
92
+
93
+ def digest_algorithm
94
+ 'SHA512'
95
+ end
96
+
97
+ def kdf_id
98
+ 3
99
+ end
100
+ end
data/lib/hpke/util.rb ADDED
@@ -0,0 +1,26 @@
1
+ module HPKE::Util
2
+ def i2osp(n, w)
3
+ # check n > 0 and n < 256 ** w
4
+ ret = []
5
+ for i in 0..(w - 1)
6
+ ret[w - (i + 1)] = n % 256
7
+ n = n >> 8
8
+ end
9
+ ret.map(&:chr).join
10
+ end
11
+
12
+ def os2ip(x)
13
+ x.bytes.reduce { |a, b| a * 256 + b }
14
+ end
15
+
16
+ def xor(a, b)
17
+ if a.bytesize != b.bytesize
18
+ return false
19
+ end
20
+ c = ""
21
+ for i in 0 .. (a.bytesize - 1)
22
+ c += (a.bytes[i] ^ b.bytes[i]).chr
23
+ end
24
+ c
25
+ end
26
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class HPKE
4
+ VERSION = "0.1.0"
5
+ end
data/lib/hpke.rb ADDED
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hpke/version"
4
+
5
+ require 'openssl'
6
+ require_relative './hpke/dhkem'
7
+ require_relative './hpke/util'
8
+
9
+ class HPKE
10
+ include HPKE::Util
11
+
12
+ attr_reader :kem, :hkdf, :aead_name, :n_k, :n_n, :n_t
13
+
14
+ MODES = {
15
+ base: 0x00,
16
+ psk: 0x01,
17
+ auth: 0x02,
18
+ auth_psk: 0x03
19
+ }
20
+ CIPHERS = {
21
+ aes_128_gcm: {
22
+ name: 'aes-128-gcm',
23
+ aead_id: 0x0001,
24
+ n_k: 16,
25
+ n_n: 12,
26
+ n_t: 16
27
+ },
28
+ aes_256_gcm: {
29
+ name: 'aes-256-gcm',
30
+ aead_id: 0x0002,
31
+ n_k: 32,
32
+ n_n: 12,
33
+ n_t: 16
34
+ },
35
+ chacha20_poly1305: {
36
+ name: 'chacha20-poly1305',
37
+ aead_id: 0x0003,
38
+ n_k: 32,
39
+ n_n: 12,
40
+ n_t: 16
41
+ },
42
+ export_only: {
43
+ aead_id: 0xffff
44
+ }
45
+ }
46
+ HASHES = {
47
+ sha256: {
48
+ name: 'SHA256',
49
+ kdf_id: 1
50
+ },
51
+ sha384: {
52
+ name: 'SHA384',
53
+ kdf_id: 2
54
+ },
55
+ sha512: {
56
+ name: 'SHA512',
57
+ kdf_id: 3
58
+ }
59
+ }
60
+ KEM_CURVES = {
61
+ p_256: DHKEM::EC::P_256,
62
+ p_384: DHKEM::EC::P_384,
63
+ p_521: DHKEM::EC::P_521,
64
+ x25519: DHKEM::X25519,
65
+ x448: DHKEM::X448
66
+ }
67
+
68
+ def initialize(kem_curve_name, kem_hash, kdf_hash, aead_cipher)
69
+ raise Exception.new('Unsupported KEM curve name') if KEM_CURVES[kem_curve_name].nil?
70
+ raise Exception.new('Unsupported AEAD cipher name') if CIPHERS[aead_cipher].nil?
71
+
72
+ @kem = KEM_CURVES[kem_curve_name].new(kem_hash)
73
+ @hkdf = HKDF.new(kdf_hash)
74
+ @aead_name = CIPHERS[aead_cipher][:name]
75
+ @aead_id = CIPHERS[aead_cipher][:aead_id]
76
+ @n_k = CIPHERS[aead_cipher][:n_k]
77
+ @n_n = CIPHERS[aead_cipher][:n_n]
78
+ @n_t = CIPHERS[aead_cipher][:n_t]
79
+ end
80
+
81
+ # public facing APIs
82
+ def setup_base_s(pk_r, info)
83
+ encap_result = @kem.encap(pk_r)
84
+ {
85
+ enc: encap_result[:enc],
86
+ context_s: key_schedule_s(MODES[:base], encap_result[:shared_secret], info, DEFAULT_PSK, DEFAULT_PSK_ID)
87
+ }
88
+ end
89
+
90
+ def setup_base_r(enc, sk_r, info)
91
+ shared_secret = @kem.decap(enc, sk_r)
92
+ key_schedule_r(MODES[:base], shared_secret, info, DEFAULT_PSK, DEFAULT_PSK_ID)
93
+ end
94
+
95
+ def setup_psk_s(pk_r, info, psk, psk_id)
96
+ encap_result = @kem.encap(pk_r)
97
+ {
98
+ enc: encap_result[:enc],
99
+ context_s: key_schedule_s(MODES[:psk], encap_result[:shared_secret], info, psk, psk_id)
100
+ }
101
+ end
102
+
103
+ def setup_psk_r(enc, sk_r, info, psk, psk_id)
104
+ shared_secret = @kem.decap(enc, sk_r)
105
+ key_schedule_r(MODES[:psk], shared_secret, info, psk, psk_id)
106
+ end
107
+
108
+ def setup_auth_s(pk_r, info, sk_s)
109
+ encap_result = @kem.auth_encap(pk_r, sk_s)
110
+ {
111
+ enc: encap_result[:enc],
112
+ context_s: key_schedule_s(MODES[:auth], encap_result[:shared_secret], info, DEFAULT_PSK, DEFAULT_PSK_ID)
113
+ }
114
+ end
115
+
116
+ def setup_auth_r(enc, sk_r, info, pk_s)
117
+ shared_secret = @kem.auth_decap(enc, sk_r, pk_s)
118
+ key_schedule_r(MODES[:auth], shared_secret, info, DEFAULT_PSK, DEFAULT_PSK_ID)
119
+ end
120
+
121
+ def setup_auth_psk_s(pk_r, info, psk, psk_id, sk_s)
122
+ encap_result = @kem.auth_encap(pk_r, sk_s)
123
+ {
124
+ enc: encap_result[:enc],
125
+ context_s: key_schedule_s(MODES[:auth_psk], encap_result[:shared_secret], info, psk, psk_id)
126
+ }
127
+ end
128
+
129
+ def setup_auth_psk_r(enc, sk_r, info, psk, psk_id, pk_s)
130
+ shared_secret = @kem.auth_decap(enc, sk_r, pk_s)
131
+ key_schedule_r(MODES[:auth_psk], shared_secret, info, psk, psk_id)
132
+ end
133
+
134
+ # for testing purposes
135
+ def setup_base_s_fixed(pk_r, info, ikm_e)
136
+ encap_result = @kem.encap_fixed(pk_r, ikm_e)
137
+ {
138
+ enc: encap_result[:enc],
139
+ context_s: key_schedule_s(MODES[:base], encap_result[:shared_secret], info, DEFAULT_PSK, DEFAULT_PSK_ID)
140
+ }
141
+ end
142
+
143
+ def setup_psk_s_fixed(pk_r, info, psk, psk_id, ikm_e)
144
+ encap_result = @kem.encap_fixed(pk_r, ikm_e)
145
+ {
146
+ enc: encap_result[:enc],
147
+ context_s: key_schedule_s(MODES[:psk], encap_result[:shared_secret], info, psk, psk_id)
148
+ }
149
+ end
150
+
151
+ def setup_auth_s_fixed(pk_r, info, sk_s, ikm_e)
152
+ encap_result = @kem.auth_encap_fixed(pk_r, sk_s, ikm_e)
153
+ {
154
+ enc: encap_result[:enc],
155
+ context_s: key_schedule_s(MODES[:auth], encap_result[:shared_secret], info, DEFAULT_PSK, DEFAULT_PSK_ID)
156
+ }
157
+ end
158
+
159
+ def setup_auth_psk_s_fixed(pk_r, info, psk, psk_id, sk_s, ikm_e)
160
+ encap_result = @kem.auth_encap_fixed(pk_r, sk_s, ikm_e)
161
+ {
162
+ enc: encap_result[:enc],
163
+ context_s: key_schedule_s(MODES[:auth_psk], encap_result[:shared_secret], info, psk, psk_id)
164
+ }
165
+ end
166
+
167
+ def export(exporter_secret, exporter_context, len)
168
+ @hkdf.labeled_expand(exporter_secret, 'sec', exporter_context, len, suite_id)
169
+ end
170
+
171
+ private
172
+
173
+ def suite_id
174
+ 'HPKE' + i2osp(@kem.kem_id, 2) + i2osp(@hkdf.kdf_id, 2) + i2osp(@aead_id, 2)
175
+ end
176
+
177
+ DEFAULT_PSK = ''
178
+ DEFAULT_PSK_ID = ''
179
+
180
+ def verify_psk_inputs(mode, psk, psk_id)
181
+ got_psk = (psk != DEFAULT_PSK)
182
+ got_psk_id = (psk_id != DEFAULT_PSK_ID)
183
+
184
+ raise Exception.new('Inconsistent PSK inputs') if got_psk != got_psk_id
185
+ raise Exception.new('PSK input provided when not needed') if got_psk && [MODES[:base], MODES[:auth]].include?(mode)
186
+ raise Exception.new('Missing required PSK input') if !got_psk && [MODES[:psk], MODES[:auth_psk]].include?(mode)
187
+
188
+ true
189
+ end
190
+
191
+ def key_schedule(mode, shared_secret, info, psk = '', psk_id = '')
192
+ verify_psk_inputs(mode, psk, psk_id)
193
+
194
+ psk_id_hash = @hkdf.labeled_extract('', 'psk_id_hash', psk_id, suite_id)
195
+ info_hash = @hkdf.labeled_extract('', 'info_hash', info, suite_id)
196
+ key_schedule_context = mode.chr + psk_id_hash + info_hash
197
+
198
+ secret = @hkdf.labeled_extract(shared_secret, 'secret', psk, suite_id)
199
+
200
+ unless @aead_id == CIPHERS[:export_only][:aead_id]
201
+ key = @hkdf.labeled_expand(secret, 'key', key_schedule_context, @n_k, suite_id)
202
+ base_nonce = @hkdf.labeled_expand(secret, 'base_nonce', key_schedule_context, @n_n, suite_id)
203
+ end
204
+ exporter_secret = @hkdf.labeled_expand(secret, 'exp', key_schedule_context, @hkdf.n_h, suite_id)
205
+
206
+ {
207
+ key: key,
208
+ base_nonce: base_nonce,
209
+ sequence_number: 0,
210
+ exporter_secret: exporter_secret
211
+ }
212
+ end
213
+
214
+ def key_schedule_s(mode, shared_secret, info, psk = '', psk_id = '')
215
+ ks = key_schedule(mode, shared_secret, info, psk, psk_id)
216
+ HPKE::ContextS.new(ks, self)
217
+ end
218
+
219
+ def key_schedule_r(mode, shared_secret, info, psk = '', psk_id = '')
220
+ ks = key_schedule(mode, shared_secret, info, psk, psk_id)
221
+ HPKE::ContextR.new(ks, self)
222
+ end
223
+ end
224
+
225
+ class HPKE::Context
226
+ include HPKE::Util
227
+ attr_reader :key, :base_nonce, :sequence_number, :exporter_secret
228
+
229
+ def initialize(initializer_hash, hpke)
230
+ @hpke = hpke
231
+ @key = initializer_hash[:key]
232
+ @base_nonce = initializer_hash[:base_nonce]
233
+ @sequence_number = initializer_hash[:sequence_number]
234
+ @exporter_secret = initializer_hash[:exporter_secret]
235
+ end
236
+
237
+ def compute_nonce(seq)
238
+ seq_bytes = i2osp(seq, @hpke.n_n)
239
+ xor(@base_nonce, seq_bytes)
240
+ end
241
+
242
+ def increment_seq
243
+ raise Exception.new('MessageLimitReachedError') if @sequence_number >= (1 << (8 * @hpke.n_n)) - 1
244
+
245
+ @sequence_number += 1
246
+ end
247
+
248
+ def export(exporter_context, len)
249
+ @hpke.export(@exporter_secret, exporter_context, len)
250
+ end
251
+ end
252
+
253
+ class HPKE::ContextS < HPKE::Context
254
+ def seal(aad, pt)
255
+ raise Exception.new('AEAD is export only') if @hpke.aead_name == :export_only
256
+
257
+ ct = cipher_seal(@key, compute_nonce(@sequence_number), aad, pt)
258
+ increment_seq
259
+ ct
260
+ end
261
+
262
+ private
263
+
264
+ def cipher_seal(key, nonce, aad, pt)
265
+ cipher = OpenSSL::Cipher.new(@hpke.aead_name)
266
+ cipher.encrypt
267
+ cipher.key = key
268
+ cipher.iv = nonce
269
+ cipher.auth_data = aad
270
+ cipher.padding = 0
271
+ s = cipher.update(pt) << cipher.final
272
+ s + cipher.auth_tag
273
+ end
274
+ end
275
+
276
+ class HPKE::ContextR < HPKE::Context
277
+ def open(aad, ct)
278
+ raise Exception.new('AEAD is export only') if @hpke.aead_name == :export_only
279
+
280
+ pt = cipher_open(@key, compute_nonce(@sequence_number), aad, ct)
281
+ # TODO: catch openerror then send out own openerror
282
+ increment_seq
283
+ pt
284
+ end
285
+
286
+ private
287
+
288
+ def cipher_open(key, nonce, aad, ct)
289
+ ct_body = ct[0, ct.length - @hpke.n_t]
290
+ tag = ct[-@hpke.n_t, @hpke.n_t]
291
+ cipher = OpenSSL::Cipher.new(@hpke.aead_name)
292
+ cipher.decrypt
293
+ cipher.key = key
294
+ cipher.iv = nonce
295
+ cipher.auth_tag = tag
296
+ cipher.auth_data = aad
297
+ cipher.padding = 0
298
+ cipher.update(ct_body) << cipher.final
299
+ end
300
+ end
data/sig/hpke.rbs ADDED
@@ -0,0 +1,4 @@
1
+ class HPKE
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hpke
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ryo Kajiwara
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-07-15 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: openssl
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 3.0.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 3.0.0
27
+ description: Hybrid Public Key Encryption (HPKE; RFC 9180) on Ruby
28
+ email:
29
+ - sylph01@s01.ninja
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - ".rspec"
35
+ - Gemfile
36
+ - Gemfile.lock
37
+ - LICENSE.txt
38
+ - README.md
39
+ - Rakefile
40
+ - lib/hpke.rb
41
+ - lib/hpke/dhkem.rb
42
+ - lib/hpke/hkdf.rb
43
+ - lib/hpke/util.rb
44
+ - lib/hpke/version.rb
45
+ - sig/hpke.rbs
46
+ homepage: https://github.com/sylph01/hpke-rb
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ homepage_uri: https://github.com/sylph01/hpke-rb
51
+ source_code_uri: https://github.com/sylph01/hpke-rb
52
+ post_install_message:
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 3.1.0
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 3.4.10
68
+ signing_key:
69
+ specification_version: 4
70
+ summary: Hybrid Public Key Encryption (HPKE; RFC 9180) on Ruby
71
+ test_files: []