protocol-zmtp 0.1.2 → 0.3.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: dafca992ff161d0cdb81664bfc85fd7f9194517222dcb5d11426a35c6a5f1c38
4
- data.tar.gz: 9176b6c3090ef6ccaab8c37bb95d00f781a91c38a57606f08215ccaaf835726c
3
+ metadata.gz: a8774efa1927a863efbec0173b00b4642fe99f809ee0df07e3ab6a164829909e
4
+ data.tar.gz: a77071e52f4461323f7d839738ad56d0ed83af312ae1071b4f88ffb365ee2514
5
5
  SHA512:
6
- metadata.gz: 0aeecc4dd2454e4953ca64a5211aae337cc05846a348434f90cecd258495aefcac9489dc264df65dcbe9e7d0c266436ecdca355c80a8e4423a4b6c052d904f91
7
- data.tar.gz: f725b19d48b976315b64e2c1f44663f0454db080a737de074f84a77122e623e8673afb7d662627972d680b6b9af7c209aab90c4c95a1a2654706b5daefe89dd9
6
+ metadata.gz: c0cc5712a1c5aabd5f360ea5fe052bb45e963f645551f0bea2a46f62d3d43d4f9f8a0a0cd3a2de63e563641d2463586b0574016db6ab394a66b78a0c7bd93924
7
+ data.tar.gz: 87ae97e2a6bc9448b16a9053d959e7b7707639bb15ad168bc020f83eae0d95309b82be6a0772a38d6e56307038de013d7544e836094c5d52b4dfb114175b5a28
@@ -30,6 +30,7 @@ module Protocol
30
30
  @data = data.b
31
31
  end
32
32
 
33
+
33
34
  # Encodes as a command frame body.
34
35
  #
35
36
  # @return [String] binary body (name-length + name + data)
@@ -38,6 +39,7 @@ module Protocol
38
39
  name_bytes.bytesize.chr.b + name_bytes + @data
39
40
  end
40
41
 
42
+
41
43
  # Encodes as a complete command Frame.
42
44
  #
43
45
  # @return [Frame]
@@ -45,6 +47,7 @@ module Protocol
45
47
  Frame.new(to_body, command: true)
46
48
  end
47
49
 
50
+
48
51
  # Decodes a command from a frame body.
49
52
  #
50
53
  # @param body [String] binary frame body
@@ -63,49 +66,78 @@ module Protocol
63
66
  new(name, data)
64
67
  end
65
68
 
66
- # Builds a READY command with Socket-Type and Identity properties.
67
- def self.ready(socket_type:, identity: "")
68
- props = encode_properties(
69
- "Socket-Type" => socket_type,
70
- "Identity" => identity,
71
- )
72
- new("READY", props)
69
+
70
+ # Builds a READY command with Socket-Type, Identity, and optional X-QoS properties.
71
+ #
72
+ # @param qos [Integer] QoS level (0 = omitted)
73
+ # @param qos_hash [String] supported hash algorithms in preference order (e.g. "xXsS")
74
+ # @return [Command]
75
+ def self.ready(socket_type:, identity: "", qos: 0, qos_hash: "")
76
+ props = { "Socket-Type" => socket_type, "Identity" => identity }
77
+ if qos > 0
78
+ props["X-QoS"] = qos.to_s
79
+ props["X-QoS-Hash"] = qos_hash unless qos_hash.empty?
80
+ end
81
+ new("READY", encode_properties(props))
73
82
  end
74
83
 
84
+
75
85
  # Builds a SUBSCRIBE command.
86
+ #
87
+ # @param prefix [String] subscription prefix to match
88
+ # @return [Command]
76
89
  def self.subscribe(prefix)
77
90
  new("SUBSCRIBE", prefix.b)
78
91
  end
79
92
 
93
+
80
94
  # Builds a CANCEL command (unsubscribe).
95
+ #
96
+ # @param prefix [String] subscription prefix to cancel
97
+ # @return [Command]
81
98
  def self.cancel(prefix)
82
99
  new("CANCEL", prefix.b)
83
100
  end
84
101
 
102
+
85
103
  # Builds a JOIN command (RADIO/DISH group subscription).
104
+ #
105
+ # @param group [String] group name to join
106
+ # @return [Command]
86
107
  def self.join(group)
87
108
  new("JOIN", group.b)
88
109
  end
89
110
 
111
+
90
112
  # Builds a LEAVE command (RADIO/DISH group unsubscription).
113
+ #
114
+ # @param group [String] group name to leave
115
+ # @return [Command]
91
116
  def self.leave(group)
92
117
  new("LEAVE", group.b)
93
118
  end
94
119
 
120
+
95
121
  # Builds a PING command.
96
122
  #
97
123
  # @param ttl [Numeric] time-to-live in seconds (sent as deciseconds)
98
124
  # @param context [String] optional context bytes (up to 16 bytes)
125
+ # @return [Command]
99
126
  def self.ping(ttl: 0, context: "".b)
100
127
  ttl_ds = (ttl * 10).to_i
101
128
  new("PING", [ttl_ds].pack("n") + context.b)
102
129
  end
103
130
 
131
+
104
132
  # Builds a PONG command.
133
+ #
134
+ # @param context [String] context bytes echoed from the PING
135
+ # @return [Command]
105
136
  def self.pong(context: "".b)
106
137
  new("PONG", context.b)
107
138
  end
108
139
 
140
+
109
141
  # Extracts TTL (in seconds) and context from a PING command's data.
