noise-ruby 0.13.0 → 0.15.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.
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noise
4
+ module Exceptions
5
+ # Raised when a transport operation is called while the handshake is still running.
6
+ class HandshakeNotFinishedError < NoiseHandshakeError
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noise
4
+ module Exceptions
5
+ # Raised when an operation needs a handshake that Connection#start_handshake has not begun yet.
6
+ class HandshakeNotStartedError < NoiseHandshakeError
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noise
4
+ module Exceptions
5
+ # Raised when a handshake message is written where one has to be read, or read where one has to
6
+ # be written. A Noise handshake alternates between the two parties, so only one of the two is
7
+ # legal at any point.
8
+ class HandshakeTurnError < NoiseHandshakeError
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noise
4
+ module Exceptions
5
+ # Raised when a transport layer gave up waiting for the rest of a message to arrive.
6
+ class ReadTimeoutError < RuntimeError
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noise
4
+ module Exceptions
5
+ # Raised when a stream ends part way through a message that a transport layer was reading.
6
+ class TruncatedMessageError < RuntimeError
7
+ end
8
+ end
9
+ end
@@ -4,16 +4,22 @@ module Noise
4
4
  module Exceptions
5
5
  autoload :DecryptError, 'noise/exceptions/decrypt_error'
6
6
  autoload :EncryptError, 'noise/exceptions/encrypt_error'
7
+ autoload :HandshakeAlreadyFinishedError, 'noise/exceptions/handshake_already_finished_error'
8
+ autoload :HandshakeNotFinishedError, 'noise/exceptions/handshake_not_finished_error'
9
+ autoload :HandshakeNotStartedError, 'noise/exceptions/handshake_not_started_error'
10
+ autoload :HandshakeTurnError, 'noise/exceptions/handshake_turn_error'
7
11
  autoload :InvalidNonceError, 'noise/exceptions/invalid_nonce_error'
8
12
  autoload :InvalidPublicKeyError, 'noise/exceptions/invalid_public_key_error'
9
13
  autoload :MaxNonceError, 'noise/exceptions/max_nonce_error'
10
14
  autoload :MessageTooLongError, 'noise/exceptions/message_too_long_error'
11
15
  autoload :MissingDependencyError, 'noise/exceptions/missing_dependency_error'
12
16
  autoload :ProtocolNameError, 'noise/exceptions/protocol_name_error'
17
+ autoload :ReadTimeoutError, 'noise/exceptions/read_timeout_error'
13
18
  autoload :NoiseHandshakeError, 'noise/exceptions/noise_handshake_error'
14
19
  autoload :NoiseValidationError, 'noise/exceptions/noise_validation_error'
15
20
  autoload :NoisePSKError, 'noise/exceptions/noise_psk_error'
16
21
  autoload :PSKValueError, 'noise/exceptions/psk_value_error'
22
+ autoload :TruncatedMessageError, 'noise/exceptions/truncated_message_error'
17
23
  autoload :UnsupportedModifierError, 'noise/exceptions/unsupported_modifier_error'
18
24
  end
19
25
  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
@@ -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
data/lib/noise/pattern.rb CHANGED
@@ -102,14 +102,16 @@ module Noise
102
102
  class Pattern
103
103
  attr_reader :name, :tokens, :modifiers, :psk_count, :fallback
104
104
 
105
- NAME_REGEX = /\A([A-Z1]+)([^A-Z]*)\z/
106
-
107
- def self.create(name)
108
- matched = NAME_REGEX.match(name)
109
- raise Noise::Exceptions::ProtocolNameError, "Malformed pattern name: #{name}" unless matched
110
-
111
- modifiers = matched[2].split('+').map { |s| Modifier.parse(s) }
112
- pattern_class(matched[1]).new(modifiers)
105
+ # Noise::ProtocolName tells a pattern name from the modifiers written after it, so both
106
+ # arrive here already separated.
107
+ #
108
+ # @param [String] name the handshake pattern name on its own, for example 'XX'.
109
+ # @param [Array<Noise::Modifier::Psk, Noise::Modifier::Fallback>] modifiers the modifiers to
110
+ # apply to it. Call #apply_pattern_modifiers to have them rewrite the token list.
111
+ # @raise [Noise::Exceptions::ProtocolNameError] if no pattern goes by that name.
112
+ # @return [Noise::Pattern]
113
+ def self.create(name, modifiers = [])
114
+ pattern_class(name).new(modifiers)
113
115
  end
