noise-ruby 0.10.0 → 0.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a5b0a36466f2dfcc8a03a1e3d70d3010d150e76dcca4c2669d70ab1d849d3cb5
4
- data.tar.gz: 4e3cea23eb34312dbc98b0b4562d045e272047d29a842027242eb28c47b0a615
3
+ metadata.gz: 876220b34ef23fb45d18cb2dd0dfd7627b5bb202e51f57af9c0cd5a62c211626
4
+ data.tar.gz: 662c47506e70adf05928da7b8c5e3fb48503e0043242f4127459297e29dba476
5
5
  SHA512:
6
- metadata.gz: ebcd5a9d334e4b7697fec40d1936d2918d71bc143aca44e5711584ce1c3b2c19fa6b64048d57c8d1b4780eced81e5f982279aeb562ba82bd4809fa6b2f38b238
7
- data.tar.gz: 69fcabcd507ad92edab302bc5576e128df0777ce890095c7765f2dc461ef512084cdcb595b4ac451f77baf2a115575787e41d39692a51f8fb8dcad7555eb9b63
6
+ metadata.gz: 8db525a248cd3a66f1597736745d70470ef109a3e7c7acd9758fda79c5c45aa78350d833c38a4d6d3d2ae9f8a06dd3b6b7f89a6c4928c12ea3dfe899efca4906
7
+ data.tar.gz: 420bd4dbeff8a66570f9d71ec219375aafeb9e5a45d76b1f23f6d9dfe47776b73da860b918f585214983224fea488370dc423f27dbd7c8905c95f33d8a4dce13
data/.dockerignore ADDED
@@ -0,0 +1,8 @@
1
+ # Gemfile.lock is deliberately not listed: the Dockerfile copies it so the image installs the same
2
+ # versions the mounted working tree will ask for.
3
+ .git/
4
+ .rspec_status
5
+ .ruby-lsp/
6
+ coverage/
7
+ pkg/
8
+ tmp/
@@ -0,0 +1,56 @@
1
+ # This workflow uses actions that are not certified by GitHub.
2
+ # They are provided by a third-party and are governed by
3
+ # separate terms of service, privacy policy, and support
4
+ # documentation.
5
+ # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake
6
+ # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby
7
+
8
+ name: Ruby
9
+
10
+ on:
11
+ push:
12
+ branches: [ master ]
13
+ pull_request:
14
+ branches: [ master ]
15
+
16
+ jobs:
17
+ lint:
18
+ runs-on: ubuntu-latest
19
+ strategy:
20
+ matrix:
21
+ ruby:
22
+ - '3.3'
23
+ steps:
24
+ - name: Checkout code
25
+ uses: actions/checkout@v4
26
+
27
+ - name: Set up Ruby ${{ matrix.ruby }}
28
+ uses: ruby/setup-ruby@v1
29
+ with:
30
+ ruby-version: ${{ matrix.ruby }}
31
+ bundler-cache: true
32
+ - name: Lint code for consistent style
33
+ run: bundle exec rubocop -f github
34
+
35
+ test:
36
+ runs-on: ubuntu-latest
37
+ strategy:
38
+ matrix:
39
+ ruby:
40
+ - '3.0'
41
+ - '3.1'
42
+ - '3.2'
43
+ - '3.3'
44
+ - '3.4'
45
+ steps:
46
+ - name: Checkout code
47
+ uses: actions/checkout@v4
48
+
49
+ - name: Set up Ruby ${{ matrix.ruby }}
50
+ uses: ruby/setup-ruby@v1
51
+ with:
52
+ ruby-version: ${{ matrix.ruby }}
53
+ bundler-cache: true
54
+ - name: Run tests
55
+ run: bundle exec rspec spec
56
+
data/.gitignore CHANGED
@@ -10,3 +10,6 @@
10
10
 
11
11
  # rspec failure tracking
12
12
  .rspec_status
13
+
14
+ AGENTS.md
15
+ CLAUDE.md
data/.rubocop.yml CHANGED
@@ -1,12 +1,11 @@
1
- Metrics/BlockLength:
1
+ require:
2
+ - rubocop-rspec
3
+ Metrics:
2
4
  Enabled: false
