noise-ruby 0.11.0 → 0.13.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: 876220b34ef23fb45d18cb2dd0dfd7627b5bb202e51f57af9c0cd5a62c211626
4
- data.tar.gz: 662c47506e70adf05928da7b8c5e3fb48503e0043242f4127459297e29dba476
3
+ metadata.gz: 326691ebec86a68d5b4978756edcd5752c7307e4eba85c615e08262f8133481e
4
+ data.tar.gz: 0626adc9ec4b0821211b04b3573a950684ca526edcd5e956450107d4d8c69ce0
5
5
  SHA512:
6
- metadata.gz: 8db525a248cd3a66f1597736745d70470ef109a3e7c7acd9758fda79c5c45aa78350d833c38a4d6d3d2ae9f8a06dd3b6b7f89a6c4928c12ea3dfe899efca4906
7
- data.tar.gz: 420bd4dbeff8a66570f9d71ec219375aafeb9e5a45d76b1f23f6d9dfe47776b73da860b918f585214983224fea488370dc423f27dbd7c8905c95f33d8a4dce13
6
+ metadata.gz: 2246a0d4be698a11efb560f6e9ddd70b50a926255c32877d8fd7346780e99e4d2950ac2e10ed81fb787ce9a61f626027e69136e94d680c9959e79c1702c73c47
7
+ data.tar.gz: fe3e968bbb464d11bd574750bdd97410b7ef5564328b327946654f76f8476fae4eadb7fbb8acebf4c4036b4f5edaa2d4a5b265c38705cc2ad33e25f310f78255
@@ -19,7 +19,7 @@ jobs:
19
19
  strategy:
20
20
  matrix:
21
21
  ruby:
22
- - '3.3'
22
+ - '4.0'
23
23
  steps:
24
24
  - name: Checkout code
25
25
  uses: actions/checkout@v4
@@ -37,11 +37,10 @@ jobs:
37
37
  strategy:
38
38
  matrix:
39
39
  ruby:
40
- - '3.0'
41
- - '3.1'
42
40
  - '3.2'
43
41
  - '3.3'
44
42
  - '3.4'
43
+ - '4.0'
45
44
  steps:
46
45
  - name: Checkout code
47
46
  uses: actions/checkout@v4
data/.rubocop.yml CHANGED
@@ -19,7 +19,7 @@ Style/WordArray:
19
19
  MinSize: 100
20
20
 
21
21
  AllCops:
22
- TargetRubyVersion: 3.0
22
+ TargetRubyVersion: 3.2
23
23
  SuggestExtensions: false
24
24
 
25
25
  Naming/MethodParameterName:
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.3.5
1
+ 4.0.6
data/Dockerfile CHANGED
@@ -1,8 +1,7 @@
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
1
+ # A Linux environment close to the CI runner, for checking a change against the Ruby version CI
2
+ # lints with. Nothing in the suite is architecture specific, so the image builds for the host
3
+ # architecture and needs no emulation.
4
+ FROM ruby:4.0
6
5
 
7
6
  # libsodium: dlopen'd by rbnacl, which lib/noise.rb requires unconditionally.
8
7
  # cargo: the blake3 gem is a Rust extension and is built at install time.
data/Gemfile CHANGED
@@ -4,6 +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. The optional ed448, secp256k1-ruby and blake3
8
- # backends are development dependencies there, so the test suite covers all of them.
7
+ # Specify your gem's dependencies in noise.gemspec. The optional blake3 backend is a development
8
+ # dependency there, so the test suite covers it too.
9
9
  gemspec
data/README.md CHANGED
@@ -30,6 +30,9 @@ Supported Features:
30
30
 
31
31
  ## Installation
32
32
 
33
+ This gem requires Ruby 3.2 or later, and is tested on 3.2, 3.3, 3.4 and 4.0. Ruby 3.0 and 3.1 are
34
+ past end of life and are no longer supported; stay on noise-ruby 0.12.0 or earlier if you need them.
35
+
33
36
  This gem needs libsodium library.
34
37
  To install libsodium, see https://github.com/jedisct1/libsodium
35
38
 
@@ -47,39 +50,6 @@ Or install it yourself as:
47
50
 
48
51
  $ gem install noise-ruby