110
142
  #
111
143
  # @return [Array(Numeric, String)] [ttl_seconds, context_bytes]
@@ -115,12 +147,19 @@ module Protocol
115
147
  [ttl_ds / 10.0, context]
116
148
  end
117
149
 
150
+
118
151
  # Parses READY command data as a property list.
152
+ #
153
+ # @return [Hash{String => String}] property name-value pairs
119
154
  def properties
120
155
  self.class.decode_properties(@data)
121
156
  end
122
157
 
158
+
123
159
  # Encodes a hash of properties into ZMTP property list format.
160
+ #
161
+ # @param props [Hash{String => String}] property name-value pairs
162
+ # @return [String] binary-encoded property list
124
163
  def self.encode_properties(props)
125
164
  parts = props.map do |name, value|
126
165
  name_bytes = name.b
@@ -130,7 +169,12 @@ module Protocol
130
169
  parts.join
131
170
  end
132
171
 
172
+
133
173
  # Decodes a ZMTP property list from binary data.
174
+ #
175
+ # @param data [String] binary-encoded property list
176
+ # @return [Hash{String => String}] property name-value pairs
177
+ # @raise [Error] on malformed property data
134
178
  def self.decode_properties(data)
135
179
  result = {}
136
180
  offset = 0
@@ -15,9 +15,11 @@ module Protocol
15
15
  FLAGS_LONG = 0x02
16
16
  FLAGS_COMMAND = 0x04
17
17
 
18
+
18
19
  # Short frame: 1-byte size, max body 255 bytes.
19
20
  SHORT_MAX = 255
20
21
 
22
+
21
23
  # @return [String] frame body (binary)
22
24
  attr_reader :body
23
25
 
@@ -30,6 +32,7 @@ module Protocol
30
32
  @command = command
31
33
  end
32
34
 
35
+
33
36
  # @return [Boolean] true if more frames follow in this message
34
37
  def more? = @more
35
38
 
@@ -52,6 +55,7 @@ module Protocol
52
55
  end
53
56
  end
54
57
 
58
+
55
59
  # Encodes a multi-part message into a single wire-format string.
56
60
  # The result can be written to multiple connections without
57
61
  # re-encoding each time (useful for fan-out patterns like PUB).
@@ -67,6 +71,7 @@ module Protocol
67
71
  buf.freeze
68
72
  end
69
73
 
74
+
70
75
  # Reads one frame from an IO-like object.
71
76
  #
72
77
  # @param io [#read_exactly] must support read_exactly(n)
@@ -26,6 +26,7 @@ module Protocol
26
26
  MECHANISM_LENGTH = 20
27
27
  AS_SERVER_OFFSET = 32
28
28
 
29
+
29
30
  # Encodes a ZMTP 3.1 greeting.
30
31
  #
31
32
  # @param mechanism [String] security mechanism name (e.g. "NULL")
@@ -39,6 +40,7 @@ module Protocol
39
40
  buf << ("\x00" * 31)
40
41
  end
41
42
 
43
+
42
44
  # Decodes a ZMTP greeting.
43
45
  #
44
46
  # @param data [String] 64-byte binary greeting
@@ -18,6 +18,12 @@ module Protocol
18
18
  # @return [String] peer's identity (from READY handshake)
19
19
  attr_reader :peer_identity
20
20
 
21
+ # @return [Integer] peer's QoS level (from READY handshake, 0 if absent)
22
+ attr_reader :peer_qos
23
+
24
+ # @return [String] peer's supported hash algorithms in preference order
25
+ attr_reader :peer_qos_hash
26
+
21
27
  # @return [Object] transport IO (#read_exactly, #write, #flush, #close)
22
28
  attr_reader :io
23
29
 
@@ -31,7 +37,7 @@ module Protocol
31
37
  # @param mechanism [Mechanism::Null, Mechanism::Curve] security mechanism
32
38
  # @param max_message_size [Integer, nil] max frame size in bytes, nil = unlimited
33
39
  def initialize(io, socket_type:, identity: "", as_server: false,
34
- mechanism: nil, max_message_size: nil)
40
+ mechanism: nil, max_message_size: nil, qos: 0, qos_hash: "")
35
41
  @io = io
36
42
  @socket_type = socket_type
37
43
  @identity = identity
@@ -39,11 +45,16 @@ module Protocol
39
45
  @mechanism = mechanism || Mechanism::Null.new
40
46
  @peer_socket_type = nil
41
47
  @peer_identity = nil
48
+ @peer_qos = nil
49
+ @peer_qos_hash = nil
50
+ @qos = qos
51
+ @qos_hash = qos_hash
42
52
  @mutex = Mutex.new
43
53
  @max_message_size = max_message_size
44
54
  @last_received_at = nil
45
55
  end
46
56
 
57
+
47
58
  # Performs the full ZMTP handshake via the configured mechanism.
48
59
  #
49
60
  # @return [void]
@@ -54,10 +65,14 @@ module Protocol
54
65
  as_server: @as_server,
55
66
  socket_type: @socket_type,
56
67
  identity: @identity,
68
+ qos: @qos,
69
+ qos_hash: @qos_hash,
57
70
  )
58
71
 
59
72
  @peer_socket_type = result[:peer_socket_type]
60
73
  @peer_identity = result[:peer_identity]
74
+ @peer_qos = result[:peer_qos] || 0
75
+ @peer_qos_hash = result[:peer_qos_hash] || ""
61
76
 
62
77
  unless @peer_socket_type