114
116
 
115
117
  def self.pattern_class(pattern)
@@ -3,7 +3,7 @@
3
3
  module Noise
4
4
  class Protocol
5
5
  attr_accessor :cipher_fn, :hash_fn, :dh_fn, :hkdf_fn
6
- attr_reader :name, :pattern
6
+ attr_reader :name, :pattern, :protocol_name
7
7
 
8
8
  CIPHER = {
9
9
  'AESGCM' => Noise::Functions::Cipher::AesGcm,
@@ -24,35 +24,59 @@ module Noise
24
24
  'BLAKE3' => Noise::Functions::Hash::Blake3
25
25
  }.freeze
26
26
 
27
+ # @param [String] name the protocol name, for example 'Noise_XX_25519_ChaChaPoly_SHA256'.
28
+ # @raise [Noise::Exceptions::ProtocolNameError] if the name is malformed, or names a pattern
29
+ # or a function this gem does not implement.
30
+ # @raise [Noise::Exceptions::UnsupportedModifierError] if it names a modifier this gem does
31
+ # not implement.
32
+ # @return [Noise::Protocol]
27
33
  def self.create(name)
28
- parts = name.split('_')
29
- raise Noise::Exceptions::ProtocolNameError, "Malformed protocol name: #{name}" unless parts.size == 5
30
-
31
- prefix, pattern_name, dh_name, cipher_name, hash_name = parts
32
- raise Noise::Exceptions::ProtocolNameError, "Malformed protocol name: #{name}" if prefix != 'Noise'
33
-
34
- new(name, pattern_name, cipher_name, hash_name, dh_name)
34
+ new(Noise::ProtocolName.parse(name))
35
35
  end
36
36
 
37
- def initialize(name, pattern_name, cipher_name, hash_name, dh_name)
38
- @name = name
39
- @pattern = Noise::Pattern.create(pattern_name)
40
- @hkdf_fn = Noise::Functions::Hash.create_hkdf_fn(hash_name)
37
+ # @param [Noise::ProtocolName] protocol_name the parsed name this protocol runs.
38
+ def initialize(protocol_name)
39
+ @protocol_name = protocol_name
40
+ @name = protocol_name.name
41
+ @pattern = Noise::Pattern.create(protocol_name.pattern_name, protocol_name.modifiers)
42
+ @hkdf_fn = Noise::Functions::Hash.create_hkdf_fn(protocol_name.hash_name)
41
43
  @pattern.apply_pattern_modifiers
42
44
 
43
- initialize_fn!(cipher_name, hash_name, dh_name)
45
+ initialize_fn!
46
+ end
47
+
48
+ def psk?
49
+ @pattern.psk?
44
50
  end
45
51
 
46
- def initialize_fn!(cipher_name, hash_name, dh_name)
47
- @cipher_fn = CIPHER[cipher_name]&.new
48
- @hash_fn = HASH[hash_name]&.new
49
- @dh_fn = DH[dh_name]&.new
52
+ private
53
+
54
+ # Looks the three functions up by the names the protocol name gives them.
55
+ #
56
+ # @raise [Noise::Exceptions::ProtocolNameError] if any of the three is one this gem does not
57
+ # implement.
58
+ def initialize_fn!
59
+ @cipher_fn = CIPHER[@protocol_name.cipher_name]&.new
60
+ @hash_fn = HASH[@protocol_name.hash_name]&.new
61
+ @dh_fn = create_dh_fn(@protocol_name.dh_names)
50
62
  raise Noise::Exceptions::ProtocolNameError, "Unsupported function in: #{@name}" unless
51
63
  @cipher_fn && @hash_fn && @dh_fn
52
64
  end
53
65
 
54
- def psk?
55
- @pattern.psk?
66
+ # A name may list more than one DH function, joined with '+', which is how a hybrid handshake
67
+ # is written. This gem runs a single DH function, so any name that lists more than one, or
68
+ # that leaves a member of the list empty, resolves to nothing and is reported as unsupported.
69
+ #
70
+ # The hybrid names the Noise extensions define also carry a modifier, hfs, which
71
+ # Noise::ProtocolName rejects first, so what reaches here today is a name that lists several
72
+ # DH functions and nothing else.
73
+ #
74
+ # @param [Array<String>] names the DH function names the protocol name lists.
75
+ # @return [Object, nil] the DH function, or nil if the name asks for one this gem lacks.
76
+ def create_dh_fn(names)
77
+ return nil unless names.size == 1
78
+
79
+ DH[names.first]&.new
56
80
  end