49
52
 
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
- and, add this line to your Gemfile:
63
-
64
- ```
65
- gem 'ed448'
66
- ```
67
-
68
- If you use Secp256k1, you must install [libsecp256k1](https://github.com/bitcoin-core/secp256k1).
69
-
70
- $ git clone https://github.com/bitcoin-core/secp256k1
71
- $ cd secp256k1
72
- $ ./autogen.sh
73
- $ ./configure --enable-module-recovery --enable-experimental --enable-module-ecdh
74
- $ make
75
- $ sudo make install
76
-
77
- and, add this line to your Gemfile:
78
-
79
- ```
80
- gem 'secp256k1-ruby'
81
- ```
82
-
83
53
  If you use BLAKE3, you must install [Rust and Cargo](https://www.rust-lang.org/tools/install).
84
54
  And add this line to your Gemfile:
85
55
 
@@ -93,6 +63,25 @@ Followings shows handshake protocol with "Noise_NN_25519_ChaChaPoly_BLAKE2b"
93
63
 
94
64
  ### Handshake
95
65
 
66
+ #### Supplying keys
67
+
68
+ Patterns other than `NN` need keys before the handshake starts. `Connection::Initiator.new` and
69
+ `Connection::Responder.new` take them in the `keypairs:` hash: `:s` is the local static private key,
70
+ `:rs` and `:re` are the remote party's static and ephemeral public keys.
71
+
72
+ ```
73
+ initiator = Noise::Connection::Initiator.new("Noise_XX_25519_ChaChaPoly_SHA256", keypairs: { s: static_private_key })
74
+ ```
75
+
76
+ `keypairs:` also accepts `:e`, the local ephemeral private key. **It exists only so that
77
+ `spec/vectors_spec.rb` can reproduce the official test vectors, which fix both sides' ephemeral keys
78
+ to make the output deterministic. Never set it in production.** A connection created with `:e` reuses
79
+ that ephemeral keypair instead of generating a fresh one per handshake, which destroys the forward
80
+ secrecy every pattern depends on: an attacker who recovers the key can decrypt every session that
81
+ used it, past and future. Nothing fails and no warning is printed - the handshake still succeeds and
82
+ your own tests still pass - so the loss is invisible. Leave `:e` unset and let the library generate
83
+ it.
84
+
96
85
  #### initiator
97
86
 
98
87
  ```
@@ -138,6 +127,31 @@ cipher = initiator.encrypt("Hello, World!") # => "\xDA\xC7\xD7as\v\xFA\xCC,\xB3\
138
127
  plain = responder.decrypt(cipher) # => "Hello, World!"
139
128
  ```
140
129
 
130
+ #### Out-of-order transport messages
131
+
132
+ Each party counts the transport messages of each direction with a nonce. If the transport layer can
133
+ deliver messages out of order or lose them, it has to carry the nonce of each message, and the
134
+ receiver sets the nonce before decrypting. Restore the previous value if the message fails to
135
+ authenticate, so that the following messages are still decryptable.
136
+
137
+ ```
138
+ initiator.encryption_nonce # => 0
139
+ responder.decryption_nonce = 2 # decrypt the message numbered 2 next
140
+ plain = responder.decrypt(cipher)
141
+ ```
142
+
143
+ #### Rekey
144
+
145
+ Rekeying replaces the key of one direction with `REKEY(k)`, so that a key compromised later cannot
146
+ decrypt the messages that came before it. The nonce keeps counting. Both parties must rekey the
147
+ matching direction at the same point of the message stream; when that happens is up to the
148
+ application protocol.
149
+
150
+ ```
151
+ initiator.rekey_encryption
152
+ responder.rekey_decryption
153
+ ```
154
+
141
155
  ## Development
142
156
 
143
157
  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,13 +3,26 @@
3
3
  module Noise
4
4
  module Connection
5
5
  class Base
6
- # The Noise spec caps a handshake or transport message at 65535 bytes.
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.
7
9
  MAX_MESSAGE_LENGTH = 65_535
10
+ MAX_PLAINTEXT_LENGTH = MAX_MESSAGE_LENGTH - Noise::State::CipherState::TAG_LENGTH
8
11
 
9
12
  attr_reader :protocol, :handshake_started, :handshake_finished, :handshake_hash, :handshake_state,
10
13
  :cipher_state_encrypt, :cipher_state_decrypt, :cipher_state_handshake, :s, :rs
11
14
  attr_accessor :psks, :prologue
12
15
 
16
+ # @param [String] name the protocol name, for example 'Noise_XX_25519_ChaChaPoly_SHA256'.
17
+ # @param [Hash] keypairs the keys the pattern needs, as private or public key strings.
18
+ # :s is the local static private key, :rs and :re the remote static and ephemeral public keys.
19
+ #
20
+ # :e sets the local ephemeral private key. It exists only so that spec/vectors_spec.rb can
21
+ # reproduce the official test vectors, which fix both sides' ephemeral keys to make the
22
+ # output deterministic. Never set it outside that use: HandshakeState#write_message reuses
23
+ # the keypair given here instead of generating a fresh one, and an ephemeral key reused
24
+ # across handshakes gives up the forward secrecy every pattern depends on. The handshake
25
+ # still succeeds, so nothing reports the loss.
13
26
  def initialize(name, keypairs: { s: nil, e: nil, rs: nil, re: nil })
14
27
  @protocol = Protocol.create(name)
15
28
 
@@ -61,8 +74,10 @@ module Noise
61
74
  raise Noise::Exceptions::NoiseHandshakeError if @next_message != :write
62
75
  raise Noise::Exceptions::NoiseHandshakeError if @handshake_finished
63
76
 
64
- if @handshake_state.expected_message_length(payload.bytesize) > MAX_MESSAGE_LENGTH
65
- raise Noise::Exceptions::NoiseHandshakeError, 'Message exceeds the maximum length.'
77
+ length = @handshake_state.expected_message_length(payload.bytesize)
78
+ if length > MAX_MESSAGE_LENGTH
79
+ raise Noise::Exceptions::MessageTooLongError,
80
+ "Message would be #{length} bytes, which exceeds the maximum of #{MAX_MESSAGE_LENGTH}."
66
81
  end
67
82
 
68
83
  @next_message = :read
@@ -76,8 +91,11 @@ module Noise
76
91
  raise Noise::Exceptions::NoiseHandshakeError unless @handshake_started
77
92
  raise Noise::Exceptions::NoiseHandshakeError if @next_message != :read
78
93
  raise Noise::Exceptions::NoiseHandshakeError if @handshake_finished
79
- raise Noise::Exceptions::NoiseHandshakeError, 'Message exceeds the maximum length.' if
80
- data.bytesize > MAX_MESSAGE_LENGTH
94
+
95
+ if data.bytesize > MAX_MESSAGE_LENGTH
96
+ raise Noise::Exceptions::MessageTooLongError,
97
+ "Message is #{data.bytesize} bytes, which exceeds the maximum of #{MAX_MESSAGE_LENGTH}."
98
+ end
81
99
 
82
100
  @next_message = :write
83
101
  buffer = +''
@@ -86,21 +104,82 @@ module Noise
86
104
  end
87
105
 
88
106
  def encrypt(data)
89
- raise Noise::Exceptions::NoiseHandshakeError unless @handshake_finished
107
+ cipher_state = transport_cipher_state(:encrypt)
108
+ if data.bytesize > MAX_PLAINTEXT_LENGTH
109
+ raise Noise::Exceptions::MessageTooLongError,
110
+ "Plaintext is #{data.bytesize} bytes, which exceeds the maximum of #{MAX_PLAINTEXT_LENGTH}."
111
+ end
90
112
 
91
- @cipher_state_encrypt.encrypt_with_ad('', data)
113
+ cipher_state.encrypt_with_ad('', data)
92
114
  end
93
115
 
94
116
  def decrypt(data)
95
- raise Noise::Exceptions::NoiseHandshakeError unless @handshake_finished
117
+ cipher_state = transport_cipher_state(:decrypt)
118
+ # Rejected before the cipher state is used, so an over-long message leaves n untouched
119
+ # and the connection usable, exactly as a failed decryption does.
120
+ if data.bytesize > MAX_MESSAGE_LENGTH
121
+ raise Noise::Exceptions::MessageTooLongError,
122
+ "Message is #{data.bytesize} bytes, which exceeds the maximum of #{MAX_MESSAGE_LENGTH}."
123
+ end
124
+
125
+ cipher_state.decrypt_with_ad('', data)
126
+ end
96
127
 
97
- @cipher_state_decrypt.decrypt_with_ad('', data)
128
+ # @return [Integer] the nonce the next #encrypt call uses.
129
+ def encryption_nonce
130
+ transport_cipher_state(:encrypt).n
131
+ end
132
+
133
+ # @return [Integer] the nonce the next #decrypt call uses.
134
+ def decryption_nonce
135
+ transport_cipher_state(:decrypt).n
136
+ end
137
+
138
+ # Sets the nonce of the next #encrypt call. Needed when the transport layer numbers the
139
+ # messages itself instead of relying on the sender and the receiver counting in step.
140
+ #
141
+ # @param [Integer] nonce a value between 0 and CipherState::MAX_NONCE.
142
+ def encryption_nonce=(nonce)
143
+ transport_cipher_state(:encrypt).nonce = nonce
144
+ end
145
+
146
+ # Sets the nonce of the next #decrypt call. This is how the Noise spec handles transport
147
+ # messages that arrive out of order: the receiver sets n to the nonce of the message it is
148
+ # about to decrypt, and restores the previous value if the message fails to authenticate.
149
+ #
150
+ # @param [Integer] nonce a value between 0 and CipherState::MAX_NONCE.
151
+ def decryption_nonce=(nonce)
152
+ transport_cipher_state(:decrypt).nonce = nonce
153
+ end
154
+
155
+ # Replaces the key used by #encrypt with REKEY(k), so that the old key cannot decrypt the
156
+ # messages that follow. Both parties must rekey the matching direction at the same point of
157
+ # the message stream, which is up to the application protocol to agree on.
158
+ #
159
+ # @return [void]
160
+ def rekey_encryption
161
+ transport_cipher_state(:encrypt).rekey
162
+ nil
163
+ end
164
+
165
+ # Replaces the key used by #decrypt with REKEY(k). See #rekey_encryption.
166
+ #
167
+ # @return [void]
168
+ def rekey_decryption
169
+ transport_cipher_state(:decrypt).rekey
170
+ nil
98
171
  end
99
172
 
100
173
  def validate_psk!
174
+ raise Noise::Exceptions::NoisePSKError, 'psks are not set.' if @psks.nil?
101
175
  # Invalid psk length! Has to be 32 bytes long
102
- raise Noise::Exceptions::NoisePSKError if @psks.any? { |psk| psk.bytesize != 32 }
103
- raise Noise::Exceptions::NoisePSKError if @protocol.pattern.psk_count != @psks.count
176
+ raise Noise::Exceptions::NoisePSKError, 'psks have to be 32 bytes long.' if
177
+ @psks.any? { |psk| psk.bytesize != 32 }
178
+
179
+ return if @protocol.pattern.psk_count == @psks.count
180
+
181
+ raise Noise::Exceptions::NoisePSKError,
182
+ "This protocol needs #{@protocol.pattern.psk_count} psks, got #{@psks.count}."
104
183
  end
105
184
 
106
185
  def missing_keypairs?
@@ -124,6 +203,23 @@ module Noise
124
203
  @symmetric_state = nil
125
204
  @cipher_state_handshake = nil
126
205
  end
206
+
207
+ private
208
+
209
+ # Returns the transport CipherState of the given direction.
210
+ #
211
+ # One-way patterns leave the direction the caller cannot use as nil, so the absence of a
212
+ # cipher state is reported the same way as a handshake that has not finished yet.
213
+ #
214
+ # @param [Symbol] direction :encrypt or :decrypt.
215
+ def transport_cipher_state(direction)
216
+ raise Noise::Exceptions::NoiseHandshakeError unless @handshake_finished
217
+
218
+ cipher_state = direction == :encrypt ? @cipher_state_encrypt : @cipher_state_decrypt
219
+ raise Noise::Exceptions::NoiseHandshakeError, "This party cannot #{direction} messages." unless cipher_state
220
+
221
+ cipher_state
222
+ end
127
223
  end
128
224
  end
129
225
  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
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noise
4
+ module Exceptions
5
+ # Raised when a message would exceed, or does exceed, the 65535 byte limit the Noise
6
+ # specification places on every message. Raised from the handshake and the transport paths
7
+ # alike, so that a caller handling "the peer sent something too long" rescues one class rather
8
+ # than one per phase.
9
+ #
10
+ # It covers over-long messages only, and the other framing errors keep their own classes: a
11
+ # truncated handshake message raises NoiseHandshakeError, and a transport message shorter than
12
+ # the authentication tag raises DecryptError. A caller that treats every framing error alike
13
+ # has to rescue all three.
14
+ #
15
+ # Kept apart from DecryptError so that a badly framed message can be told from a failed
16
+ # authentication, which has very different security meaning.
17
+ class MessageTooLongError < StandardError
18
+ end
19
+ end
20
+ end
@@ -4,8 +4,10 @@ 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'
9
11
  autoload :MissingDependencyError, 'noise/exceptions/missing_dependency_error'
10
12
  autoload :ProtocolNameError, 'noise/exceptions/protocol_name_error'
11
13
  autoload :NoiseHandshakeError, 'noise/exceptions/noise_handshake_error'
@@ -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,26 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Noise.require_optional('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
- # Ed448::X448::X448_PRIVATE_BYTES, spelled out so this file loads without the gem.
10
13
  DHLEN = 56
11
14
 
12
- def initialize
13
- Noise.optional_dependency!('ed448')
14
- end
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'
15
18
 
16
19
  def generate_keypair
17
- private_key = SecureRandom.random_bytes(DHLEN)
18
- public_key = Ed448::X448.derive_public_key(private_key)
19
- 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)
20
22
  end
21
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.
22
32
  def dh(private_key, public_key)
23
- 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
24
41
  end
25
42
 
26
43
  def dhlen
@@ -28,8 +45,8 @@ module Noise
28
45
  end
29
46
 
30
47
  def self.from_private(private_key)
31
- public_key = Ed448::X448.derive_public_key(private_key)
32
- 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)
33
50
  end
34
51
  end
35
52
  end
@@ -1,13 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Noise.require_optional 'secp256k1'
4
-
5
3
  module Noise
6
4
  module Functions
7
5
  module DH
6
+ # The secp256k1 DH function, as the Lightning Network uses it in BOLT #8.
7
+ #
8
+ # The shared secret is SHA256 of the shared point in compressed form, not the raw X
9
+ # coordinate. That is what BOLT #8 specifies and what libsecp256k1's ecdh returned when this
10
+ # function was backed by that library, so the value on the wire is unchanged.
8
11
  class Secp256k1
12
+ # Length of a compressed secp256k1 point. The 65-byte uncompressed form encodes the same
13
+ # point, but Noise exchanges only the compressed one, so it is rejected.
14
+ DHLEN = 33
15
+
16
+ # Length of a secp256k1 scalar, which is what a private key is.
17
+ PRIVATE_KEY_LEN = 32
18
+
19
+ # The name OpenSSL knows the curve by.
20
+ CURVE = 'secp256k1'
21
+
22
+ # Builds the curve group once, because dh needs it on every call.
23
+ #
24
+ # secp256k1 is a builtin curve of OpenSSL 3, but an OpenSSL restricted to a FIPS provider
25
+ # does not offer it. That leaves the function unusable in the same way a missing system
26
+ # library did, so it is reported as MissingDependencyError rather than as an OpenSSL error
27
+ # raised in the middle of a handshake.
9
28
  def initialize
10
- Noise.optional_dependency!('secp256k1')
29
+ @group = OpenSSL::PKey::EC::Group.new(CURVE)
30
+ rescue OpenSSL::PKey::EC::Group::Error => e
31
+ raise Noise::Exceptions::MissingDependencyError,
32
+ "OpenSSL does not offer the #{CURVE} curve: #{e.message}"
11
33
  end
12
34
 
13
35
  def generate_keypair
@@ -15,28 +37,78 @@ module Noise
15
37
  private_key = 1 + SecureRandom.random_number(group.order - 1)
16
38
  public_key = group.generator.multiply_by_scalar(private_key)
17
39
  Noise::Key.new(
18
- ECDSA::Format::IntegerOctetString.encode(private_key, 32),
40
+ ECDSA::Format::IntegerOctetString.encode(private_key, PRIVATE_KEY_LEN),
19
41
  ECDSA::Format::PointOctetString.encode(public_key, compression: true)
20
42
  )
21
43
  end
22
44
 
45
+ # Computes the ECDH shared secret for the given remote public key.
46
+ #
47
+ # Every way a peer-supplied key can be unusable raises InvalidPublicKeyError, matching the
48
+ # other DH functions: a length other than DHLEN, and an encoding that names no point on the
49
+ # curve. A private key the caller owns is not the peer's fault, so a malformed one raises
50
+ # ArgumentError instead, as it did when the gem parsed it.
23
51
  def dh(private_key, public_key)
24
- key = ::Secp256k1::PublicKey.new(pubkey: public_key, raw: true)
25
- key.ecdh(private_key)
26
- rescue ::Secp256k1::AssertError
27
- raise Noise::Exceptions::InvalidPublicKeyError, public_key
52
+ raise Noise::Exceptions::InvalidPublicKeyError, public_key unless public_key.bytesize == DHLEN
53
+
54
+ scalar = OpenSSL::BN.new(self.class.decode_private_key(private_key))
55
+ shared = parse_public_key(public_key).mul(scalar)
56
+ # A backstop rather than a reachable case: secp256k1 has cofactor 1, so a point that
57
+ # parsed has order n, and the scalar is already known to be in [1, n-1]. It stays because
58
+ # the cost is one comparison and the failure it guards is severe - the compressed
59
+ # encoding of infinity is the single byte 0x00, whose SHA256 anyone can precompute.
60
+ raise Noise::Exceptions::InvalidPublicKeyError, public_key if shared.infinity?
61
+
62
+ OpenSSL::Digest.digest('SHA256', shared.to_octet_string(:compressed))
28
63
  end
29
64
 
30
65
  def dhlen
31
- 33
66
+ DHLEN
32
67
  end
33
68
 
34
69
  def self.from_private(private_key)
35
- group = ECDSA::Group::Secp256k1
36
- scalar = ECDSA::Format::IntegerOctetString.decode(private_key)
37
- point = group.generator.multiply_by_scalar(scalar)
70
+ scalar = decode_private_key(private_key)
71
+ point = ECDSA::Group::Secp256k1.generator.multiply_by_scalar(scalar)
38
72
  Noise::Key.new(private_key, ECDSA::Format::PointOctetString.encode(point, compression: true))
39
73
  end
74
+
75
+ # Decodes a private key into the scalar it denotes, rejecting the values that cannot serve
76
+ # as one. Both entry points that take a private key go through this, so a key from_private
77
+ # accepts is one dh can use.
78
+ #
79
+ # Rejected are a length other than PRIVATE_KEY_LEN, zero, and anything at or above the
80
+ # group order: those scalars multiply every point to infinity, whose compressed encoding is
81
+ # the single byte 0x00. Without the check from_private would hand back a Noise::Key holding
82
+ # that one byte as its public key, and the handshake it is used in would fail only once the
83
+ # key reached the wire. A private key belongs to the caller rather than to the peer, so a
84
+ # bad one raises ArgumentError and not InvalidPublicKeyError.
85
+ def self.decode_private_key(private_key)
86
+ unless private_key.bytesize == PRIVATE_KEY_LEN
87
+ raise ArgumentError, "private key must be #{PRIVATE_KEY_LEN} bytes"
88
+ end
89
+
90
+ scalar = ECDSA::Format::IntegerOctetString.decode(private_key)
91
+ order = ECDSA::Group::Secp256k1.order
92
+ raise ArgumentError, 'private key is out of range' unless scalar.between?(1, order - 1)
93
+
94
+ scalar
95
+ end
96
+
97
+ private
98
+
99
+ # Decodes a public key into a point on the curve.
100
+ #
101
+ # The key is passed as a String, not as an OpenSSL::BN. OpenSSL then decodes it as an octet
102
+ # string and raises Point::Error for anything that is not a point on the curve, the
103
+ # all-zero key included: 0x00 introduces the encoding of infinity, which is one byte long,
104
+ # so 33 zero bytes are rejected as a malformed encoding. Handing over a BN instead would
105
+ # drop the leading zero byte and produce the point at infinity, whose shared secret is a
106
+ # constant that the sender of such a key could precompute.
107
+ def parse_public_key(public_key)
108
+ OpenSSL::PKey::EC::Point.new(@group, public_key)
109
+ rescue OpenSSL::PKey::EC::Point::Error
110
+ raise Noise::Exceptions::InvalidPublicKeyError, public_key
111
+ end
40
112
  end
41
113
  end
42
114
  end
@@ -29,7 +29,17 @@ module Noise
29
29
  !@k.nil?
30
30
  end
31
31
 
32
+ # Sets n, the nonce the next encrypt_with_ad or decrypt_with_ad call uses. This is SetNonce()
33
+ # of the Noise spec, needed to decrypt transport messages that arrive out of order.
34
+ #
35
+ # The value is checked here because the ciphers pack n into 8 bytes: a value outside the
36
+ # unsigned 64-bit range silently produces a wrong nonce instead of an error.
37
+ #
38
+ # @param [Integer] nonce a value between 0 and MAX_NONCE.
39
+ # @raise [Noise::Exceptions::InvalidNonceError] if nonce is out of that range.
32
40
  def nonce=(nonce)
41
+ raise Noise::Exceptions::InvalidNonceError unless nonce.is_a?(Integer) && nonce.between?(0, MAX_NONCE)
42
+
33
43
  @n = nonce
34
44
  end
35
45
 
@@ -56,6 +66,9 @@ module Noise
56
66
  plaintext
57
67
  end
58
68
 
69
+ # Replaces k with REKEY(k). n is left as it is, as the Noise spec requires.
70
+ #
71
+ # @return [String] the new 32 bytes key.
59
72
  def rekey
60
73
  @k = @cipher.rekey(@k)
61
74
  end
@@ -107,6 +107,12 @@ module Noise
107
107
  pattern.each do |token|
108
108
  case token
109
109
  when Noise::Token::E
110
+ # A fresh keypair per handshake is what the pattern's forward secrecy rests on. Two
111
+ # things preset @e. Connection::Base#fallback carries the ephemeral of the aborted
112
+ # handshake into the new state, which the fallback patterns require: process_fallback
113
+ # mixes its public key as a pre-message. A caller passing keypairs[:e] to
114
+ # Connection::Base#initialize also presets it, and that is supported for reproducing
115
+ # the official test vectors and nothing else.
110
116
  @e ||= @protocol.dh_fn.generate_keypair
111
117
  message_buffer << @e.public_key
112
118
  mix_e(@e.public_key)
data/lib/noise/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Noise
4
- VERSION = '0.11.0'
4
+ VERSION = '0.13.0'
5
5
  end
data/lib/noise.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  require 'noise/version'
4
4
 
5
5
  require 'ecdsa'
6
- require 'logger'
6
+ require 'openssl'
7
7
  require 'rbnacl'
8
8
  require 'ruby_hmac'
9
9
  require 'securerandom'
@@ -26,18 +26,25 @@ module Noise
26
26
  @unavailable_dependencies = {}
27
27
 
28
28
  class << self
29
- def logger
30
- @logger ||= Logger.new($stdout)
31
- end
32
-
29
+ # Requires an optional backend gem and yields once it is loaded. A LoadError is not fatal:
30
+ # the reason is recorded so optional_dependency! can raise it later, and a warning goes to
31
+ # $stderr so the missing backend is visible at load time. Kernel#warn is used rather than a
32
+ # Logger because logger is no longer a default gem on Ruby 4.0, and this single message does
33
+ # not justify a runtime dependency on it.
33
34
  def require_optional(name)
34
35
  require name
35
- yield if block_given?
36
36
  rescue LoadError => e
37
37
  @unavailable_dependencies[name] = e.message
38
- logger.warn("Optional dependency '#{name}' is unavailable: #{e.message}")
38
+ warn("Optional dependency '#{name}' is unavailable: #{e.message}")
39
+ else
40
+ # The block runs outside the rescue on purpose: a LoadError raised by the block itself is
41
+ # about something other than this dependency, and swallowing it here would hide the cause.
42
+ yield if block_given?
39
43
  end
40
44
 
45
+ # Raises MissingDependencyError if the named optional backend failed to load earlier. Call it
46
+ # from the entry point of a function that needs the backend, so the failure surfaces where the
47
+ # backend is used rather than at require time.
41
48
  def optional_dependency!(name)
42
49
  reason = @unavailable_dependencies[name]
43
50
  return if reason.nil?
data/noise.gemspec CHANGED
@@ -6,7 +6,7 @@ require 'noise/version'
6
6
 
7
7
  Gem::Specification.new do |spec|
8
8
  spec.name = 'noise-ruby'
9
- spec.required_ruby_version = '~> 3.0'
9
+ spec.required_ruby_version = '>= 3.2'
10
10
  spec.version = Noise::VERSION
11
11
  spec.authors = ['Hajime Yamaguchi']
12
12
  spec.email = ['gen.yamaguchi0@gmail.com']
@@ -22,7 +22,6 @@ Gem::Specification.new do |spec|
22
22
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
23
23
  spec.require_paths = ['lib']
24
24
 
25
- spec.add_development_dependency 'bundler', '~> 2.0'
26
25
  spec.add_development_dependency 'rake', '>= 12.3.3'
27
26
  spec.add_development_dependency 'rspec', '~> 3.0'
28
27
 
@@ -31,14 +30,17 @@ Gem::Specification.new do |spec|
31
30
  spec.add_development_dependency 'simplecov'
32
31
  spec.add_development_dependency 'simplecov-json'
33
32
 
34
- # Optional backends. Each one is needed only when its function appears in a protocol name, and
35
- # each also needs a system library that cannot be installed as a gem, so none of them is a runtime
36
- # dependency. Add the one you need to your own Gemfile; see the README for the system libraries.
33
+ # Optional backend. BLAKE3 is needed only when it appears in a protocol name, and it needs Rust
34
+ # to build, so it is not a runtime dependency. Add it to your own Gemfile if you need it; see the
35
+ # README.
37
36
  spec.add_development_dependency 'blake3'
38
- spec.add_development_dependency 'ed448'
39
- spec.add_development_dependency 'secp256k1-ruby'
40
37
 
41
38
  spec.add_runtime_dependency 'ecdsa'
39
+ # The 448 DH function needs the raw key API (OpenSSL::PKey.new_raw_private_key and friends),
40
+ # which arrived in openssl 3.0. Every supported interpreter bundles a newer default gem, but the
41
+ # floor is stated so an older openssl pinned in an application's Gemfile fails to resolve rather
42
+ # than failing at runtime.
43
+ spec.add_runtime_dependency 'openssl', '>= 3.0'
42
44
  spec.add_runtime_dependency 'rbnacl'
43
45
  spec.add_runtime_dependency 'ruby-hmac'
44
46
  end
metadata CHANGED
@@ -1,29 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: noise-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hajime Yamaguchi
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-07-31 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: bundler
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - "~>"
18
- - !ruby/object:Gem::Version
19
- version: '2.0'
20
- type: :development
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - "~>"
25
- - !ruby/object:Gem::Version
26
- version: '2.0'
27
12
  - !ruby/object:Gem::Dependency
28
13
  name: rake
29
14
  requirement: !ruby/object:Gem::Requirement
@@ -123,27 +108,13 @@ dependencies:
123
108
  - !ruby/object:Gem::Version
124
109
  version: '0'
125
110
  - !ruby/object:Gem::Dependency
126
- name: ed448
127
- requirement: !ruby/object:Gem::Requirement
128
- requirements:
129
- - - ">="
130
- - !ruby/object:Gem::Version
131
- version: '0'
132
- type: :development
133
- prerelease: false
134
- version_requirements: !ruby/object:Gem::Requirement
135
- requirements:
136
- - - ">="
137
- - !ruby/object:Gem::Version
138
- version: '0'
139
- - !ruby/object:Gem::Dependency
140
- name: secp256k1-ruby
111
+ name: ecdsa
141
112
  requirement: !ruby/object:Gem::Requirement
142
113
  requirements:
143
114
  - - ">="
144
115
  - !ruby/object:Gem::Version
145
116
  version: '0'
146
- type: :development
117
+ type: :runtime
147
118
  prerelease: false
148
119
  version_requirements: !ruby/object:Gem::Requirement
149
120
  requirements:
@@ -151,19 +122,19 @@ dependencies:
151
122
  - !ruby/object:Gem::Version
152
123
  version: '0'
153
124
  - !ruby/object:Gem::Dependency
154
- name: ecdsa
125
+ name: openssl
155
126
  requirement: !ruby/object:Gem::Requirement
156
127
  requirements:
157
128
  - - ">="
158
129
  - !ruby/object:Gem::Version
159
- version: '0'
130
+ version: '3.0'
160
131
  type: :runtime
161
132
  prerelease: false
162
133
  version_requirements: !ruby/object:Gem::Requirement
163
134
  requirements:
164
135
  - - ">="
165
136
  - !ruby/object:Gem::Version
166
- version: '0'
137
+ version: '3.0'
167
138
  - !ruby/object:Gem::Dependency
168
139
  name: rbnacl
169
140
  requirement: !ruby/object:Gem::Requirement
@@ -220,8 +191,10 @@ files:
220
191
  - lib/noise/exceptions.rb
221
192
  - lib/noise/exceptions/decrypt_error.rb
222
193
  - lib/noise/exceptions/encrypt_error.rb
194
+ - lib/noise/exceptions/invalid_nonce_error.rb
223
195
  - lib/noise/exceptions/invalid_public_key_error.rb
224
196
  - lib/noise/exceptions/max_nonce_error.rb
197
+ - lib/noise/exceptions/message_too_long_error.rb
225
198
  - lib/noise/exceptions/missing_dependency_error.rb
226
199
  - lib/noise/exceptions/noise_handshake_error.rb
227
200
  - lib/noise/exceptions/noise_psk_error.rb
@@ -257,23 +230,21 @@ files:
257
230
  homepage: https://github.com/Yamaguchi/noise
258
231
  licenses: []
259
232
  metadata: {}
260
- post_install_message:
261
233
  rdoc_options: []
262
234
  require_paths:
263
235
  - lib
264
236
  required_ruby_version: !ruby/object:Gem::Requirement
265
237
  requirements:
266
- - - "~>"
238
+ - - ">="
267
239
  - !ruby/object:Gem::Version
268
- version: '3.0'
240
+ version: '3.2'
269
241
  required_rubygems_version: !ruby/object:Gem::Requirement
270
242
  requirements:
271
243
  - - ">="
272
244
  - !ruby/object:Gem::Version
273
245
  version: '0'
274
246
  requirements: []
275
- rubygems_version: 3.5.16
276
- signing_key:
247
+ rubygems_version: 4.0.16
277
248
  specification_version: 4
278
249
  summary: A Ruby implementation of the Noise Protocol framework
279
250
  test_files: []