63
78
  raise Error, "peer READY missing Socket-Type"
@@ -69,6 +84,7 @@ module Protocol
69
84
  end
70
85
  end
71
86
 
87
+
72
88
  # Sends a multi-frame message (write + flush).
73
89
  #
74
90
  # @param parts [Array<String>] message frames
@@ -80,6 +96,7 @@ module Protocol
80
96
  end
81
97
  end
82
98
 
99
+
83
100
  # Writes a multi-frame message to the buffer without flushing.
84
101
  # Call {#flush} after batching writes.
85
102
  #
@@ -91,6 +108,7 @@ module Protocol
91
108
  end
92
109
  end
93
110
 
111
+
94
112
  # Writes pre-encoded wire bytes to the buffer without flushing.
95
113
  # Used for fan-out: encode once, write to many connections.
96
114
  #
@@ -102,22 +120,26 @@ module Protocol
102
120
  end
103
121
  end
104
122
 
123
+
105
124
  # Returns true if the ZMTP mechanism encrypts at the frame level
106
- # (e.g. CURVE). TLS encryption is below ZMTP and does not affect
107
- # wire-format bytes.
125
+ # (e.g. CURVE, BLAKE3ZMQ).
108
126
  #
109
127
  # @return [Boolean]
110
- def curve?
128
+ def encrypted?
111
129
  @mechanism.encrypted?
112
130
  end
113
131
 
132
+
114
133
  # Flushes the write buffer to the underlying IO.
134
+ #
135
+ # @return [void]
115
136
  def flush
116
137
  @mutex.synchronize do
117
138
  @io.flush
118
139
  end
119
140
  end
120
141
 
142
+
121
143
  # Receives a multi-frame message.
122
144
  # PING/PONG commands are handled automatically by #read_frame.
123
145
  #
@@ -137,6 +159,7 @@ module Protocol
137
159
  frames.freeze
138
160
  end
139
161
 
162
+
140
163
  # Sends a command.
141
164
  #
142
165
  # @param command [Codec::Command]
@@ -152,9 +175,11 @@ module Protocol
152
175
  end
153
176
  end
154
177
 
178
+
155
179
  # Reads one frame from the wire. Handles PING/PONG automatically.
156
- # When using an encrypted mechanism, MESSAGE commands are decrypted
157
- # back to ZMTP frames transparently.
180
+ # When using an encrypted mechanism, all frames are decrypted
181
+ # transparently (supports both CURVE MESSAGE wrapping and inline
182
+ # encryption like BLAKE3ZMQ).
158
183
  #
159
184
  # @return [Codec::Frame]
160
185
  # @raise [EOFError] if connection is closed
@@ -168,9 +193,7 @@ module Protocol
168
193
  end
169
194
  touch_heartbeat
170
195
 
171
- if @mechanism.encrypted? && frame.body.bytesize > 8 && frame.body.byteslice(0, 8) == "\x07MESSAGE".b
172
- frame = @mechanism.decrypt(frame)
173
- end
196
+ frame = @mechanism.decrypt(frame) if @mechanism.encrypted?
174
197
 
175
198
  if frame.command?
176
199
  cmd = Codec::Command.from_body(frame.body)
@@ -187,11 +210,15 @@ module Protocol
187
210
  end
188
211
  end
189
212
 
213
+
190
214
  # Records that a frame was received (for heartbeat expiry tracking).
215
+ #
216
+ # @return [void]
191
217
  def touch_heartbeat
192
218
  @last_received_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
193
219
  end
194
220
 
221
+
195
222
  # Returns true if no frame has been received within +timeout+ seconds.
196
223
  #
197
224
  # @param timeout [Numeric] seconds
@@ -201,7 +228,10 @@ module Protocol
201
228
  (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_received_at) > timeout
202
229
  end
203
230
 
231
+
204
232
  # Closes the connection.
233
+ #
234
+ # @return [void]
205
235
  def close
206
236
  @io.close
207
237
  rescue IOError
@@ -3,6 +3,7 @@
3
3
  module Protocol
4
4
  module ZMTP
5
5
  # Raised on ZMTP protocol violations.
6
- class Error < RuntimeError; end
6
+ class Error < RuntimeError
7
+ end
7
8
  end
8
9
  end
@@ -23,6 +23,7 @@ module Protocol
23
23
  class Curve
24
24
  MECHANISM_NAME = "CURVE"
25
25
 
26
+
26
27
  # Nonce prefixes.
27
28
  NONCE_PREFIX_HELLO = "CurveZMQHELLO---"
28
29
  NONCE_PREFIX_WELCOME = "WELCOME-"
@@ -33,46 +34,66 @@ module Protocol
33
34
  NONCE_PREFIX_VOUCH = "VOUCH---"
34
35
  NONCE_PREFIX_COOKIE = "COOKIE--"
35
36
 
37
+
36
38
  BOX_OVERHEAD = 16
37
39
  MAX_NONCE = (2**64) - 1
38
40
 
41
+
39
42
  # Creates a CURVE server mechanism.
40
43
  #
41
44
  # @param public_key [String] 32 bytes
42
45
  # @param secret_key [String] 32 bytes
43
46
  # @param crypto [Module] NaCl-compatible backend (RbNaCl or Nuckle)
44
- # @param authenticator [#include?, #call, nil] client key authenticator
47
+ # @param authenticator [#call, nil] called with a {PeerInfo}
48
+ # during authentication; must return truthy to allow the connection.
49
+ # When nil, any client with a valid vouch is accepted.
45
50
  # @return [Curve]