3
5
 
4
- Metrics/LineLength:
6
+ Layout/LineLength:
5
7
  Max: 120
6
8
 
7
- Metrics/MethodLength:
8
- Max: 30
9
-
10
9
  Style/Documentation:
11
10
  Enabled: false
12
11
 
@@ -20,7 +19,30 @@ Style/WordArray:
20
19
  MinSize: 100
21
20
 
22
21
  AllCops:
23
- TargetRubyVersion: 2.4.1
22
+ TargetRubyVersion: 3.0
23
+ SuggestExtensions: false
24
24
 
25
- Naming/UncommunicativeMethodParamName:
25
+ Naming/MethodParameterName:
26
26
  MinNameLength: 1
27
+
28
+ RSpec:
29
+ Exclude:
30
+ - "spec/factories/*"
31
+ RSpec/AnyInstance:
32
+ Enabled: false
33
+ RSpec/ContextWording:
34
+ Enabled: false
35
+ RSpec/ExampleLength:
36
+ Enabled: false
37
+ RSpec/MultipleExpectations:
38
+ Enabled: false
39
+ RSpec/NamedSubject:
40
+ Enabled: false
41
+ RSpec/NestedGroups:
42
+ Enabled: false
43
+ RSpec/MultipleMemoizedHelpers:
44
+ Enabled: false
45
+ RSpec/SpecFilePathFormat:
46
+ Enabled: false
47
+ RSpec/IndexedLet:
48
+ Enabled: false
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 2.5.1
1
+ 3.3.5
data/Dockerfile ADDED
@@ -0,0 +1,29 @@
1
+ # check=skip=FromPlatformFlagConstDisallowed
2
+ # The test suite loads the x86_64 libsecp256k1 / libgoldilocks bundled in spec/lib, and
3
+ # spec/spec_helper.rb points at them unconditionally, so this image has to be linux/amd64
4
+ # even on an arm64 host. It mirrors the CI runner (ubuntu x86_64).
5
+ FROM --platform=linux/amd64 ruby:3.3
6
+
7
+ # libsodium: dlopen'd by rbnacl, which lib/noise.rb requires unconditionally.
8
+ # cargo: the blake3 gem is a Rust extension and is built at install time.
9
+ # git: noise.gemspec calls `git ls-files` while the gemspec is evaluated.
10
+ RUN apt-get update -qq \
11
+ && apt-get install -y --no-install-recommends build-essential git libsodium23 cargo \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ WORKDIR /work
15
+
16
+ # Install the gems from the dependency definitions alone so the layer stays cached when only
17
+ # the source changes. Gems live in BUNDLE_PATH (/usr/local/bundle), outside /work, so mounting
18
+ # the working tree over /work at run time does not hide them.
19
+ #
20
+ # Gemfile.lock is gitignored, hence the glob: it is copied when the host has one so the image
21
+ # installs the exact versions the mounted lock will ask for at run time, and skipped otherwise.
22
+ COPY Gemfile* noise.gemspec ./
23
+ COPY lib/noise/version.rb lib/noise/version.rb
24
+ RUN bundle install
25
+
26
+ # Run with the working tree mounted:
27
+ # docker run --rm -v "$PWD":/work noise-dev
28
+ # docker run --rm -v "$PWD":/work noise-dev bundle exec rubocop
29
+ CMD ["bundle", "exec", "rspec"]
data/Gemfile CHANGED
@@ -4,11 +4,6 @@ source 'https://rubygems.org'
4
4
 
5
5
  git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
6
6
 
7
- # Specify your gem's dependencies in noise.gemspec
7
+ # Specify your gem's dependencies in noise.gemspec. The optional ed448, secp256k1-ruby and blake3
8
+ # backends are development dependencies there, so the test suite covers all of them.
8
9
  gemspec
9
-
10
- # Use secp256k1 as ecdh function
11
- # gem 'secp256k1-ruby'
12
-
13
- # Use blake3 as hash function
14
- # gem 'blake3'
data/README.md CHANGED
@@ -59,6 +59,12 @@ After installing, define an environment variable as follows:
59
59
 
60
60
  $ export LIBGOLDILOCKS=/usr/local/lib/libgoldilocks.so
