noise-ruby 0.12.0 → 0.14.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: 4a3f4c1b701c2de6240cb5fc0fb2410e1790c390142b256e6b822af4fa327cef
4
- data.tar.gz: a57735c978c1227262bc122e869aa6866e76a943654981fb57411d70cab34f80
3
+ metadata.gz: a6817736b9a72ca54f30ceb3238c9962fbd1f4a09092e9e0f783fec5a1035e9f
4
+ data.tar.gz: de1d7510423907f0b73fa4f4115916bb2044195dc4dbbf8acb1f83cb484f2e76
5
5
  SHA512:
6
- metadata.gz: 915a8da57226d70b24a5a7289325eba851d1e7e40c7d7ef0d0081411008f388a78eec00a760fe92f05ef82ccd707bc776bc8d4d385a6e14d1c3184714b7437b3
7
- data.tar.gz: dd660c060e00d5c1d0c0e89c434743d6e91749ba09d16a8217e0031c641a2788636ba0fbf64bcfcc031ed84c75e290e3526d95af40fc6518a0211b1e292f7d77
6
+ metadata.gz: 3235949265d26680b60447ad79e7c2ef02824233343a948b3b6805cd519c5e3f1ff5b573abb2ab1feec11eb7e51523448e47ce787ed81bc0399e8ab86e2b8368
7
+ data.tar.gz: 29d18a35a9d052d117e2ce0422f2a165eedf85d8f53af2d4b77263af500a257d194237cfa274b6ee40d432fbb8366490ed30fffafdaffa7713d70593c4104559
@@ -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,14 +1,13 @@
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
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
- # 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
6
  # git: noise.gemspec calls `git ls-files` while the gemspec is evaluated.
7
+ # No Rust toolchain: the blake3-rb gem ships a precompiled binary for this platform.
8
+ # No libsodium: every function is computed by OpenSSL or in pure Ruby.
10
9
  RUN apt-get update -qq \