46
- def self.server(public_key, secret_key, crypto:, authenticator: nil)
51
+ def self.server(public_key:, secret_key:, crypto:, authenticator: nil)
47
52
  new(public_key:, secret_key:, crypto:, as_server: true, authenticator:)
48
53
  end
49
54
 
55
+
50
56
  # Creates a CURVE client mechanism.
51
57
  #
52
- # @param public_key [String] 32 bytes
53
- # @param secret_key [String] 32 bytes
54
- # @param server_key [String] 32 bytes
58
+ # @param server_key [String] 32 bytes (server permanent public key)
55
59
  # @param crypto [Module] NaCl-compatible backend (RbNaCl or Nuckle)
60
+ # @param public_key [String, nil] 32 bytes (or nil for auto-generated ephemeral identity)
61
+ # @param secret_key [String, nil] 32 bytes (or nil for auto-generated ephemeral identity)
56
62
  # @return [Curve]
57
- def self.client(public_key, secret_key, server_key:, crypto:)
63
+ def self.client(server_key:, crypto:, public_key: nil, secret_key: nil)
58
64
  new(public_key:, secret_key:, server_key:, crypto:, as_server: false)
59
65
  end
60
66
 
61
- def initialize(server_key: nil, public_key:, secret_key:, crypto:, as_server: false, authenticator: nil)
62
- validate_key!(public_key, "public_key")
63
- validate_key!(secret_key, "secret_key")
64
67
 
65
- @crypto = crypto
66
- @permanent_public = crypto::PublicKey.new(public_key.b)
67
- @permanent_secret = crypto::PrivateKey.new(secret_key.b)
68
- @as_server = as_server
69
- @authenticator = authenticator
68
+ # @param public_key [String, nil] 32-byte permanent public key
69
+ # @param secret_key [String, nil] 32-byte permanent secret key
70
+ # @param server_key [String, nil] 32-byte server permanent public key (client only)
71
+ # @param crypto [Module] NaCl-compatible crypto backend
72
+ # @param as_server [Boolean] whether this side acts as the CURVE server
73
+ # @param authenticator [#call, nil] optional server-side authenticator
74
+ def initialize(public_key: nil, secret_key: nil, server_key: nil, crypto:, as_server: false, authenticator: nil)
75
+ @crypto = crypto
76
+ @as_server = as_server
77
+ @authenticator = authenticator
70
78
 
71
79
  if as_server
80
+ validate_key!(public_key, "public_key")
81
+ validate_key!(secret_key, "secret_key")
82
+ @permanent_public = crypto::PublicKey.new(public_key.b)
83
+ @permanent_secret = crypto::PrivateKey.new(secret_key.b)
72
84
  @cookie_key = crypto::Random.random_bytes(32)
73
85
  else
74
86
  validate_key!(server_key, "server_key")
75
87
  @server_public = crypto::PublicKey.new(server_key.b)
88
+ if public_key && secret_key
89
+ validate_key!(public_key, "public_key")
90
+ validate_key!(secret_key, "secret_key")
91
+ @permanent_public = crypto::PublicKey.new(public_key.b)
92
+ @permanent_secret = crypto::PrivateKey.new(secret_key.b)
93
+ else
94
+ @permanent_secret = crypto::PrivateKey.generate
95
+ @permanent_public = @permanent_secret.public_key
96
+ end
76
97
  end
77
98
 
78
99
  @session_box = nil
@@ -80,6 +101,11 @@ module Protocol
80
101
  @recv_nonce = -1
81
102
  end
82
103
 
104
+
105
+ # Resets session state when duplicating (e.g. for a new connection).
106
+ #
107
+ # @param source [Curve] the original instance being duplicated
108
+ # @return [void]
83
109
  def initialize_dup(source)
84
110
  super
85
111
  @session_box = nil
@@ -89,16 +115,44 @@ module Protocol
89
115
  @recv_nonce_buf = nil
90
116
  end
91
117
 
118
+
119
+ # @return [Boolean] true -- CURVE always encrypts frames
92
120
  def encrypted? = true
93
121
 
94
- def handshake!(io, as_server:, socket_type:, identity:)
122
+ # Returns a periodic maintenance task for rotating the cookie key (server only).
123
+ #
124
+ # @return [Hash, nil] a hash with +:interval+ (seconds) and +:task+ (Proc), or nil for clients
125
+ def maintenance
126
+ return unless @as_server
127
+ { interval: 60, task: -> { @cookie_key = @crypto::Random.random_bytes(32) } }.freeze
128
+ end
129
+
130
+
131
+ # Performs the full CurveZMQ handshake (HELLO/WELCOME/INITIATE/READY).
132
+ #
133
+ # @param io [#read_exactly, #write, #flush] transport IO
134
+ # @param as_server [Boolean] ignored -- uses the value from #initialize
135
+ # @param socket_type [String] our socket type name
136
+ # @param identity [String] our identity
137
+ # @param qos [Integer] QoS level
138
+ # @param qos_hash [String] supported hash algorithms
139
+ # @return [Hash] { peer_socket_type:, peer_identity:, peer_qos:, peer_qos_hash: }
140
+ # @raise [Error] on handshake failure
141
+ def handshake!(io, as_server:, socket_type:, identity:, qos: 0, qos_hash: "")
95
142
  if @as_server
96
- server_handshake!(io, socket_type:, identity:)
143
+ server_handshake!(io, socket_type:, identity:, qos:, qos_hash:)
97
144
  else