61
61
 
62
+ and, add this line to your Gemfile:
63
+
64
+ ```
65
+ gem 'ed448'
66
+ ```
67
+
62
68
  If you use Secp256k1, you must install [libsecp256k1](https://github.com/bitcoin-core/secp256k1).
63
69
 
64
70
  $ git clone https://github.com/bitcoin-core/secp256k1
@@ -3,10 +3,12 @@
3
3
  module Noise
4
4
  module Connection
5
5
  class Base
6
- attr_reader :protocol, :handshake_started, :handshake_finished, :handshake_hash, :handshake_state
7
- attr_reader :cipher_state_encrypt, :cipher_state_decrypt, :cipher_state_handshake
6
+ # The Noise spec caps a handshake or transport message at 65535 bytes.
7
+ MAX_MESSAGE_LENGTH = 65_535
8
+
9
+ attr_reader :protocol, :handshake_started, :handshake_finished, :handshake_hash, :handshake_state,
10
+ :cipher_state_encrypt, :cipher_state_decrypt, :cipher_state_handshake, :s, :rs
8
11
  attr_accessor :psks, :prologue
9
- attr_reader :s, :rs
10
12
 
11
13
  def initialize(name, keypairs: { s: nil, e: nil, rs: nil, re: nil })
12
14
  @protocol = Protocol.create(name)
@@ -27,11 +29,15 @@ module Noise
27
29
  @handshake_started = true
28
30
  end
29
31
 
32
+ # Restarts the handshake with a fallback pattern, carrying over the keys of the aborted one.
33
+ #
34
+ # The roles swap here: the party that wrote the aborted message now reads, and the one that
35
+ # failed to read it now writes. Both sides are already in that state, so @next_message is
36
+ # deliberately left as it is rather than reset through initialize_next_message.
30
37
  def fallback(fallback_name)
31
38
  @protocol = Protocol.create(fallback_name)
32
39
  @handshake_started = false
33
40
  @handshake_finished = false
34
- # initialize_next_message
35
41
  @local_keypairs = { e: @handshake_state.e, s: @handshake_state.s }
36
42
  @remote_keys = { re: @handshake_state.re, rs: @handshake_state.rs }
37
43
  start_handshake
@@ -40,7 +46,6 @@ module Noise
40
46
  def initialise_handshake_state
41
47
  @handshake_state = Noise::State::HandshakeState.new(
42
48
  self,
43
- protocol,
44
49
  initiator?,
45
50
  @prologue,
46
51
  @local_keypairs,
@@ -56,10 +61,13 @@ module Noise
56
61
  raise Noise::Exceptions::NoiseHandshakeError if @next_message != :write
57
62
  raise Noise::Exceptions::NoiseHandshakeError if @handshake_finished
58
63
 
64
+ if @handshake_state.expected_message_length(payload.bytesize) > MAX_MESSAGE_LENGTH
65
+ raise Noise::Exceptions::NoiseHandshakeError, 'Message exceeds the maximum length.'
66
+ end
67
+
59
68
  @next_message = :read
60
69
  buffer = +''
61
- result = @handshake_state.write_message(payload, buffer)
62
- @handshake_finished = true if result
70
+ @handshake_finished = @handshake_state.write_message(payload, buffer)
63
71
  buffer
64
72
  end
65
73
 
@@ -68,11 +76,12 @@ module Noise
68
76
  raise Noise::Exceptions::NoiseHandshakeError unless @handshake_started
69
77
  raise Noise::Exceptions::NoiseHandshakeError if @next_message != :read
70
78
  raise Noise::Exceptions::NoiseHandshakeError if @handshake_finished
79
+ raise Noise::Exceptions::NoiseHandshakeError, 'Message exceeds the maximum length.' if
80
+ data.bytesize > MAX_MESSAGE_LENGTH
71
81
 
72
82
  @next_message = :write
73
83
  buffer = +''
74
- result = @handshake_state.read_message(data, buffer)
75
- @handshake_finished = true if result
84
+ @handshake_finished = @handshake_state.read_message(data, buffer)
76
85
  buffer
77
86
  end
78
87
 
@@ -94,23 +103,19 @@ module Noise
94
103
  raise Noise::Exceptions::NoisePSKError if @protocol.pattern.psk_count != @psks.count
95
104
  end
96
105
 
97
- def valid_keypairs?
106
+ def missing_keypairs?
98
107
  keypairs = @local_keypairs.merge(@remote_keys)
99
108
  @protocol.pattern.required_keypairs(initiator?).any? { |keypair| !keypairs[keypair] }
100
109
  end
101
110
 
102
111
  def validate
103
- validate_psk! if psk_handshake?
112
+ validate_psk! if @protocol.psk?
104
113
 
105
- raise Noise::Exceptions::NoiseValidationError if valid_keypairs?
114
+ raise Noise::Exceptions::NoiseValidationError if missing_keypairs?
106
115
 
107
116
  true
108
117
  end
109
118
 
110
- def psk_handshake?
111
- @protocol.is_psk_handshake
112
- end
113
-
114
119
  def handshake_done(_c1, _c2)
115
120
  @handshake_hash = @symmetric_state.handshake_hash
116
121
  @s = @handshake_state.s
@@ -4,6 +4,7 @@ module Noise
4
4
  module Exceptions
5
5
  class DecryptError < StandardError
6
6
  def initialize(cause)
7
+ super
7
8
  @cause = cause
8
9
  end
9
10
  end
@@ -4,6 +4,7 @@ module Noise
4
4
  module Exceptions
5
5
  class EncryptError < StandardError
6
6
  def initialize(cause)
7
+ super
7
8
  @cause = cause
8
9
  end
9
10
  end
@@ -3,7 +3,10 @@
3
3
  module Noise
4
4
  module Exceptions
5
5
  class InvalidPublicKeyError < StandardError
6
+ attr_reader :public_key
7
+
6
8
  def initialize(public_key)
9
+ super("Invalid public key: #{public_key.unpack1('H*')}")
7
10
  @public_key = public_key
8
11
  end
9
12
  end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noise
4
+ module Exceptions
5
+ class MissingDependencyError < RuntimeError
6
+ end
7
+ end
8
+ end
@@ -6,6 +6,7 @@ module Noise
6
6
  autoload :EncryptError, 'noise/exceptions/encrypt_error'
7
7
  autoload :InvalidPublicKeyError, 'noise/exceptions/invalid_public_key_error'
8
8
  autoload :MaxNonceError, 'noise/exceptions/max_nonce_error'
9
+ autoload :MissingDependencyError, 'noise/exceptions/missing_dependency_error'
9
10
  autoload :ProtocolNameError, 'noise/exceptions/protocol_name_error'
10
11
  autoload :NoiseHandshakeError, 'noise/exceptions/noise_handshake_error'
11
12
  autoload :NoiseValidationError, 'noise/exceptions/noise_validation_error'
@@ -7,28 +7,29 @@ module Noise
7
7
  MAX_NONCE = 2**64 - 1
8
8
 
9
9
  def encrypt(k, n, ad, plaintext)
10
- cipher = OpenSSL::Cipher::AES.new(256, :GCM).encrypt
10
+ cipher = OpenSSL::Cipher.new('aes-256-gcm').encrypt
11
11
  cipher.key = k
12
12
  cipher.iv = nonce_to_bytes(n)
13
13
  cipher.auth_data = ad
14
- cipher.update(plaintext) + cipher.final + cipher.auth_tag
14
+ update(cipher, plaintext) + cipher.final + cipher.auth_tag
15
15
  rescue OpenSSL::Cipher::CipherError => e
16
- raise Noise::Exceptions::EncryptError.new(e)
16
+ raise Noise::Exceptions::EncryptError, "Encrypt failed. #{e.message}", e.backtrace
17
17
  end
18
18
 
19
19
  def decrypt(k, n, ad, ciphertext)
20
- cipher = OpenSSL::Cipher::AES.new(256, :GCM).decrypt
20
+ cipher = OpenSSL::Cipher.new('aes-256-gcm').decrypt
21
21
  cipher.key = k
22
22
  cipher.iv = nonce_to_bytes(n)
23
23
  cipher.auth_data = ad
24
- cipher.auth_tag = ciphertext[-16..-1]
25
- cipher.update(ciphertext[0...-16]) + cipher.final
24
+ cipher.auth_tag = ciphertext[-16..]
25
+ update(cipher, ciphertext[0...-16]) + cipher.final
26
26
  rescue OpenSSL::Cipher::CipherError => e
27
- raise Noise::Exceptions::DecryptError.new(e)
27
+ raise Noise::Exceptions::DecryptError, "Decrpyt failed. #{e.message}", e.backtrace
28
28
  end
29
29
 
30
+ # 4 zero bytes followed by n as a big-endian 64 bit integer.
30
31
  def nonce_to_bytes(n)
31
- "\x00" * 4 + format('%16x', n).htb
32
+ "\x00" * 4 + [n].pack('Q>')
32
33
  end
33
34
 
34
35
  # Returns a new 32-byte cipher key as a pseudorandom function of k.
@@ -40,6 +41,16 @@ module Noise
40
41
  def rekey(k)
41
42
  encrypt(k, MAX_NONCE, '', "\x00" * 32)[0...32]
42
43
  end
44
+
45
+ private
46
+
47
+ # A zero-length payload is normal in a Noise message, but the openssl gem shipped with
48
+ # Ruby 3.0 raises ArgumentError('data must not be empty') instead of returning ''.
49
+ def update(cipher, data)
50
+ return String.new if data.empty?
51
+
52
+ cipher.update(data)
53
+ end
43
54
  end
44
55
  end
45
56
  end
@@ -10,18 +10,19 @@ module Noise
10
10
  cipher = RbNaCl::AEAD::ChaCha20Poly1305IETF.new(String.new(k).force_encoding('ASCII-8BIT'))
11
11
  cipher.encrypt(nonce_to_bytes(n), plaintext, ad)
12
12
  rescue ::RbNaCl::CryptoError => e
13
- raise Noise::Exceptions::EncryptError.new(e)
13
+ raise Noise::Exceptions::EncryptError, "Encrypt failed. #{e.message}", e.backtrace
14
14
  end
15
15
 
16
16
  def decrypt(k, n, ad, ciphertext)
17
17
  cipher = RbNaCl::AEAD::ChaCha20Poly1305IETF.new(String.new(k).force_encoding('ASCII-8BIT'))
18
18
  cipher.decrypt(nonce_to_bytes(n), ciphertext, ad)
19
19
  rescue ::RbNaCl::CryptoError => e
20
- raise Noise::Exceptions::DecryptError.new(e)
20
+ raise Noise::Exceptions::DecryptError, "Decrpyt failed. #{e.message}", e.backtrace
21
21
  end
22
22
 
23
+ # 4 zero bytes followed by n as a little-endian 64 bit integer.
23
24
  def nonce_to_bytes(n)
24
- "\x00" * 4 + format('%16x', n).htb.reverse
25
+ "\x00" * 4 + [n].pack('Q<')
25
26
  end
26
27
 
27
28
  # Returns a new 32-byte cipher key as a pseudorandom function of k.
@@ -31,7 +32,7 @@ module Noise
31
32
  # zerolen is a zero-length byte sequence, and zeros is a sequence of
32
33
  # 32 bytes filled with zeros.
33
34
  def rekey(k)
34
- encrypt(k, MAX_NONCE, '', "\x00" * 32)[0..32]
35
+ encrypt(k, MAX_NONCE, '', "\x00" * 32)[0...32]
35
36
  end
36
37
  end
37
38
  end
@@ -1,16 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- begin
4
- require 'ed448'
5
- Ed448.init
6
- rescue LoadError
7
- end
3
+ Noise.require_optional('ed448') { Ed448.init }
8
4
 
9
5
  module Noise
10
6
  module Functions
11
7
  module DH
12
8
  class ED448
13
- DHLEN = Ed448::X448::X448_PRIVATE_BYTES
9
+ # Ed448::X448::X448_PRIVATE_BYTES, spelled out so this file loads without the gem.
10
+ DHLEN = 56
11
+
12
+ def initialize
13
+ Noise.optional_dependency!('ed448')
14
+ end
14
15
 
15
16
  def generate_keypair
16
17
  private_key = SecureRandom.random_bytes(DHLEN)
@@ -1,14 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- begin
4
- require 'secp256k1'
5
- rescue LoadError
6
- end
3
+ Noise.require_optional 'secp256k1'
7
4
 
8
5
  module Noise
9
6
  module Functions
10
7
  module DH
11
8
  class Secp256k1
9
+ def initialize
10
+ Noise.optional_dependency!('secp256k1')
11
+ end
12
+
12
13
  def generate_keypair
13
14
  group = ECDSA::Group::Secp256k1
14
15
  private_key = 1 + SecureRandom.random_number(group.order - 1)
@@ -22,7 +23,7 @@ module Noise
22
23
  def dh(private_key, public_key)
23
24
  key = ::Secp256k1::PublicKey.new(pubkey: public_key, raw: true)
24
25
  key.ecdh(private_key)
25
- rescue ::Secp256k1::AssertError => _
26
+ rescue ::Secp256k1::AssertError
26
27
  raise Noise::Exceptions::InvalidPublicKeyError, public_key
27
28
  end
28
29
 
@@ -60,6 +60,7 @@ module Noise
60
60
  # @return context
61
61
  def init(out_len, key)
62
62
  raise ArgumentError if out_len.zero? || out_len > 32
63
+
63
64
  h = IV.dup
64
65
  h[0] ^= 0x01010000 ^ (key.size << 8) ^ out_len
65
66
  t = 0
@@ -168,6 +169,7 @@ module Noise
168
169
 
169
170
  class Context
170
171
  attr_accessor :b, :h, :t, :c, :out_len
172
+
171
173
  def initialize(b, h, t, c, out_len)
172
174
  @b = b
173
175
  @h = h
@@ -1,16 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- begin
4
- require 'blake3'
5
- rescue LoadError
6
- end
3
+ Noise.require_optional 'blake3'
7
4
 
8
5
  module Noise
9
6
  module Functions
10
7
  module Hash
11
8
  class Blake3
12
- HASHLEN = 64
13
- BLOCKLEN = 128
9
+ HASHLEN = 32
10
+ BLOCKLEN = 64
11
+
12
+ def initialize
13
+ Noise.optional_dependency!('blake3')
14
+ end
15
+
14
16
  def hash(data)
15
17
  ::Blake3.digest(data)
16
18
  end
@@ -10,14 +10,17 @@ module Noise
10
10
  autoload :Blake3, 'noise/functions/hash/blake3'
11
11
 
12
12
  def self.hmac_hash(key, data, digest)
13
- if digest.include?('SHA')
13
+ case digest
14
+ when /SHA/
14
15
  OpenSSL::HMAC.digest(OpenSSL::Digest.new(digest), key, data)
15
- elsif digest.include?('BLAKE2b')
16
+ when /BLAKE2b/
16
17
  Noise::Functions::Hash::Blake2bHMAC.new(key).update(data).digest
17
- elsif digest.include?('BLAKE2s')
18
+ when /BLAKE2s/
18
19
  Noise::Functions::Hash::Blake2sHMAC.new(key).update(data).digest
19
- elsif digest.include?('BLAKE3')
20
+ when /BLAKE3/
20
21
  Noise::Functions::Hash::Blake3HMAC.new(key).update(data).digest
22
+ else
23
+ raise Noise::Exceptions::ProtocolNameError, "Unsupported hash function: #{digest}"
21
24
  end
22
25
  end
23
26
 
@@ -30,9 +33,10 @@ module Noise
30
33
  def self.hkdf(chaining_key, input_key_material, num_outputs, digest)
31
34
  temp_key = hmac_hash(chaining_key, input_key_material, digest)
32
35
  output1 = hmac_hash(temp_key, "\x01", digest)
33
- output2 = hmac_hash(temp_key, output1 + "\x02", digest)
36
+ output2 = hmac_hash(temp_key, "#{output1}\u0002", digest)
34
37
  return [output1, output2] if num_outputs == 2
35
- output3 = hmac_hash(temp_key, output2 + "\x03", digest)
38
+
39
+ output3 = hmac_hash(temp_key, "#{output2}\u0003", digest)
36
40
  [output1, output2, output3]
37
41
  end
38
42
  end