noise-ruby 0.10.1 → 0.12.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: cffe6f7ffc789455f6b27bf3246cce8870cf57600e551d8f8edf91437ccd7afd
4
- data.tar.gz: f9c83042c8aa626a7a6e81ad62dd64ccf6c5515c33f1f64e12904b2ffcaa5fa9
3
+ metadata.gz: 4a3f4c1b701c2de6240cb5fc0fb2410e1790c390142b256e6b822af4fa327cef
4
+ data.tar.gz: a57735c978c1227262bc122e869aa6866e76a943654981fb57411d70cab34f80
5
5
  SHA512:
6
- metadata.gz: a1c2fe0e62f0939b48052d348511a97cedcf222316491d43f1d6128d146108587eedeb972d1fbce732a15aa7b73684f76e013e7a57c2f27fe85a3f7331a140bd
7
- data.tar.gz: fa9aea76917642726a8688fbc7b3d0f5fb9b32d1fe058ba7fbf81757d9754da92c1809633378b518753cf105e25e452f65975cfbef0f9b679ec12c68c5198d80
6
+ metadata.gz: 915a8da57226d70b24a5a7289325eba851d1e7e40c7d7ef0d0081411008f388a78eec00a760fe92f05ef82ccd707bc776bc8d4d385a6e14d1c3184714b7437b3
7
+ data.tar.gz: dd660c060e00d5c1d0c0e89c434743d6e91749ba09d16a8217e0031c641a2788636ba0fbf64bcfcc031ed84c75e290e3526d95af40fc6518a0211b1e292f7d77
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/
@@ -14,17 +14,43 @@ on:
14
14
  branches: [ master ]
15
15
 
16
16
  jobs:
17
- test:
18
-
17
+ lint:
19
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
20
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'
21
45
  steps:
22
- - uses: actions/checkout@v2
23
- - name: Set up Ruby
24
- uses: ruby/setup-ruby@v1
25
- with:
26
- ruby-version: 2.6
27
- - name: Install dependencies
28
- run: bundle install
29
- - name: Run tests
30
- run: bundle exec rake
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,15 +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
- Metrics/AbcSize:
11
- Max: 20
12
-
13
9
  Style/Documentation:
14
10
  Enabled: false
15
11
 
@@ -23,7 +19,30 @@ Style/WordArray:
23
19
  MinSize: 100
24
20
 
25
21
  AllCops:
26
- TargetRubyVersion: 2.4.1
22
+ TargetRubyVersion: 3.0
23
+ SuggestExtensions: false
27
24
 
28
- Naming/UncommunicativeMethodParamName:
25
+ Naming/MethodParameterName:
29
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.7.6
1
+ 3.3.5
data/Dockerfile ADDED
@@ -0,0 +1,29 @@
1
+ # check=skip=FromPlatformFlagConstDisallowed
2
+ # The test suite loads the x86_64 libsecp256k1 bundled in spec/lib, and spec/spec_helper.rb
3
+ # points at it unconditionally, so this image has to be linux/amd64 even on an arm64 host.
4
+ # 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
@@ -47,18 +47,6 @@ Or install it yourself as:
47
47
 
48
48
  $ gem install noise-ruby
49
49
 