98
- client_handshake!(io, socket_type:, identity:)
145
+ client_handshake!(io, socket_type:, identity:, qos:, qos_hash:)
99
146
  end
100
147
  end
101
148
 
149
+
150
+ # Encrypts a frame body into a CURVE MESSAGE command on the wire.
151
+ #
152
+ # @param body [String] plaintext frame body
153
+ # @param more [Boolean] whether more frames follow in this message
154
+ # @param command [Boolean] whether this is a command frame
155
+ # @return [String] binary wire bytes ready for writing
102
156
  def encrypt(body, more: false, command: false)
103
157
  flags = 0
104
158
  flags |= 0x01 if more
@@ -122,9 +176,16 @@ module Protocol
122
176
  wire << "\x07MESSAGE" << short_nonce << ciphertext
123
177
  end
124
178
 
179
+
125
180
  MESSAGE_PREFIX = "\x07MESSAGE".b.freeze
126
181
  MESSAGE_PREFIX_SIZE = MESSAGE_PREFIX.bytesize
127
182
 
183
+
184
+ # Decrypts a CURVE MESSAGE command frame back into a plaintext frame.
185
+ #
186
+ # @param frame [Codec::Frame] an encrypted MESSAGE command frame
187
+ # @return [Codec::Frame] the decrypted frame with restored flags
188
+ # @raise [Error] on decryption failure or nonce violation
128
189
  def decrypt(frame)
129
190
  body = frame.body
130
191
  unless body.start_with?(MESSAGE_PREFIX)
@@ -161,7 +222,7 @@ module Protocol
161
222
  # Client-side handshake
162
223
  # ----------------------------------------------------------------
163
224
 
164
- def client_handshake!(io, socket_type:, identity:)
225
+ def client_handshake!(io, socket_type:, identity:, qos: 0, qos_hash: "")
165
226
  cn_secret = @crypto::PrivateKey.generate
166
227
  cn_public = cn_secret.public_key
167
228
 
@@ -172,6 +233,7 @@ module Protocol
172
233
  raise Error, "expected CURVE mechanism, got #{peer_greeting[:mechanism]}"
173
234
  end
174
235
 
236
+
175
237
  # --- HELLO ---
176
238
  short_nonce = [1].pack("Q>")
177
239
  nonce = NONCE_PREFIX_HELLO + short_nonce
@@ -218,10 +280,12 @@ module Protocol
218
280
  vouch_plaintext = cn_public.to_s + @server_public.to_s
219
281
  vouch = @crypto::Box.new(sn_public, @permanent_secret).encrypt(vouch_nonce, vouch_plaintext)
220
282
 
221
- metadata = Codec::Command.encode_properties(
222
- "Socket-Type" => socket_type,
223
- "Identity" => identity,
224
- )
283
+ props = { "Socket-Type" => socket_type, "Identity" => identity }
284
+ if qos > 0
285
+ props["X-QoS"] = qos.to_s
286
+ props["X-QoS-Hash"] = qos_hash unless qos_hash.empty?
287
+ end
288
+ metadata = Codec::Command.encode_properties(props)
225
289
 
226
290
  initiate_box_plaintext = "".b
227
291
  initiate_box_plaintext << @permanent_public.to_s
@@ -264,20 +328,23 @@ module Protocol
264
328
  props = Codec::Command.decode_properties(r_plaintext)
265
329
  peer_socket_type = props["Socket-Type"]
266
330
  peer_identity = props["Identity"] || ""
331
+ peer_qos = (props["X-QoS"] || "0").to_i
332
+ peer_qos_hash = props["X-QoS-Hash"] || ""
267
333
 
268
334
  @session_box = session
269
335
  @send_nonce = 1
270
336
  @recv_nonce = 0
271
337
  init_nonce_buffers!
272
338
 
273
- { peer_socket_type: peer_socket_type, peer_identity: peer_identity }
339
+ { peer_socket_type: peer_socket_type, peer_identity: peer_identity, peer_qos: peer_qos, peer_qos_hash: peer_qos_hash }
274
340
  end
275
341
 
342
+
276
343
  # ----------------------------------------------------------------
277
344
  # Server-side handshake
278
345
  # ----------------------------------------------------------------
279
346
 
280
- def server_handshake!(io, socket_type:, identity:)
347
+ def server_handshake!(io, socket_type:, identity:, qos: 0, qos_hash: "")
281
348
  io.write(Codec::Greeting.encode(mechanism: MECHANISM_NAME, as_server: true))
282
349
  io.flush
283
350
  peer_greeting = Codec::Greeting.decode(io.read_exactly(Codec::Greeting::SIZE))
@@ -285,6 +352,7 @@ module Protocol
285
352
  raise Error, "expected CURVE mechanism, got #{peer_greeting[:mechanism]}"
286
353
  end
287
354
 
355
+
288
356
  # --- Read HELLO ---
289
357
  hello_frame = Codec::Frame.read_from(io)
290
358
  raise Error, "expected command frame" unless hello_frame.command?
@@ -308,6 +376,7 @@ module Protocol
308
376
  raise Error, "HELLO signature content invalid"
309
377
  end
310
378
 
379
+
311
380
  # --- WELCOME ---
312
381
  sn_secret = @crypto::PrivateKey.generate
313
382
  sn_public = sn_secret.public_key
@@ -391,20 +460,21 @@ module Protocol
391
460
  end
392
461
 