11
- && apt-get install -y --no-install-recommends build-essential git libsodium23 cargo \
10
+ && apt-get install -y --no-install-recommends build-essential git \
12
11
  && rm -rf /var/lib/apt/lists/*
13
12
 
14
13
  WORKDIR /work
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,8 +30,17 @@ Supported Features:
30
30
 
31
31
  ## Installation
32
32
 
33
- This gem needs libsodium library.
34
- To install libsodium, see https://github.com/jedisct1/libsodium
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
+
36
+ Every function except BLAKE3, which is optional and covered below, is computed either by the
37
+ OpenSSL that Ruby is linked against or in pure Ruby, so no system library has to be installed
38
+ separately. OpenSSL 1.1.1 or later provides everything this gem asks of it, and that is what the
39
+ official Ruby packages and the usual version managers (rbenv, rvm, asdf) are built with. Two kinds
40
+ of build fall short: a Ruby linked against LibreSSL has no BLAKE2b, no ChaCha20-Poly1305 and no
41
+ X448, and an OpenSSL restricted to the FIPS provider has neither BLAKE2b nor ChaCha20-Poly1305 nor
42
+ the secp256k1 curve. On such a build the protocol names that use a missing function raise an error,
43
+ and the remaining ones keep working.
35
44
 
36
45
  Add this line to your application's Gemfile:
37
46
 
@@ -47,27 +56,15 @@ Or install it yourself as:
47
56
 
48
57
  $ gem install noise-ruby
49
58
 
50
- If you use Secp256k1, you must install [libsecp256k1](https://github.com/bitcoin-core/secp256k1).
51
-
52
- $ git clone https://github.com/bitcoin-core/secp256k1
53
- $ cd secp256k1
54
- $ ./autogen.sh
55
- $ ./configure --enable-module-recovery --enable-experimental --enable-module-ecdh
56
- $ make
57
- $ sudo make install
58
-
59
- and, add this line to your Gemfile:
59
+ If you use BLAKE3, add this line to your Gemfile:
60
60
 
61
61
  ```
62
- gem 'secp256k1-ruby'
62
+ gem 'blake3-rb'
63
63
  ```
64
64
 
65
- If you use BLAKE3, you must install [Rust and Cargo](https://www.rust-lang.org/tools/install).
66
- And add this line to your Gemfile:
67
-
68
- ```
69
- gem 'blake3'
70
- ```
65
+ `blake3-rb` ships precompiled binaries for common Linux, macOS and Windows platforms (x86_64 and
66
+ aarch64), so no Rust or C toolchain is needed to install it there. On any other platform bundler
67
+ falls back to the source gem, which does need a C compiler.
71
68
 
72
69
  ## Usage
73
70
 
@@ -75,6 +72,25 @@ Followings shows handshake protocol with "Noise_NN_25519_ChaChaPoly_BLAKE2b"
75
72
 
76
73
  ### Handshake
77
74
 
75
+ #### Supplying keys
76
+
77
+ Patterns other than `NN` need keys before the handshake starts. `Connection::Initiator.new` and
78
+ `Connection::Responder.new` take them in the `keypairs:` hash: `:s` is the local static private key,
79
+ `:rs` and `:re` are the remote party's static and ephemeral public keys.
80
+
81
+ ```
82
+ initiator = Noise::Connection::Initiator.new("Noise_XX_25519_ChaChaPoly_SHA256", keypairs: { s: static_private_key })
83
+ ```
84
+
85
+ `keypairs:` also accepts `:e`, the local ephemeral private key. **It exists only so that
86
+ `spec/vectors_spec.rb` can reproduce the official test vectors, which fix both sides' ephemeral keys
87
+ to make the output deterministic. Never set it in production.** A connection created with `:e` reuses
88
+ that ephemeral keypair instead of generating a fresh one per handshake, which destroys the forward
89
+ secrecy every pattern depends on: an attacker who recovers the key can decrypt every session that
90
+ used it, past and future. Nothing fails and no warning is printed - the handshake still succeeds and
91
+ your own tests still pass - so the loss is invisible. Leave `:e` unset and let the library generate
92
+ it.
93
+
78
94
  #### initiator
79
95
 
80
96
  ```
@@ -13,6 +13,16 @@ module Noise
13
13
  :cipher_state_encrypt, :cipher_state_decrypt, :cipher_state_handshake, :s, :rs
14
14
  attr_accessor :psks, :prologue
15
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.
16
26
  def initialize(name, keypairs: { s: nil, e: nil, rs: nil, re: nil })
17
27
  @protocol = Protocol.create(name)
18
28
 
@@ -64,8 +74,10 @@ module Noise
64
74
  raise Noise::Exceptions::NoiseHandshakeError if @next_message != :write
65
75
  raise Noise::Exceptions::NoiseHandshakeError if @handshake_finished
66
76
 
67
- if @handshake_state.expected_message_length(payload.bytesize) > MAX_MESSAGE_LENGTH
68
- 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}."
69
81
  end
70
82
 
71
83
  @next_message = :read
@@ -79,8 +91,11 @@ module Noise
79
91
  raise Noise::Exceptions::NoiseHandshakeError unless @handshake_started
80
92
  raise Noise::Exceptions::NoiseHandshakeError if @next_message != :read
81
93
  raise Noise::Exceptions::NoiseHandshakeError if @handshake_finished
82
- raise Noise::Exceptions::NoiseHandshakeError, 'Message exceeds the maximum length.' if
83
- 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
84
99
 
85
100
  @next_message = :write
86
101
  buffer = +''
@@ -2,9 +2,18 @@
2
2
 
3
3
  module Noise
4
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.
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.
8
17
  class MessageTooLongError < StandardError
9
18
  end
10
19
  end
@@ -6,17 +6,32 @@ module Noise
6
6
  class ChaChaPoly
7
7
  MAX_NONCE = 2**64 - 1
8
8
 
9
+ # The name OpenSSL knows the AEAD construction of RFC 8439 by. It takes a 12 byte nonce and
10
+ # produces a 16 byte authentication tag, which is what the Noise specification requires of
11
+ # the ChaChaPoly cipher functions.
12
+ ALGORITHM = 'chacha20-poly1305'
13
+
14
+ # Length in bytes of the authentication tag that encrypt appends to the ciphertext.
15
+ TAGLEN = 16
16
+
9
17
  def encrypt(k, n, ad, plaintext)
10
- cipher = RbNaCl::AEAD::ChaCha20Poly1305IETF.new(String.new(k).force_encoding('ASCII-8BIT'))
11
- cipher.encrypt(nonce_to_bytes(n), plaintext, ad)
12
- rescue ::RbNaCl::CryptoError => e
18
+ cipher = OpenSSL::Cipher.new(ALGORITHM).encrypt
19
+ cipher.key = k
20
+ cipher.iv = nonce_to_bytes(n)
21
+ cipher.auth_data = ad
22
+ update(cipher, plaintext) + cipher.final + cipher.auth_tag
23
+ rescue OpenSSL::Cipher::CipherError => e
13
24
  raise Noise::Exceptions::EncryptError, "Encrypt failed. #{e.message}", e.backtrace
14
25
  end
15
26
 
16
27
  def decrypt(k, n, ad, ciphertext)
17
- cipher = RbNaCl::AEAD::ChaCha20Poly1305IETF.new(String.new(k).force_encoding('ASCII-8BIT'))
18
- cipher.decrypt(nonce_to_bytes(n), ciphertext, ad)
19
- rescue ::RbNaCl::CryptoError => e
28
+ cipher = OpenSSL::Cipher.new(ALGORITHM).decrypt
29
+ cipher.key = k
30
+ cipher.iv = nonce_to_bytes(n)
31
+ cipher.auth_data = ad
32
+ cipher.auth_tag = ciphertext[-TAGLEN..]
33
+ update(cipher, ciphertext[0...-TAGLEN]) + cipher.final
34
+ rescue OpenSSL::Cipher::CipherError => e
20
35
  raise Noise::Exceptions::DecryptError, "Decrpyt failed. #{e.message}", e.backtrace
21
36
  end
22
37
 
@@ -34,6 +49,16 @@ module Noise
34
49
  def rekey(k)
35
50
  encrypt(k, MAX_NONCE, '', "\x00" * 32)[0...32]
36
51
  end
52
+
53
+ private
54
+
55
+ # A zero-length payload is normal in a Noise message, but OpenSSL::Cipher#update raises
56
+ # ArgumentError('data must not be empty') instead of returning ''.
57
+ def update(cipher, data)
58
+ return String.new if data.empty?
59
+
60
+ cipher.update(data)
61
+ end
37
62
  end
38
63
  end
39
64
  end
@@ -3,29 +3,41 @@
3
3
  module Noise
4
4
  module Functions
5
5
  module DH
6
+ # The 25519 DH function of the Noise specification, which is X25519 as defined by RFC 7748 and
7
+ # not the Ed25519 signature scheme the class name suggests. The name is kept because it is
8
+ # what Protocol::DH maps '25519' to and what callers reference.
9
+ #
10
+ # OpenSSL implements X25519 and exposes it through the raw key interface, so this function
11
+ # needs no gem and no system library beyond the OpenSSL the other functions already use.
6
12
  class ED25519
7
13
  DHLEN = 32
14
+
15
+ # The name OpenSSL knows the curve by. Ed25519 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 = 'X25519'
18
+
8
19
  def generate_keypair
9
- private_key = 1 + SecureRandom.random_number(RbNaCl::GroupElement::STANDARD_GROUP_ORDER - 1)
10
- scalar_as_string = ECDSA::Format::IntegerOctetString.encode(private_key, 32)
11
- public_key = RbNaCl::GroupElements::Curve25519.base.mult(scalar_as_string)
12
- Noise::Key.new(ECDSA::Format::IntegerOctetString.encode(private_key, 32), public_key.to_bytes)
20
+ pkey = OpenSSL::PKey.generate_key(ALGORITHM)
21
+ Noise::Key.new(pkey.raw_private_key, pkey.raw_public_key)
13
22
  end
14
23
 
15
24
  # Computes the X25519 shared secret for the given remote public key.
16
25
  #
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.
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.
23
32
  def dh(private_key, public_key)
24
33
  raise Noise::Exceptions::InvalidPublicKeyError, public_key unless public_key.bytesize == DHLEN
25
34
 
26
- RbNaCl::GroupElement.new(public_key).mult(private_key).to_bytes
27
- rescue RbNaCl::CryptoError
28
- raise Noise::Exceptions::InvalidPublicKeyError, public_key
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
29
41
  end
30
42
 
31
43
  def dhlen
@@ -33,8 +45,8 @@ module Noise
33
45
  end
34
46
 
35
47
  def self.from_private(private_key)
36
- public_key = RbNaCl::GroupElements::Curve25519.base.mult(private_key)
37
- Noise::Key.new(private_key, public_key.to_bytes)
48
+ pkey = OpenSSL::PKey.new_raw_private_key(ALGORITHM, private_key)
49
+ Noise::Key.new(private_key, pkey.raw_public_key)
38
50
  end
39
51
  end
40
52
  end
@@ -1,17 +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
9
- # Length of a compressed secp256k1 point. libsecp256k1 also accepts the 65-byte
10
- # uncompressed form, but Noise exchanges only the compressed one.
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.
11
14
  DHLEN = 33
12
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.
13
28
  def initialize
14
- 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}"
15
33
  end
16
34
 
17
35
  def generate_keypair
@@ -19,25 +37,29 @@ module Noise
19
37
  private_key = 1 + SecureRandom.random_number(group.order - 1)
20
38
  public_key = group.generator.multiply_by_scalar(private_key)
21
39
  Noise::Key.new(
22
- ECDSA::Format::IntegerOctetString.encode(private_key, 32),
40
+ ECDSA::Format::IntegerOctetString.encode(private_key, PRIVATE_KEY_LEN),
23
41
  ECDSA::Format::PointOctetString.encode(public_key, compression: true)
24
42
  )
25
43
  end
26
44
 
27
45
  # Computes the ECDH shared secret for the given remote public key.
28
46
  #
29
- # A point that is not on the curve makes libsecp256k1 raise Secp256k1::AssertError,
30
- # while a public key of any other length raises ArgumentError before the point is
31
- # even parsed. Both are translated to InvalidPublicKeyError, matching the other DH
32
- # functions. The length is checked here rather than left to the gem so that
33
- # ArgumentError raised for a malformed private key keeps propagating as itself.
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.
34
51
  def dh(private_key, public_key)
35
52
  raise Noise::Exceptions::InvalidPublicKeyError, public_key unless public_key.bytesize == DHLEN
36
53
 
37
- key = ::Secp256k1::PublicKey.new(pubkey: public_key, raw: true)
38
- key.ecdh(private_key)
39
- rescue ::Secp256k1::AssertError
40
- raise Noise::Exceptions::InvalidPublicKeyError, public_key
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))
41
63
  end
42
64
 
43
65
  def dhlen
@@ -45,11 +67,48 @@ module Noise
45
67
  end
46
68
 
47
69
  def self.from_private(private_key)
48
- group = ECDSA::Group::Secp256k1
49
- scalar = ECDSA::Format::IntegerOctetString.decode(private_key)
50
- point = group.generator.multiply_by_scalar(scalar)
70
+ scalar = decode_private_key(private_key)
71
+ point = ECDSA::Group::Secp256k1.generator.multiply_by_scalar(scalar)
51
72
  Noise::Key.new(private_key, ECDSA::Format::PointOctetString.encode(point, compression: true))
52
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
53
112
  end
54
113
  end
55
114
  end
@@ -6,8 +6,14 @@ module Noise
6
6
  class Blake2b
7
7
  HASHLEN = 64
8
8
  BLOCKLEN = 128
9
+
10
+ # The name OpenSSL knows BLAKE2b with a 64 byte output by. OpenSSL only implements the
11
+ # fixed 512 bit output length, which is exactly the HASHLEN the Noise specification gives
12
+ # the BLAKE2b hash function.
13
+ DIGEST_NAME = 'BLAKE2b512'
14
+
9
15
  def hash(data)
10
- RbNaCl::Hash.blake2b(data)
16
+ OpenSSL::Digest.digest(DIGEST_NAME, data)
11
17
  end
12
18
 
13
19
  def hashlen
@@ -19,9 +25,25 @@ module Noise
19
25
  end
20
26
  end
21
27
 
28
+ # Builds an OpenSSL BLAKE2b digest with no argument, which is how HMAC::Base creates the
29
+ # digests it feeds the inner and outer blocks to. OpenSSL defines no OpenSSL::Digest::BLAKE2b512
30
+ # constant, so the algorithm name is bound here instead of being passed in at every call.
31
+ class Blake2bDigester < OpenSSL::Digest
32
+ def initialize
33
+ super(Blake2b::DIGEST_NAME)
34
+ end
35
+
36
+ # HMAC::Base hashes a key longer than the block size through this class method. The one
37
+ # inherited from OpenSSL::Digest takes the algorithm name as its first argument, which this
38
+ # class has already bound, so it is redefined to take the data alone.
39
+ def self.digest(data)
40
+ new.digest(data)
41
+ end
42
+ end
43
+
22
44
  class Blake2bHMAC < HMAC::Base
23
45
  def initialize(key = nil)
24
- super(RbNaCl::Hash::Blake2b, 128, 64, key)
46
+ super(Blake2bDigester, Blake2b::BLOCKLEN, Blake2b::HASHLEN, key)
25
47
  end
26
48
  public_class_method :new, :digest, :hexdigest
27
49
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Noise.require_optional 'blake3'
3
+ Noise.require_optional 'blake3-rb'
4
4
 
5
5
  module Noise
6
6
  module Functions
@@ -10,11 +10,11 @@ module Noise
10
10
  BLOCKLEN = 64
11
11
 
12
12
  def initialize
13
- Noise.optional_dependency!('blake3')
13
+ Noise.optional_dependency!('blake3-rb')
14
14
  end
15
15
 
16
16
  def hash(data)
17
- ::Blake3.digest(data)
17
+ ::Digest::Blake3.digest(data)
18
18
  end
19
19
 
20
20
  def hashlen
@@ -28,7 +28,7 @@ module Noise
28
28
 
29
29
  class Blake3HMAC < HMAC::Base
30
30
  def initialize(key = nil)
31
- super(::Blake3::Hasher, Blake3::BLOCKLEN, Blake3::HASHLEN, key)
31
+ super(::Digest::Blake3, Blake3::BLOCKLEN, Blake3::HASHLEN, key)
32
32
  end
33
33
  public_class_method :new, :digest, :hexdigest
34
34
  end
@@ -7,7 +7,7 @@ module Noise
7
7
  HASHLEN = 32
8
8
  BLOCKLEN = 64
9
9
  def hash(data)
10
- RbNaCl::Hash.sha256(data)
10
+ OpenSSL::Digest.digest('SHA256', data)
11
11
  end
12
12
 
13
13
  def hashlen
@@ -7,7 +7,7 @@ module Noise
7
7
  HASHLEN = 64
8
8
  BLOCKLEN = 128
9
9
  def hash(data)
10
- RbNaCl::Hash.sha512(data)
10
+ OpenSSL::Digest.digest('SHA512', data)
11
11
  end
12
12
 
13
13
  def hashlen
@@ -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.12.0'
4
+ VERSION = '0.14.0'
5
5
  end
data/lib/noise.rb CHANGED
@@ -3,9 +3,7 @@
3
3
  require 'noise/version'
4
4
 
5
5
  require 'ecdsa'
6
- require 'logger'
7
6
  require 'openssl'
8
- require 'rbnacl'
9
7
  require 'ruby_hmac'
10
8
  require 'securerandom'
11
9
 
@@ -27,18 +25,25 @@ module Noise
27
25
  @unavailable_dependencies = {}
28
26
 
29
27
  class << self
30
- def logger
31
- @logger ||= Logger.new($stdout)
32
- end
33
-
28
+ # Requires an optional backend gem and yields once it is loaded. A LoadError is not fatal:
29
+ # the reason is recorded so optional_dependency! can raise it later, and a warning goes to
30
+ # $stderr so the missing backend is visible at load time. Kernel#warn is used rather than a
31
+ # Logger because logger is no longer a default gem on Ruby 4.0, and this single message does
32
+ # not justify a runtime dependency on it.
34
33
  def require_optional(name)
35
34
  require name
36
- yield if block_given?
37
35
  rescue LoadError => e
38
36
  @unavailable_dependencies[name] = e.message
39
- logger.warn("Optional dependency '#{name}' is unavailable: #{e.message}")
37
+ warn("Optional dependency '#{name}' is unavailable: #{e.message}")
38
+ else
39
+ # The block runs outside the rescue on purpose: a LoadError raised by the block itself is
40
+ # about something other than this dependency, and swallowing it here would hide the cause.
41
+ yield if block_given?
40
42
  end
41
43
 
44
+ # Raises MissingDependencyError if the named optional backend failed to load earlier. Call it
45
+ # from the entry point of a function that needs the backend, so the failure surfaces where the
46
+ # backend is used rather than at require time.
42
47
  def optional_dependency!(name)
43
48
  reason = @unavailable_dependencies[name]
44
49
  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,17 +30,15 @@ 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.
37
- spec.add_development_dependency 'blake3'
38
- spec.add_development_dependency 'secp256k1-ruby'
33
+ # Optional backend. BLAKE3 is needed only when it appears in a protocol name, so it is not a
34
+ # runtime dependency. Add it to your own Gemfile if you need it; see the README.
35
+ spec.add_development_dependency 'blake3-rb'
39
36
 
40
37
  spec.add_runtime_dependency 'ecdsa'
41
- # The 448 DH function needs the raw key API (OpenSSL::PKey.new_raw_private_key and friends),
42
- # which arrived in openssl 3.0. Ruby 3.0 still ships 2.2 as its default gem, so the version has
43
- # to be requested explicitly rather than left to whatever the interpreter bundles.
38
+ # The 25519 and 448 DH functions need the raw key API (OpenSSL::PKey.new_raw_private_key and
39
+ # friends), which arrived in openssl 3.0. Every supported interpreter bundles a newer default
40
+ # gem, but the floor is stated so an older openssl pinned in an application's Gemfile fails to
41
+ # resolve rather than failing at runtime.
44
42
  spec.add_runtime_dependency 'openssl', '>= 3.0'
45
- spec.add_runtime_dependency 'rbnacl'
46
43
  spec.add_runtime_dependency 'ruby-hmac'
47
44
  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.12.0
4
+ version: 0.14.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-08-01 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
@@ -109,21 +94,7 @@ dependencies:
109
94
  - !ruby/object:Gem::Version
110
95
  version: '0'
111
96
  - !ruby/object:Gem::Dependency
112
- name: blake3
113
- requirement: !ruby/object:Gem::Requirement
114
- requirements:
115
- - - ">="
116
- - !ruby/object:Gem::Version
117
- version: '0'
118
- type: :development
119
- prerelease: false
120
- version_requirements: !ruby/object:Gem::Requirement
121
- requirements:
122
- - - ">="
123
- - !ruby/object:Gem::Version
124
- version: '0'
125
- - !ruby/object:Gem::Dependency
126
- name: secp256k1-ruby
97
+ name: blake3-rb
127
98
  requirement: !ruby/object:Gem::Requirement
128
99
  requirements:
129
100
  - - ">="
@@ -164,20 +135,6 @@ dependencies:
164
135
  - - ">="
165
136
  - !ruby/object:Gem::Version
166
137
  version: '3.0'
167
- - !ruby/object:Gem::Dependency
168
- name: rbnacl
169
- requirement: !ruby/object:Gem::Requirement
170
- requirements:
171
- - - ">="
172
- - !ruby/object:Gem::Version
173
- version: '0'
174
- type: :runtime
175
- prerelease: false
176
- version_requirements: !ruby/object:Gem::Requirement
177
- requirements:
178
- - - ">="
179
- - !ruby/object:Gem::Version
180
- version: '0'
181
138
  - !ruby/object:Gem::Dependency
182
139
  name: ruby-hmac
183
140
  requirement: !ruby/object:Gem::Requirement
@@ -259,23 +216,21 @@ files:
259
216
  homepage: https://github.com/Yamaguchi/noise
260
217
  licenses: []
261
218
  metadata: {}
262
- post_install_message:
263
219
  rdoc_options: []
264
220
  require_paths:
265
221
  - lib
266
222
  required_ruby_version: !ruby/object:Gem::Requirement
267
223
  requirements:
268
- - - "~>"
224
+ - - ">="
269
225
  - !ruby/object:Gem::Version
270
- version: '3.0'
226
+ version: '3.2'
271
227
  required_rubygems_version: !ruby/object:Gem::Requirement
272
228
  requirements:
273
229
  - - ">="
274
230
  - !ruby/object:Gem::Version
275
231
  version: '0'
276
232
  requirements: []
277
- rubygems_version: 3.5.16
278
- signing_key:
233
+ rubygems_version: 4.0.16
279
234
  specification_version: 4
280
235
  summary: A Ruby implementation of the Noise Protocol framework
281
236
  test_files: []