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 +4 -4
- data/lib/protocol/zmtp/codec/command.rb +51 -7
- data/lib/protocol/zmtp/codec/frame.rb +5 -0
- data/lib/protocol/zmtp/codec/greeting.rb +2 -0
- data/lib/protocol/zmtp/connection.rb +39 -9
- data/lib/protocol/zmtp/error.rb +2 -1
- data/lib/protocol/zmtp/mechanism/curve.rb +135 -42
- data/lib/protocol/zmtp/mechanism/null.rb +8 -3
- data/lib/protocol/zmtp/mechanism/plain.rb +168 -0
- data/lib/protocol/zmtp/peer_info.rb +9 -0
- data/lib/protocol/zmtp/version.rb +1 -1
- data/lib/protocol/zmtp/z85.rb +12 -0
- data/lib/protocol/zmtp.rb +4 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a8774efa1927a863efbec0173b00b4642fe99f809ee0df07e3ab6a164829909e
|
|
4
|
+
data.tar.gz: a77071e52f4461323f7d839738ad56d0ed83af312ae1071b4f88ffb365ee2514
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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).
|
|
107
|
-
# wire-format bytes.
|
|
125
|
+
# (e.g. CURVE, BLAKE3ZMQ).
|
|
108
126
|
#
|
|
109
127
|
# @return [Boolean]
|
|
110
|
-
def
|
|
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,
|
|
157
|
-
#
|
|
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
|
-
|
|
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
|
data/lib/protocol/zmtp/error.rb
CHANGED
|
@@ -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 [#
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
"
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
"
|
|
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)
|
|
450
|
-
|
|
451
|
-
@send_nonce_buf.setbyte(
|
|
452
|
-
|
|
453
|
-
@send_nonce_buf.setbyte(
|
|
454
|
-
|
|
455
|
-
@send_nonce_buf.setbyte(
|
|
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
|
data/lib/protocol/zmtp/z85.rb
CHANGED
|
@@ -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.
|
|
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
|