393
462
  if @authenticator
394
- client_key = client_permanent.to_s
395
- allowed = if @authenticator.respond_to?(:include?)
396
- @authenticator.include?(client_key)
397
- else
398
- @authenticator.call(client_key)
399
- end
400
- raise Error, "client key not authorized" unless allowed
463
+ peer = PeerInfo.new(public_key: client_permanent)
464
+ unless @authenticator.call(peer)
465
+ send_error(io, "client key not authorized")
466
+ raise Error, "client key not authorized"
467
+ end
401
468
  end
402
469
 
470
+
403
471
  # --- READY ---
404
- ready_metadata = Codec::Command.encode_properties(
405
- "Socket-Type" => socket_type,
406
- "Identity" => identity,
407
- )
472
+ ready_props = { "Socket-Type" => socket_type, "Identity" => identity }
473
+ if qos > 0
474
+ ready_props["X-QoS"] = qos.to_s
475
+ ready_props["X-QoS-Hash"] = qos_hash unless qos_hash.empty?
476
+ end
477
+ ready_metadata = Codec::Command.encode_properties(ready_props)
408
478
 
409
479
  r_short_nonce = [1].pack("Q>")
410
480
  r_nonce = NONCE_PREFIX_READY + r_short_nonce
@@ -428,9 +498,12 @@ module Protocol
428
498
  {
429
499
  peer_socket_type: props["Socket-Type"],
430
500
  peer_identity: props["Identity"] || "",
501
+ peer_qos: (props["X-QoS"] || "0").to_i,
502
+ peer_qos_hash: props["X-QoS-Hash"] || "",
431
503
  }
432
504
  end
433
505
 
506
+
434
507
  # ----------------------------------------------------------------
435
508
  # Nonce helpers
436
509
  # ----------------------------------------------------------------
@@ -442,21 +515,41 @@ module Protocol
442
515
  @recv_nonce_buf = String.new(recv_pfx + ("\x00" * 8), encoding: Encoding::BINARY)
443
516
  end
444
517
 
518
+
445
519
  def make_send_nonce
446
520
  @send_nonce += 1
447
521
  raise Error, "nonce counter exhausted" if @send_nonce > MAX_NONCE
448
522
  n = @send_nonce
449
- @send_nonce_buf.setbyte(23, n & 0xFF); n >>= 8
450
- @send_nonce_buf.setbyte(22, n & 0xFF); n >>= 8
451
- @send_nonce_buf.setbyte(21, n & 0xFF); n >>= 8
452
- @send_nonce_buf.setbyte(20, n & 0xFF); n >>= 8
453
- @send_nonce_buf.setbyte(19, n & 0xFF); n >>= 8
454
- @send_nonce_buf.setbyte(18, n & 0xFF); n >>= 8
455
- @send_nonce_buf.setbyte(17, n & 0xFF); n >>= 8
523
+ @send_nonce_buf.setbyte(23, n & 0xFF)
524
+ n >>= 8
525
+ @send_nonce_buf.setbyte(22, n & 0xFF)
526
+ n >>= 8
527
+ @send_nonce_buf.setbyte(21, n & 0xFF)
528
+ n >>= 8
529
+ @send_nonce_buf.setbyte(20, n & 0xFF)
530
+ n >>= 8
531
+ @send_nonce_buf.setbyte(19, n & 0xFF)
532
+ n >>= 8
533
+ @send_nonce_buf.setbyte(18, n & 0xFF)
534
+ n >>= 8
535
+ @send_nonce_buf.setbyte(17, n & 0xFF)
536
+ n >>= 8
456
537
  @send_nonce_buf.setbyte(16, n & 0xFF)
457
538
  @send_nonce_buf
458
539
  end
459
540
 
541
+
542
+ def send_error(io, reason)
543
+ error_body = "".b
544
+ error_body << "\x05ERROR"
545
+ error_body << reason.bytesize.chr << reason.b
546
+ io.write(Codec::Frame.new(error_body, command: true).to_wire)
547
+ io.flush
548
+ rescue IOError
549
+ # connection may already be broken
550
+ end
551
+
552
+
460
553
  def validate_key!(key, name)
461
554
  raise ArgumentError, "#{name} is required" if key.nil?
462
555
  raise ArgumentError, "#{name} must be 32 bytes (got #{key.b.bytesize})" unless key.b.bytesize == 32
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Protocol
4
4
  module ZMTP
5
+ # Security mechanisms for the ZMTP handshake (NULL, PLAIN, CURVE).
5
6
  module Mechanism
6
7
  # NULL security mechanism — no encryption, no authentication.
7
8
  #
@@ -10,6 +11,7 @@ module Protocol
10
11
  class Null
11
12
  MECHANISM_NAME = "NULL"
12
13
 
14
+
13
15
  # Performs the full NULL handshake over +io+.
14
16
  #
15
17
  # 1. Exchange 64-byte greetings
@@ -22,7 +24,7 @@ module Protocol
22
24
  # @param identity [String]
23
25
  # @return [Hash] { peer_socket_type:, peer_identity: }
24
26
  # @raise [Error]
25
- def handshake!(io, as_server:, socket_type:, identity:)
27
+ def handshake!(io, as_server:, socket_type:, identity:, qos: 0, qos_hash: "")
26
28
  io.write(Codec::Greeting.encode(mechanism: MECHANISM_NAME, as_server: as_server))
27
29
  io.flush
28
30
 