57
81
  end
58
82
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noise
4
+ # A Noise protocol name, split into the parts that name a handshake pattern and the three
5
+ # functions the protocol runs on.
6
+ #
7
+ # A name has five parts joined with '_': the prefix 'Noise', the handshake pattern with its
8
+ # modifiers, the DH function, the cipher function and the hash function, for example
9
+ # 'Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s'.
10
+ #
11
+ # Two of those parts hold a list of their own. The pattern part is a pattern name followed by
12
+ # the modifiers applied to it, and the DH part is a list joined with '+', because a hybrid
13
+ # handshake names two DH functions at once ('Noise_NNhfs_25519+448_ChaChaPoly_BLAKE2s'). Both
14
+ # are split here, so that every rule about the shape of a name lives in this class alone.
15
+ #
16
+ # Which functions exist is Noise::Protocol's question, not this one's: a name that asks for a
17
+ # cipher, hash or DH function this gem does not implement still parses. Modifiers are the one
18
+ # exception, because a modifier has to be understood before it can be held as a value, so an
19
+ # unknown one is rejected here.
20
+ #
21
+ # The members are the names as written, not the functions themselves. Noise::Protocol resolves
22
+ # them, and calls its resolved functions cipher_fn, hash_fn and dh_fn.
23
+ ProtocolName = Data.define(:name, :pattern_name, :modifiers, :dh_names, :cipher_name,
24
+ :hash_name) do
25
+ # Parses a protocol name.
26
+ #
27
+ # @param [String] name for example 'Noise_XX_25519_ChaChaPoly_SHA256'.
28
+ # @raise [Noise::Exceptions::ProtocolNameError] if the name is not five parts with the 'Noise'
29
+ # prefix and a pattern part whose modifiers can be told from the pattern name.
30
+ # @raise [Noise::Exceptions::UnsupportedModifierError] if a modifier is written correctly but
31
+ # is not one this gem implements.
32
+ # @return [Noise::ProtocolName]
33
+ def self.parse(name)
34
+ # 'Noise', the pattern with its modifiers, the DH functions, the cipher and the hash.
35
+ parts = name.split('_')
36
+ malformed!(name) unless parts.size == 5
37
+
38
+ prefix, pattern_part, dh_part, cipher_name, hash_name = parts
39
+ malformed!(name) unless prefix == 'Noise'
40
+
41
+ # A pattern name is capitals, plus the digit 1 that deferred patterns use (X1K1). Whatever
42
+ # follows it is the modifiers.
43
+ matched = /\A([A-Z1]+)([^A-Z]*)\z/.match(pattern_part)
44
+ malformed!(name) unless matched
45
+
46
+ new(name: name, pattern_name: matched[1], modifiers: parse_modifiers(matched[2]),
47
+ dh_names: split_dh(dh_part), cipher_name: cipher_name, hash_name: hash_name)
48
+ end
49
+
50
+ # @param [String] part the modifiers as written, which is '' when there are none.
51
+ # @return [Array] the modifiers, in the order the name writes them.
52
+ def self.parse_modifiers(part)
53
+ part.split('+').map { |modifier| Modifier.parse(modifier) }.freeze
54
+ end
55
+ private_class_method :parse_modifiers
56
+
57
+ # Keeps the empty members a stray '+' leaves behind, so that '25519+' reads as two DH
58
+ # functions, the second of which has no name, rather than as the single function '25519'.
59
+ # Nothing resolves an empty name, so such a name is reported as unsupported.
60
+ #
61
+ # @param [String] part the DH functions as written.
62
+ # @return [Array<String>] one name per member.
63
+ def self.split_dh(part)
64
+ part.split('+', -1).freeze
65
+ end
66
+ private_class_method :split_dh
67
+
68
+ def self.malformed!(name)
69
+ raise Noise::Exceptions::ProtocolNameError, "Malformed protocol name: #{name}"
70
+ end
71
+ private_class_method :malformed!
72
+
73
+ # @return [String] the name this was parsed from.
74
+ def to_s
75
+ name
76
+ end
77
+ end
78
+ end