50
- If you use Ed448 as DH function, you must install [libgoldilocks](https://github.com/otrv4/libgoldilocks).
51
-
52
- After installing, define an environment variable as follows:
53
-
54
- * on macOS
55
-
56
- $ export LIBGOLDILOCKS=/usr/local/lib/libgoldilocks.dylib
57
-
58
- * on Linux(Ubuntu)
59
-
60
- $ export LIBGOLDILOCKS=/usr/local/lib/libgoldilocks.so
61
-
62
50
  If you use Secp256k1, you must install [libsecp256k1](https://github.com/bitcoin-core/secp256k1).
63
51
 
64
52
  $ git clone https://github.com/bitcoin-core/secp256k1
@@ -132,6 +120,31 @@ cipher = initiator.encrypt("Hello, World!") # => "\xDA\xC7\xD7as\v\xFA\xCC,\xB3\
132
120
  plain = responder.decrypt(cipher) # => "Hello, World!"
133
121
  ```
134
122
 
123
+ #### Out-of-order transport messages
124
+
125
+ Each party counts the transport messages of each direction with a nonce. If the transport layer can
126
+ deliver messages out of order or lose them, it has to carry the nonce of each message, and the
127
+ receiver sets the nonce before decrypting. Restore the previous value if the message fails to
128
+ authenticate, so that the following messages are still decryptable.
129
+
130
+ ```
131
+ initiator.encryption_nonce # => 0
132
+ responder.decryption_nonce = 2 # decrypt the message numbered 2 next
133
+ plain = responder.decrypt(cipher)
134
+ ```
135
+
136
+ #### Rekey
137
+
138
+ Rekeying replaces the key of one direction with `REKEY(k)`, so that a key compromised later cannot
139
+ decrypt the messages that came before it. The nonce keeps counting. Both parties must rekey the
140
+ matching direction at the same point of the message stream; when that happens is up to the
141
+ application protocol.
142
+
143
+ ```
144
+ initiator.rekey_encryption
145
+ responder.rekey_decryption
146
+ ```
147
+
135
148
  ## Development
136
149
 
137
150
  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.
@@ -3,10 +3,15 @@
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. A transport message is
7
+ # the ciphertext, so the plaintext a caller may hand to encrypt is shorter by the
8
+ # authentication tag that ENCRYPT() appends.
9
+ MAX_MESSAGE_LENGTH = 65_535
10
+ MAX_PLAINTEXT_LENGTH = MAX_MESSAGE_LENGTH - Noise::State::CipherState::TAG_LENGTH
11
+
12
+ attr_reader :protocol, :handshake_started, :handshake_finished, :handshake_hash, :handshake_state,
13
+ :cipher_state_encrypt, :cipher_state_decrypt, :cipher_state_handshake, :s, :rs
8
14
  attr_accessor :psks, :prologue
9
- attr_reader :s, :rs
10
15
 
11
16
  def initialize(name, keypairs: { s: nil, e: nil, rs: nil, re: nil })
12
17
  @protocol = Protocol.create(name)
@@ -27,11 +32,15 @@ module Noise
27
32
  @handshake_started = true
28
33
  end
29
34
 
35
+ # Restarts the handshake with a fallback pattern, carrying over the keys of the aborted one.
36
+ #
37
+ # The roles swap here: the party that wrote the aborted message now reads, and the one that
38
+ # failed to read it now writes. Both sides are already in that state, so @next_message is
39
+ # deliberately left as it is rather than reset through initialize_next_message.
30
40
  def fallback(fallback_name)
31
41
  @protocol = Protocol.create(fallback_name)
32
42
  @handshake_started = false
33
43
  @handshake_finished = false
34
- # initialize_next_message
35
44
  @local_keypairs = { e: @handshake_state.e, s: @handshake_state.s }
36
45
  @remote_keys = { re: @handshake_state.re, rs: @handshake_state.rs }
37
46
  start_handshake
@@ -55,10 +64,13 @@ module Noise
55
64
  raise Noise::Exceptions::NoiseHandshakeError if @next_message != :write
56
65
  raise Noise::Exceptions::NoiseHandshakeError if @handshake_finished
57
66
 
67
+ if @handshake_state.expected_message_length(payload.bytesize) > MAX_MESSAGE_LENGTH
68
+ raise Noise::Exceptions::NoiseHandshakeError, 'Message exceeds the maximum length.'
69
+ end
70
+
58
71
  @next_message = :read
59
72
  buffer = +''
60
- result = @handshake_state.write_message(payload, buffer)
61
- @handshake_finished = true if result
73
+ @handshake_finished = @handshake_state.write_message(payload, buffer)
62
74
  buffer
63
75
  end
64
76
 
@@ -67,33 +79,95 @@ module Noise
67
79
  raise Noise::Exceptions::NoiseHandshakeError unless @handshake_started
68
80
  raise Noise::Exceptions::NoiseHandshakeError if @next_message != :read
69
81
  raise Noise::Exceptions::NoiseHandshakeError if @handshake_finished
82
+ raise Noise::Exceptions::NoiseHandshakeError, 'Message exceeds the maximum length.' if
83
+ data.bytesize > MAX_MESSAGE_LENGTH
70
84
 
71
85
  @next_message = :write
72
86
  buffer = +''
73
- result = @handshake_state.read_message(data, buffer)
74
- @handshake_finished = true if result
87
+ @handshake_finished = @handshake_state.read_message(data, buffer)
75
88
  buffer
76
89
  end
77
90
 
78
91
  def encrypt(data)
79
- raise Noise::Exceptions::NoiseHandshakeError unless @handshake_finished
92
+ cipher_state = transport_cipher_state(:encrypt)
93
+ if data.bytesize > MAX_PLAINTEXT_LENGTH
94
+ raise Noise::Exceptions::MessageTooLongError,
95
+ "Plaintext is #{data.bytesize} bytes, which exceeds the maximum of #{MAX_PLAINTEXT_LENGTH}."
96
+ end
80
97
 
81
- @cipher_state_encrypt.encrypt_with_ad('', data)
98
+ cipher_state.encrypt_with_ad('', data)
82
99
  end
83
100
 
84
101
  def decrypt(data)
85
- raise Noise::Exceptions::NoiseHandshakeError unless @handshake_finished
102
+ cipher_state = transport_cipher_state(:decrypt)
103
+ # Rejected before the cipher state is used, so an over-long message leaves n untouched
104
+ # and the connection usable, exactly as a failed decryption does.
105
+ if data.bytesize > MAX_MESSAGE_LENGTH
106
+ raise Noise::Exceptions::MessageTooLongError,
107
+ "Message is #{data.bytesize} bytes, which exceeds the maximum of #{MAX_MESSAGE_LENGTH}."
108
+ end
109
+
110
+ cipher_state.decrypt_with_ad('', data)
111
+ end
112
+
113
+ # @return [Integer] the nonce the next #encrypt call uses.
114
+ def encryption_nonce
115
+ transport_cipher_state(:encrypt).n
116
+ end
117
+
118
+ # @return [Integer] the nonce the next #decrypt call uses.
119
+ def decryption_nonce
120
+ transport_cipher_state(:decrypt).n
121
+ end
122
+
123
+ # Sets the nonce of the next #encrypt call. Needed when the transport layer numbers the
124
+ # messages itself instead of relying on the sender and the receiver counting in step.
125
+ #
126
+ # @param [Integer] nonce a value between 0 and CipherState::MAX_NONCE.
127
+ def encryption_nonce=(nonce)
128
+ transport_cipher_state(:encrypt).nonce = nonce
129
+ end
130
+
131
+ # Sets the nonce of the next #decrypt call. This is how the Noise spec handles transport
132
+ # messages that arrive out of order: the receiver sets n to the nonce of the message it is
133
+ # about to decrypt, and restores the previous value if the message fails to authenticate.
134
+ #
135
+ # @param [Integer] nonce a value between 0 and CipherState::MAX_NONCE.
136
+ def decryption_nonce=(nonce)
137
+ transport_cipher_state(:decrypt).nonce = nonce
138
+ end
139
+
140
+ # Replaces the key used by #encrypt with REKEY(k), so that the old key cannot decrypt the
141
+ # messages that follow. Both parties must rekey the matching direction at the same point of
142
+ # the message stream, which is up to the application protocol to agree on.
143
+ #
144
+ # @return [void]
145
+ def rekey_encryption
146
+ transport_cipher_state(:encrypt).rekey
147
+ nil
148
+ end
86
149
 
87
- @cipher_state_decrypt.decrypt_with_ad('', data)
150
+ # Replaces the key used by #decrypt with REKEY(k). See #rekey_encryption.
151
+ #
152
+ # @return [void]
153
+ def rekey_decryption
154
+ transport_cipher_state(:decrypt).rekey
155
+ nil
88
156
  end
89
157
 
90
158
  def validate_psk!
159
+ raise Noise::Exceptions::NoisePSKError, 'psks are not set.' if @psks.nil?
91
160
  # Invalid psk length! Has to be 32 bytes long
92
- raise Noise::Exceptions::NoisePSKError if @psks.any? { |psk| psk.bytesize != 32 }
93
- raise Noise::Exceptions::NoisePSKError if @protocol.pattern.psk_count != @psks.count
161
+ raise Noise::Exceptions::NoisePSKError, 'psks have to be 32 bytes long.' if
162
+ @psks.any? { |psk| psk.bytesize != 32 }
163
+
164
+ return if @protocol.pattern.psk_count == @psks.count
165
+
166
+ raise Noise::Exceptions::NoisePSKError,
167
+ "This protocol needs #{@protocol.pattern.psk_count} psks, got #{@psks.count}."
94
168
  end
95
169
 
96
- def valid_keypairs?
170
+ def missing_keypairs?
97
171
  keypairs = @local_keypairs.merge(@remote_keys)
98
172
  @protocol.pattern.required_keypairs(initiator?).any? { |keypair| !keypairs[keypair] }
99
173
  end
@@ -101,7 +175,7 @@ module Noise
101
175
  def validate
102
176
  validate_psk! if @protocol.psk?
103
177
 
104
- raise Noise::Exceptions::NoiseValidationError if valid_keypairs?
178
+ raise Noise::Exceptions::NoiseValidationError if missing_keypairs?
105
179
 
106
180
  true
107
181
  end
@@ -114,6 +188,23 @@ module Noise
114
188
  @symmetric_state = nil
115
189
  @cipher_state_handshake = nil
116
190
  end
191
+
192
+ private
193
+
194
+ # Returns the transport CipherState of the given direction.
195
+ #
196
+ # One-way patterns leave the direction the caller cannot use as nil, so the absence of a
197
+ # cipher state is reported the same way as a handshake that has not finished yet.
198
+ #
199
+ # @param [Symbol] direction :encrypt or :decrypt.
200
+ def transport_cipher_state(direction)
201
+ raise Noise::Exceptions::NoiseHandshakeError unless @handshake_finished
202
+
203
+ cipher_state = direction == :encrypt ? @cipher_state_encrypt : @cipher_state_decrypt
204
+ raise Noise::Exceptions::NoiseHandshakeError, "This party cannot #{direction} messages." unless cipher_state
205
+
206
+ cipher_state
207
+ end
117
208
  end
118
209
  end
119
210
  end
@@ -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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noise
4
+ module Exceptions
5
+ # Raised when a nonce given to CipherState#nonce= is not an unsigned 64-bit integer.
6
+ class InvalidNonceError < RuntimeError
7
+ end
8
+ end
9
+ 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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noise
4
+ module Exceptions
5
+ # Raised when a transport message would exceed, or does exceed, the 65535 byte limit the Noise
6
+ # specification places on every message. Kept apart from DecryptError so that a caller can tell a
7
+ # badly framed message from a failed authentication, which has very different security meaning.
8
+ class MessageTooLongError < StandardError
9
+ end
10
+ end
11
+ 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
@@ -4,8 +4,11 @@ module Noise
4
4
  module Exceptions
5
5
  autoload :DecryptError, 'noise/exceptions/decrypt_error'
6
6
  autoload :EncryptError, 'noise/exceptions/encrypt_error'
7
+ autoload :InvalidNonceError, 'noise/exceptions/invalid_nonce_error'
7
8
  autoload :InvalidPublicKeyError, 'noise/exceptions/invalid_public_key_error'
8
9
  autoload :MaxNonceError, 'noise/exceptions/max_nonce_error'
10
+ autoload :MessageTooLongError, 'noise/exceptions/message_too_long_error'
11
+ autoload :MissingDependencyError, 'noise/exceptions/missing_dependency_error'
9
12
  autoload :ProtocolNameError, 'noise/exceptions/protocol_name_error'
10
13
  autoload :NoiseHandshakeError, 'noise/exceptions/noise_handshake_error'
11
14
  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
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
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
@@ -20,8 +20,9 @@ module Noise
20
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
@@ -12,8 +12,20 @@ module Noise
12
12
  Noise::Key.new(ECDSA::Format::IntegerOctetString.encode(private_key, 32), public_key.to_bytes)
13
13
  end
14
14
 
15
+ # Computes the X25519 shared secret for the given remote public key.
16
+ #
17
+ # RbNaCl reports a public key it cannot use in two different ways: a wrong length
18
+ # raises RbNaCl::LengthError, and an all-zero or low-order point raises
19
+ # RbNaCl::CryptoError. Both are translated to InvalidPublicKeyError so that a
20
+ # caller handling a peer-supplied key rescues the same class for every DH function.
21
+ # The length is checked before the call so that RbNaCl::LengthError raised for a
22
+ # malformed private key keeps propagating as itself.
15
23
  def dh(private_key, public_key)
24
+ raise Noise::Exceptions::InvalidPublicKeyError, public_key unless public_key.bytesize == DHLEN
25
+
16
26
  RbNaCl::GroupElement.new(public_key).mult(private_key).to_bytes
27
+ rescue RbNaCl::CryptoError
28
+ raise Noise::Exceptions::InvalidPublicKeyError, public_key
17
29
  end
18
30
 
19
31
  def dhlen
@@ -1,21 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_force('ed448') { Ed448.init }
4
-
5
3
  module Noise
6
4
  module Functions
7
5
  module DH
6
+ # The 448 DH function of the Noise specification, which is X448 as defined by RFC 7748 and
7
+ # not the Ed448 signature scheme the class name suggests. The name is kept because it is what
8
+ # Protocol::DH maps '448' to and what callers reference.
9
+ #
10
+ # OpenSSL implements X448 and exposes it through the raw key interface, so this function needs
11
+ # no gem and no system library beyond the OpenSSL that AesGcm and the HMAC helpers already use.
8
12
  class ED448
9
- DHLEN = Ed448::X448::X448_PRIVATE_BYTES
13
+ DHLEN = 56
14
+
15
+ # The name OpenSSL knows the curve by. Ed448 is a different algorithm, and asking for it
16
+ # here would produce a signing key rather than one that can derive a shared secret.
17
+ ALGORITHM = 'X448'
10
18
 
11
19
  def generate_keypair
12
- private_key = SecureRandom.random_bytes(DHLEN)
13
- public_key = Ed448::X448.derive_public_key(private_key)
14
- Noise::Key.new(private_key, public_key)
20
+ pkey = OpenSSL::PKey.generate_key(ALGORITHM)
21
+ Noise::Key.new(pkey.raw_private_key, pkey.raw_public_key)
15
22
  end
16
23
 
24
+ # Computes the X448 shared secret for the given remote public key.
25
+ #
26
+ # OpenSSL reports a public key it cannot use as OpenSSL::PKey::PKeyError, both when the key
27
+ # is not DHLEN bytes and when the derivation would produce the all-zero output that RFC 7748
28
+ # requires be rejected. Both are translated to InvalidPublicKeyError so that a caller
29
+ # handling a peer-supplied key rescues the same class for every DH function. The local key
30
+ # is built outside the rescue so that a malformed private key keeps propagating as
31
+ # PKeyError instead of being reported as the peer's fault.
17
32
  def dh(private_key, public_key)
18
- Ed448::X448.dh(public_key, private_key)
33
+ raise Noise::Exceptions::InvalidPublicKeyError, public_key unless public_key.bytesize == DHLEN
34
+
35
+ local = OpenSSL::PKey.new_raw_private_key(ALGORITHM, private_key)
36
+ begin
37
+ local.derive(OpenSSL::PKey.new_raw_public_key(ALGORITHM, public_key))
38
+ rescue OpenSSL::PKey::PKeyError
39
+ raise Noise::Exceptions::InvalidPublicKeyError, public_key
40
+ end
19
41
  end
20
42
 
21
43
  def dhlen
@@ -23,8 +45,8 @@ module Noise
23
45
  end
24
46
 
25
47
  def self.from_private(private_key)
26
- public_key = Ed448::X448.derive_public_key(private_key)
27
- Noise::Key.new(private_key, public_key)
48
+ pkey = OpenSSL::PKey.new_raw_private_key(ALGORITHM, private_key)
49
+ Noise::Key.new(private_key, pkey.raw_public_key)
28
50
  end
29
51
  end
30
52
  end