@@ -33,7 +35,7 @@ module Protocol
33
35
  raise Error, "unsupported mechanism: #{peer_greeting[:mechanism]}"
34
36
  end
35
37
 
36
- ready_cmd = Codec::Command.ready(socket_type: socket_type, identity: identity)
38
+ ready_cmd = Codec::Command.ready(socket_type: socket_type, identity: identity, qos: qos, qos_hash: qos_hash)
37
39
  io.write(ready_cmd.to_frame.to_wire)
38
40
  io.flush
39
41
 
@@ -50,14 +52,17 @@ module Protocol
50
52
  props = peer_cmd.properties
51
53
  peer_socket_type = props["Socket-Type"]
52
54
  peer_identity = props["Identity"] || ""
55
+ peer_qos = (props["X-QoS"] || "0").to_i
56
+ peer_qos_hash = props["X-QoS-Hash"] || ""
53
57
 
54
58
  unless peer_socket_type
55
59
  raise Error, "peer READY missing Socket-Type"
56
60
  end
57
61
 
58
- { peer_socket_type: peer_socket_type, peer_identity: peer_identity }
62
+ { peer_socket_type: peer_socket_type, peer_identity: peer_identity, peer_qos: peer_qos, peer_qos_hash: peer_qos_hash }
59
63
  end
60
64
 
65
+
61
66
  # @return [Boolean] false — NULL does not encrypt frames
62
67
  def encrypted? = false
63
68
  end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protocol
4
+ module ZMTP
5
+ module Mechanism
6
+ # PLAIN security mechanism — username/password authentication, no encryption.
7
+ #
8
+ # Implements the ZMTP PLAIN handshake (RFC 24):
9
+ #
10
+ # client → server: HELLO (username + password)
11
+ # server → client: WELCOME (empty, credentials accepted)
12
+ # client → server: INITIATE (socket metadata)
13
+ # server → client: READY (socket metadata)
14
+ #
15
+ class Plain
16
+ MECHANISM_NAME = "PLAIN"
17
+
18
+
19
+ # @param username [String] client username (max 255 bytes)
20
+ # @param password [String] client password (max 255 bytes)
21
+ # @param authenticator [#call, nil] server-side credential verifier;
22
+ # called as +authenticator.call(username, password)+ and must return
23
+ # truthy to accept the connection. When +nil+, all credentials pass.
24
+ def initialize(username: "", password: "", authenticator: nil)
25
+ @username = username
26
+ @password = password
27
+ @authenticator = authenticator
28
+ end
29
+
30
+
31
+ # Performs the full PLAIN handshake over +io+.
32
+ #
33
+ # @param io [#read_exactly, #write, #flush] transport IO
34
+ # @param as_server [Boolean]
35
+ # @param socket_type [String]
36
+ # @param identity [String]
37
+ # @return [Hash] { peer_socket_type:, peer_identity: }
38
+ # @raise [Error]
39
+ def handshake!(io, as_server:, socket_type:, identity:, qos: 0, qos_hash: "")
40
+ io.write(Codec::Greeting.encode(mechanism: MECHANISM_NAME, as_server: as_server))
41
+ io.flush
42
+
43
+ greeting_data = io.read_exactly(Codec::Greeting::SIZE)
44
+ peer_greeting = Codec::Greeting.decode(greeting_data)
45
+
46
+ unless peer_greeting[:mechanism] == MECHANISM_NAME
47
+ raise Error, "unsupported mechanism: #{peer_greeting[:mechanism]}"
48
+ end
49
+
50
+ if as_server
51
+ server_handshake!(io, socket_type: socket_type, identity: identity, qos: qos, qos_hash: qos_hash)
52
+ else
53
+ client_handshake!(io, socket_type: socket_type, identity: identity, qos: qos, qos_hash: qos_hash)
54
+ end
55
+ end
56
+
57
+
58
+ # @return [Boolean] false — PLAIN does not encrypt frames
59
+ def encrypted?
60
+ false
61
+ end
62
+
63
+
64
+ private
65
+
66
+
67
+ def client_handshake!(io, socket_type:, identity:, qos: 0, qos_hash: "")
68
+ send_command(io, hello_command)
69
+
70
+ cmd = read_command(io)
71
+ raise Error, "expected WELCOME, got #{cmd.name}" unless cmd.name == "WELCOME"
72
+
73
+ props = { "Socket-Type" => socket_type, "Identity" => identity }
74
+ if qos > 0
75
+ props["X-QoS"] = qos.to_s
76
+ props["X-QoS-Hash"] = qos_hash unless qos_hash.empty?
77
+ end
78
+ initiate = Codec::Command.new("INITIATE", Codec::Command.encode_properties(props))
79
+ send_command(io, initiate)
80
+
81
+ cmd = read_command(io)
82
+ raise Error, "expected READY, got #{cmd.name}" unless cmd.name == "READY"
83
+
84
+ extract_peer_info(cmd)
85
+ end
86
+
87
+
88
+ def server_handshake!(io, socket_type:, identity:, qos: 0, qos_hash: "")
89
+ cmd = read_command(io)
90
+ raise Error, "expected HELLO, got #{cmd.name}" unless cmd.name == "HELLO"
91
+
92
+ username, password = decode_credentials(cmd.data)
93
+
94
+ if @authenticator && !@authenticator.call(username, password)
95
+ raise Error, "authentication failed"
96
+ end
97
+
98
+ send_command(io, Codec::Command.new("WELCOME"))
99
+
100
+ cmd = read_command(io)
101
+ raise Error, "expected INITIATE, got #{cmd.name}" unless cmd.name == "INITIATE"
102
+
103
+ peer_info = extract_peer_info(cmd)
104
+
105
+ send_command(io, Codec::Command.ready(socket_type: socket_type, identity: identity, qos: qos, qos_hash: qos_hash))
106
+
107
+ peer_info
108
+ end
109
+
110
+
111
+ def hello_command
112
+ u = @username.b
113
+ p = @password.b
114
+
115
+ raise Error, "username too long (max 255 bytes)" if u.bytesize > 255
116
+ raise Error, "password too long (max 255 bytes)" if p.bytesize > 255
117
+
118
+ data = u.bytesize.chr.b + u + p.bytesize.chr.b + p
119
+ Codec::Command.new("HELLO", data)
120
+ end
121
+
122
+
123
+ def decode_credentials(data)
124
+ data = data.b
125
+ raise Error, "HELLO body too short" if data.bytesize < 1
126
+
127
+ u_len = data.getbyte(0)
128
+ p_offset = 1 + u_len
129
+
130
+ raise Error, "HELLO username truncated" if data.bytesize < p_offset + 1
131
+
132
+ username = data.byteslice(1, u_len)
133
+ p_len = data.getbyte(p_offset)
134
+
135
+ raise Error, "HELLO password truncated" if data.bytesize < p_offset + 1 + p_len
136
+
137
+ password = data.byteslice(p_offset + 1, p_len)
138
+
139
+ [username, password]
140
+ end
141
+
142
+
143
+ def extract_peer_info(cmd)
144
+ props = cmd.properties
145
+ peer_socket_type = props["Socket-Type"]
146
+
147
+ raise Error, "peer command missing Socket-Type" unless peer_socket_type
148
+
149
+ { peer_socket_type: peer_socket_type, peer_identity: props["Identity"] || "", peer_qos: (props["X-QoS"] || "0").to_i, peer_qos_hash: props["X-QoS-Hash"] || "" }
150
+ end
151
+
152
+
153
+ def send_command(io, cmd)
154
+ io.write(cmd.to_frame.to_wire)
155
+ io.flush
156
+ end
157
+
158
+
159
+ def read_command(io)
160
+ frame = Codec::Frame.read_from(io)
161
+ raise Error, "expected command frame, got data frame" unless frame.command?
162
+
163
+ Codec::Command.from_body(frame.body)
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protocol
4
+ module ZMTP
5
+ # Context passed to an authenticator during authentication.
6
+ # +public_key+ is a +crypto::PublicKey+ instance.
7
+ PeerInfo = Data.define(:public_key)
8
+ end
9
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Protocol
4
4
  module ZMTP
5
- VERSION = "0.1.2"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -15,6 +15,12 @@ module Protocol
15
15
 
16
16
  BASE = 85
17
17
 
18
+
19
+ # Encodes binary data to a Z85-encoded ASCII string.
20
+ #
21
+ # @param data [String] binary data (length must be a multiple of 4)
22
+ # @return [String] Z85-encoded string (5/4 the size of the input)
23
+ # @raise [ArgumentError] if data length is not a multiple of 4
18
24
  def self.encode(data)
19
25
  data = data.b
20
26
  raise ArgumentError, "data length must be a multiple of 4 (got #{data.bytesize})" unless (data.bytesize % 4).zero?
@@ -32,6 +38,12 @@ module Protocol
32
38
  out
33
39
  end
34
40
 
41
+
42
+ # Decodes a Z85-encoded string back to binary data.
43
+ #
44
+ # @param string [String] Z85-encoded string (length must be a multiple of 5)
45
+ # @return [String] decoded binary data (4/5 the size of the input)
46
+ # @raise [ArgumentError] if string length is not a multiple of 5 or contains invalid characters
35
47
  def self.decode(string)
36
48
  raise ArgumentError, "string length must be a multiple of 5 (got #{string.bytesize})" unless (string.bytesize % 5).zero?
37
49
 
data/lib/protocol/zmtp.rb CHANGED
@@ -5,10 +5,14 @@ require_relative "zmtp/error"
5
5
  require_relative "zmtp/valid_peers"
6
6
  require_relative "zmtp/codec"
7
7
  require_relative "zmtp/connection"
8
+ require_relative "zmtp/peer_info"
8
9
  require_relative "zmtp/mechanism/null"
10
+ require_relative "zmtp/mechanism/plain"
9
11
  require_relative "zmtp/z85"
10
12
 
13
+ # Top-level namespace for wire protocol implementations.
11
14
  module Protocol
15
+ # ZMTP 3.1 (ZeroMQ Message Transport Protocol) implementation.
12
16
  module ZMTP
13
17
  # Autoload CURVE mechanism — requires a crypto backend (rbnacl or nuckle).
14
18
  autoload :Curve, File.expand_path("zmtp/mechanism/curve", __dir__)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: protocol-zmtp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger
@@ -29,6 +29,8 @@ files:
29
29
  - lib/protocol/zmtp/error.rb
30
30
  - lib/protocol/zmtp/mechanism/curve.rb
31
31
  - lib/protocol/zmtp/mechanism/null.rb
32
+ - lib/protocol/zmtp/mechanism/plain.rb
33
+ - lib/protocol/zmtp/peer_info.rb
32
34
  - lib/protocol/zmtp/valid_peers.rb
33
35
  - lib/protocol/zmtp/version.rb
34
36
  - lib/protocol/zmtp/z85.rb