protocol-zmtp 0.1.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 +7 -0
- data/LICENSE +13 -0
- data/README.md +61 -0
- data/lib/protocol/zmtp/codec/command.rb +163 -0
- data/lib/protocol/zmtp/codec/frame.rb +81 -0
- data/lib/protocol/zmtp/codec/greeting.rb +76 -0
- data/lib/protocol/zmtp/codec.rb +13 -0
- data/lib/protocol/zmtp/connection.rb +206 -0
- data/lib/protocol/zmtp/error.rb +8 -0
- data/lib/protocol/zmtp/mechanism/curve.rb +467 -0
- data/lib/protocol/zmtp/mechanism/null.rb +66 -0
- data/lib/protocol/zmtp/valid_peers.rb +28 -0
- data/lib/protocol/zmtp/version.rb +7 -0
- data/lib/protocol/zmtp/z85.rb +58 -0
- data/lib/protocol/zmtp.rb +16 -0
- metadata +56 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6eac99ed3115e12e24bfc7545fd235cbeddecf9acbf6e80363247f6d85c1ed37
|
|
4
|
+
data.tar.gz: 58f81f079d7b5785baf7fa8b15f00224ac93c9505ed2f0b00d9f6bcc12147f55
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ff813236409d769b319b712c120995ed1bbf5e0c51dcb772a9a3e86f3130a379a1aac64843425e67a76277f91e4c74b44450b4fbe60e5a8f1173bdefa051d50c
|
|
7
|
+
data.tar.gz: 208791933fd0006915a7155ba0a18c32ec81f84854df0d77f39314e598fcc4fad3b1eb69cc341365685a3eae55928c662d767bce4331d5958c2e8d8039e84fc5
|
data/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright (c) 2026, Patrik Wenger
|
|
2
|
+
|
|
3
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
4
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
5
|
+
copyright notice and this permission notice appear in all copies.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
8
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
9
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
10
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
11
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
12
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
13
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Protocol::ZMTP
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/protocol-zmtp)
|
|
4
|
+
[](https://github.com/paddor/protocol-zmtp/actions/workflows/ci.yml)
|
|
5
|
+
|
|
6
|
+
ZMTP 3.1 wire protocol — codec, connection, NULL and CURVE mechanisms.
|
|
7
|
+
No runtime dependencies.
|
|
8
|
+
|
|
9
|
+
## What's in the box
|
|
10
|
+
|
|
11
|
+
- **Codec::Frame** — ZMTP frame encode/decode (flags, size, body)
|
|
12
|
+
- **Codec::Greeting** — 64-byte greeting exchange
|
|
13
|
+
- **Codec::Command** — READY, PING/PONG, SUBSCRIBE, etc.
|
|
14
|
+
- **Connection** — per-connection frame I/O, handshake, PING/PONG
|
|
15
|
+
- **Mechanism::Null** — NULL security (no encryption)
|
|
16
|
+
- **Mechanism::Curve** — CurveZMQ (RFC 26) with pluggable crypto backend
|
|
17
|
+
- **Z85** — ZeroMQ RFC 32 encoding
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require "protocol/zmtp"
|
|
23
|
+
|
|
24
|
+
# NULL mechanism — no encryption
|
|
25
|
+
conn = Protocol::ZMTP::Connection.new(
|
|
26
|
+
io,
|
|
27
|
+
socket_type: "REQ",
|
|
28
|
+
mechanism: Protocol::ZMTP::Mechanism::Null.new,
|
|
29
|
+
)
|
|
30
|
+
conn.handshake!
|
|
31
|
+
conn.send_message(["hello"])
|
|
32
|
+
msg = conn.receive_message
|
|
33
|
+
|
|
34
|
+
# CURVE mechanism — pass any NaCl-compatible backend
|
|
35
|
+
require "protocol/zmtp/mechanism/curve"
|
|
36
|
+
require "nuckle" # or: require "rbnacl"
|
|
37
|
+
|
|
38
|
+
server_mech = Protocol::ZMTP::Mechanism::Curve.server(
|
|
39
|
+
public_key, secret_key, crypto: Nuckle,
|
|
40
|
+
)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## CURVE crypto backend
|
|
44
|
+
|
|
45
|
+
`Mechanism::Curve` accepts a `crypto:` parameter — any module that
|
|
46
|
+
provides the NaCl API:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# RbNaCl (libsodium, fast, constant-time)
|
|
50
|
+
Protocol::ZMTP::Mechanism::Curve.server(pub, sec, crypto: RbNaCl)
|
|
51
|
+
|
|
52
|
+
# Nuckle (pure Ruby, no C dependencies, don't use in production)
|
|
53
|
+
Protocol::ZMTP::Mechanism::Curve.server(pub, sec, crypto: Nuckle)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The backend must provide: `PrivateKey`, `PublicKey`, `Box`, `SecretBox`,
|
|
57
|
+
`Random`, `Util`, and `CryptoError`.
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
|
|
61
|
+
ISC
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protocol
|
|
4
|
+
module ZMTP
|
|
5
|
+
module Codec
|
|
6
|
+
# ZMTP command encode/decode.
|
|
7
|
+
#
|
|
8
|
+
# Command frame body format:
|
|
9
|
+
# 1 byte: command name length
|
|
10
|
+
# N bytes: command name
|
|
11
|
+
# remaining: command data
|
|
12
|
+
#
|
|
13
|
+
# READY command data = property list:
|
|
14
|
+
# 1 byte: property name length
|
|
15
|
+
# N bytes: property name
|
|
16
|
+
# 4 bytes: property value length (big-endian)
|
|
17
|
+
# N bytes: property value
|
|
18
|
+
#
|
|
19
|
+
class Command
|
|
20
|
+
# @return [String] command name (e.g. "READY", "SUBSCRIBE")
|
|
21
|
+
attr_reader :name
|
|
22
|
+
|
|
23
|
+
# @return [String] command data (binary)
|
|
24
|
+
attr_reader :data
|
|
25
|
+
|
|
26
|
+
# @param name [String] command name
|
|
27
|
+
# @param data [String] command data
|
|
28
|
+
def initialize(name, data = "".b)
|
|
29
|
+
@name = name
|
|
30
|
+
@data = data.b
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Encodes as a command frame body.
|
|
34
|
+
#
|
|
35
|
+
# @return [String] binary body (name-length + name + data)
|
|
36
|
+
def to_body
|
|
37
|
+
name_bytes = @name.b
|
|
38
|
+
name_bytes.bytesize.chr.b + name_bytes + @data
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Encodes as a complete command Frame.
|
|
42
|
+
#
|
|
43
|
+
# @return [Frame]
|
|
44
|
+
def to_frame
|
|
45
|
+
Frame.new(to_body, command: true)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Decodes a command from a frame body.
|
|
49
|
+
#
|
|
50
|
+
# @param body [String] binary frame body
|
|
51
|
+
# @return [Command]
|
|
52
|
+
# @raise [Error] on malformed command
|
|
53
|
+
def self.from_body(body)
|
|
54
|
+
body = body.b
|
|
55
|
+
raise Error, "command body too short" if body.bytesize < 1
|
|
56
|
+
|
|
57
|
+
name_len = body.getbyte(0)
|
|
58
|
+
|
|
59
|
+
raise Error, "command name truncated" if body.bytesize < 1 + name_len
|
|
60
|
+
|
|
61
|
+
name = body.byteslice(1, name_len)
|
|
62
|
+
data = body.byteslice(1 + name_len..)
|
|
63
|
+
new(name, data)
|
|
64
|
+
end
|
|
65
|
+
|
|
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)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Builds a SUBSCRIBE command.
|
|
76
|
+
def self.subscribe(prefix)
|
|
77
|
+
new("SUBSCRIBE", prefix.b)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Builds a CANCEL command (unsubscribe).
|
|
81
|
+
def self.cancel(prefix)
|
|
82
|
+
new("CANCEL", prefix.b)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Builds a JOIN command (RADIO/DISH group subscription).
|
|
86
|
+
def self.join(group)
|
|
87
|
+
new("JOIN", group.b)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Builds a LEAVE command (RADIO/DISH group unsubscription).
|
|
91
|
+
def self.leave(group)
|
|
92
|
+
new("LEAVE", group.b)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Builds a PING command.
|
|
96
|
+
#
|
|
97
|
+
# @param ttl [Numeric] time-to-live in seconds (sent as deciseconds)
|
|
98
|
+
# @param context [String] optional context bytes (up to 16 bytes)
|
|
99
|
+
def self.ping(ttl: 0, context: "".b)
|
|
100
|
+
ttl_ds = (ttl * 10).to_i
|
|
101
|
+
new("PING", [ttl_ds].pack("n") + context.b)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Builds a PONG command.
|
|
105
|
+
def self.pong(context: "".b)
|
|
106
|
+
new("PONG", context.b)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Extracts TTL (in seconds) and context from a PING command's data.
|
|
110
|
+
#
|
|
111
|
+
# @return [Array(Numeric, String)] [ttl_seconds, context_bytes]
|
|
112
|
+
def ping_ttl_and_context
|
|
113
|
+
ttl_ds = @data.unpack1("n")
|
|
114
|
+
context = @data.bytesize > 2 ? @data.byteslice(2..) : "".b
|
|
115
|
+
[ttl_ds / 10.0, context]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Parses READY command data as a property list.
|
|
119
|
+
def properties
|
|
120
|
+
self.class.decode_properties(@data)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Encodes a hash of properties into ZMTP property list format.
|
|
124
|
+
def self.encode_properties(props)
|
|
125
|
+
parts = props.map do |name, value|
|
|
126
|
+
name_bytes = name.b
|
|
127
|
+
value_bytes = value.b
|
|
128
|
+
name_bytes.bytesize.chr.b + name_bytes + [value_bytes.bytesize].pack("N") + value_bytes
|
|
129
|
+
end
|
|
130
|
+
parts.join
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Decodes a ZMTP property list from binary data.
|
|
134
|
+
def self.decode_properties(data)
|
|
135
|
+
result = {}
|
|
136
|
+
offset = 0
|
|
137
|
+
|
|
138
|
+
while offset < data.bytesize
|
|
139
|
+
raise Error, "property name truncated" if offset + 1 > data.bytesize
|
|
140
|
+
name_len = data.getbyte(offset)
|
|
141
|
+
offset += 1
|
|
142
|
+
|
|
143
|
+
raise Error, "property name truncated" if offset + name_len > data.bytesize
|
|
144
|
+
name = data.byteslice(offset, name_len)
|
|
145
|
+
offset += name_len
|
|
146
|
+
|
|
147
|
+
raise Error, "property value length truncated" if offset + 4 > data.bytesize
|
|
148
|
+
value_len = data.byteslice(offset, 4).unpack1("N")
|
|
149
|
+
offset += 4
|
|
150
|
+
|
|
151
|
+
raise Error, "property value truncated" if offset + value_len > data.bytesize
|
|
152
|
+
value = data.byteslice(offset, value_len)
|
|
153
|
+
offset += value_len
|
|
154
|
+
|
|
155
|
+
result[name] = value
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
result
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protocol
|
|
4
|
+
module ZMTP
|
|
5
|
+
module Codec
|
|
6
|
+
# ZMTP frame encode/decode.
|
|
7
|
+
#
|
|
8
|
+
# Wire format:
|
|
9
|
+
# Byte 0: flags (bit 0=MORE, bit 1=LONG, bit 2=COMMAND)
|
|
10
|
+
# Next 1-8: size (1-byte if short, 8-byte big-endian if LONG)
|
|
11
|
+
# Next N: body
|
|
12
|
+
#
|
|
13
|
+
class Frame
|
|
14
|
+
FLAGS_MORE = 0x01
|
|
15
|
+
FLAGS_LONG = 0x02
|
|
16
|
+
FLAGS_COMMAND = 0x04
|
|
17
|
+
|
|
18
|
+
# Short frame: 1-byte size, max body 255 bytes.
|
|
19
|
+
SHORT_MAX = 255
|
|
20
|
+
|
|
21
|
+
# @return [String] frame body (binary)
|
|
22
|
+
attr_reader :body
|
|
23
|
+
|
|
24
|
+
# @param body [String] frame body
|
|
25
|
+
# @param more [Boolean] more frames follow
|
|
26
|
+
# @param command [Boolean] this is a command frame
|
|
27
|
+
def initialize(body, more: false, command: false)
|
|
28
|
+
@body = body.b
|
|
29
|
+
@more = more
|
|
30
|
+
@command = command
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Boolean] true if more frames follow in this message
|
|
34
|
+
def more? = @more
|
|
35
|
+
|
|
36
|
+
# @return [Boolean] true if this is a command frame
|
|
37
|
+
def command? = @command
|
|
38
|
+
|
|
39
|
+
# Encodes to wire bytes.
|
|
40
|
+
#
|
|
41
|
+
# @return [String] binary wire representation (flags + size + body)
|
|
42
|
+
def to_wire
|
|
43
|
+
size = @body.bytesize
|
|
44
|
+
flags = 0
|
|
45
|
+
flags |= FLAGS_MORE if @more
|
|
46
|
+
flags |= FLAGS_COMMAND if @command
|
|
47
|
+
|
|
48
|
+
if size > SHORT_MAX
|
|
49
|
+
(flags | FLAGS_LONG).chr.b + [size].pack("Q>") + @body
|
|
50
|
+
else
|
|
51
|
+
flags.chr.b + size.chr.b + @body
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Reads one frame from an IO-like object.
|
|
56
|
+
#
|
|
57
|
+
# @param io [#read_exactly] must support read_exactly(n)
|
|
58
|
+
# @return [Frame]
|
|
59
|
+
# @raise [Error] on invalid frame
|
|
60
|
+
# @raise [EOFError] if the connection is closed
|
|
61
|
+
def self.read_from(io)
|
|
62
|
+
flags = io.read_exactly(1).getbyte(0)
|
|
63
|
+
|
|
64
|
+
more = (flags & FLAGS_MORE) != 0
|
|
65
|
+
long = (flags & FLAGS_LONG) != 0
|
|
66
|
+
command = (flags & FLAGS_COMMAND) != 0
|
|
67
|
+
|
|
68
|
+
size = if long
|
|
69
|
+
io.read_exactly(8).unpack1("Q>")
|
|
70
|
+
else
|
|
71
|
+
io.read_exactly(1).getbyte(0)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
body = size > 0 ? io.read_exactly(size) : "".b
|
|
75
|
+
|
|
76
|
+
new(body, more: more, command: command)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protocol
|
|
4
|
+
module ZMTP
|
|
5
|
+
module Codec
|
|
6
|
+
# ZMTP 3.1 greeting encode/decode.
|
|
7
|
+
#
|
|
8
|
+
# The greeting is always exactly 64 bytes:
|
|
9
|
+
# Offset Bytes Field
|
|
10
|
+
# 0 1 0xFF (signature start)
|
|
11
|
+
# 1-8 8 0x00 padding
|
|
12
|
+
# 9 1 0x7F (signature end)
|
|
13
|
+
# 10 1 major version
|
|
14
|
+
# 11 1 minor version
|
|
15
|
+
# 12-31 20 mechanism (null-padded ASCII)
|
|
16
|
+
# 32 1 as-server flag (0x00 or 0x01)
|
|
17
|
+
# 33-63 31 filler (0x00)
|
|
18
|
+
#
|
|
19
|
+
module Greeting
|
|
20
|
+
SIZE = 64
|
|
21
|
+
SIGNATURE_START = 0xFF
|
|
22
|
+
SIGNATURE_END = 0x7F
|
|
23
|
+
VERSION_MAJOR = 3
|
|
24
|
+
VERSION_MINOR = 1
|
|
25
|
+
MECHANISM_OFFSET = 12
|
|
26
|
+
MECHANISM_LENGTH = 20
|
|
27
|
+
AS_SERVER_OFFSET = 32
|
|
28
|
+
|
|
29
|
+
# Encodes a ZMTP 3.1 greeting.
|
|
30
|
+
#
|
|
31
|
+
# @param mechanism [String] security mechanism name (e.g. "NULL")
|
|
32
|
+
# @param as_server [Boolean] whether this peer is the server
|
|
33
|
+
# @return [String] 64-byte binary greeting
|
|
34
|
+
def self.encode(mechanism: "NULL", as_server: false)
|
|
35
|
+
buf = "\xFF".b + ("\x00" * 8) + "\x7F".b
|
|
36
|
+
buf << [VERSION_MAJOR, VERSION_MINOR].pack("CC")
|
|
37
|
+
buf << mechanism.b.ljust(MECHANISM_LENGTH, "\x00")
|
|
38
|
+
buf << (as_server ? "\x01" : "\x00")
|
|
39
|
+
buf << ("\x00" * 31)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Decodes a ZMTP greeting.
|
|
43
|
+
#
|
|
44
|
+
# @param data [String] 64-byte binary greeting
|
|
45
|
+
# @return [Hash] { major:, minor:, mechanism:, as_server: }
|
|
46
|
+
# @raise [Error] on invalid greeting
|
|
47
|
+
def self.decode(data)
|
|
48
|
+
raise Error, "greeting too short (#{data.bytesize} bytes)" if data.bytesize < SIZE
|
|
49
|
+
|
|
50
|
+
data = data.b
|
|
51
|
+
|
|
52
|
+
unless data.getbyte(0) == SIGNATURE_START && data.getbyte(9) == SIGNATURE_END
|
|
53
|
+
raise Error, "invalid greeting signature"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
major = data.getbyte(10)
|
|
57
|
+
minor = data.getbyte(11)
|
|
58
|
+
|
|
59
|
+
unless major >= 3
|
|
60
|
+
raise Error, "unsupported ZMTP version #{major}.#{minor} (need >= 3.0)"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
mechanism = data.byteslice(MECHANISM_OFFSET, MECHANISM_LENGTH).delete("\x00")
|
|
64
|
+
as_server = data.getbyte(AS_SERVER_OFFSET) == 1
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
major: major,
|
|
68
|
+
minor: minor,
|
|
69
|
+
mechanism: mechanism,
|
|
70
|
+
as_server: as_server,
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protocol
|
|
4
|
+
module ZMTP
|
|
5
|
+
# Manages one ZMTP peer connection over any transport IO.
|
|
6
|
+
#
|
|
7
|
+
# Delegates the security handshake to a Mechanism object (Null, Curve, etc.),
|
|
8
|
+
# then provides message send/receive and command send/receive on top of the
|
|
9
|
+
# framing codec.
|
|
10
|
+
#
|
|
11
|
+
# Heartbeat timing is tracked but not driven — the caller (e.g. an engine)
|
|
12
|
+
# is responsible for periodically sending PINGs and checking expiry.
|
|
13
|
+
#
|
|
14
|
+
class Connection
|
|
15
|
+
# @return [String] peer's socket type (from READY handshake)
|
|
16
|
+
attr_reader :peer_socket_type
|
|
17
|
+
|
|
18
|
+
# @return [String] peer's identity (from READY handshake)
|
|
19
|
+
attr_reader :peer_identity
|
|
20
|
+
|
|
21
|
+
# @return [Object] transport IO (#read_exactly, #write, #flush, #close)
|
|
22
|
+
attr_reader :io
|
|
23
|
+
|
|
24
|
+
# @return [Float, nil] monotonic timestamp of last received frame
|
|
25
|
+
attr_reader :last_received_at
|
|
26
|
+
|
|
27
|
+
# @param io [#read_exactly, #write, #flush, #close] transport IO
|
|
28
|
+
# @param socket_type [String] our socket type name (e.g. "REQ")
|
|
29
|
+
# @param identity [String] our identity
|
|
30
|
+
# @param as_server [Boolean] whether we are the server side
|
|
31
|
+
# @param mechanism [Mechanism::Null, Mechanism::Curve] security mechanism
|
|
32
|
+
# @param max_message_size [Integer, nil] max frame size in bytes, nil = unlimited
|
|
33
|
+
def initialize(io, socket_type:, identity: "", as_server: false,
|
|
34
|
+
mechanism: nil, max_message_size: nil)
|
|
35
|
+
@io = io
|
|
36
|
+
@socket_type = socket_type
|
|
37
|
+
@identity = identity
|
|
38
|
+
@as_server = as_server
|
|
39
|
+
@mechanism = mechanism || Mechanism::Null.new
|
|
40
|
+
@peer_socket_type = nil
|
|
41
|
+
@peer_identity = nil
|
|
42
|
+
@mutex = Mutex.new
|
|
43
|
+
@max_message_size = max_message_size
|
|
44
|
+
@last_received_at = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Performs the full ZMTP handshake via the configured mechanism.
|
|
48
|
+
#
|
|
49
|
+
# @return [void]
|
|
50
|
+
# @raise [Error] on handshake failure
|
|
51
|
+
def handshake!
|
|
52
|
+
result = @mechanism.handshake!(
|
|
53
|
+
@io,
|
|
54
|
+
as_server: @as_server,
|
|
55
|
+
socket_type: @socket_type,
|
|
56
|
+
identity: @identity,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@peer_socket_type = result[:peer_socket_type]
|
|
60
|
+
@peer_identity = result[:peer_identity]
|
|
61
|
+
|
|
62
|
+
unless @peer_socket_type
|
|
63
|
+
raise Error, "peer READY missing Socket-Type"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
unless VALID_PEERS[@socket_type.to_sym]&.include?(@peer_socket_type.to_sym)
|
|
67
|
+
raise Error,
|
|
68
|
+
"incompatible socket types: #{@socket_type} cannot connect to #{@peer_socket_type}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Sends a multi-frame message (write + flush).
|
|
73
|
+
#
|
|
74
|
+
# @param parts [Array<String>] message frames
|
|
75
|
+
# @return [void]
|
|
76
|
+
def send_message(parts)
|
|
77
|
+
@mutex.synchronize do
|
|
78
|
+
write_frames(parts)
|
|
79
|
+
@io.flush
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Writes a multi-frame message to the buffer without flushing.
|
|
84
|
+
# Call {#flush} after batching writes.
|
|
85
|
+
#
|
|
86
|
+
# @param parts [Array<String>] message frames
|
|
87
|
+
# @return [void]
|
|
88
|
+
def write_message(parts)
|
|
89
|
+
@mutex.synchronize do
|
|
90
|
+
write_frames(parts)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Flushes the write buffer to the underlying IO.
|
|
95
|
+
def flush
|
|
96
|
+
@mutex.synchronize do
|
|
97
|
+
@io.flush
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Receives a multi-frame message.
|
|
102
|
+
# PING/PONG commands are handled automatically by #read_frame.
|
|
103
|
+
#
|
|
104
|
+
# @return [Array<String>] message frames
|
|
105
|
+
# @raise [EOFError] if connection is closed
|
|
106
|
+
def receive_message
|
|
107
|
+
frames = []
|
|
108
|
+
loop do
|
|
109
|
+
frame = read_frame
|
|
110
|
+
if frame.command?
|
|
111
|
+
yield frame if block_given?
|
|
112
|
+
next
|
|
113
|
+
end
|
|
114
|
+
frames << frame.body.freeze
|
|
115
|
+
break unless frame.more?
|
|
116
|
+
end
|
|
117
|
+
frames.freeze
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Sends a command.
|
|
121
|
+
#
|
|
122
|
+
# @param command [Codec::Command]
|
|
123
|
+
# @return [void]
|
|
124
|
+
def send_command(command)
|
|
125
|
+
@mutex.synchronize do
|
|
126
|
+
if @mechanism.encrypted?
|
|
127
|
+
@io.write(@mechanism.encrypt(command.to_body, command: true))
|
|
128
|
+
else
|
|
129
|
+
@io.write(command.to_frame.to_wire)
|
|
130
|
+
end
|
|
131
|
+
@io.flush
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Reads one frame from the wire. Handles PING/PONG automatically.
|
|
136
|
+
# When using an encrypted mechanism, MESSAGE commands are decrypted
|
|
137
|
+
# back to ZMTP frames transparently.
|
|
138
|
+
#
|
|
139
|
+
# @return [Codec::Frame]
|
|
140
|
+
# @raise [EOFError] if connection is closed
|
|
141
|
+
def read_frame
|
|
142
|
+
loop do
|
|
143
|
+
frame = Codec::Frame.read_from(@io)
|
|
144
|
+
touch_heartbeat
|
|
145
|
+
|
|
146
|
+
if @mechanism.encrypted? && frame.body.bytesize > 8 && frame.body.byteslice(0, 8) == "\x07MESSAGE".b
|
|
147
|
+
frame = @mechanism.decrypt(frame)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
if @max_message_size && !frame.command? && frame.body.bytesize > @max_message_size
|
|
151
|
+
close
|
|
152
|
+
raise Error, "frame size #{frame.body.bytesize} exceeds max_message_size #{@max_message_size}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
if frame.command?
|
|
156
|
+
cmd = Codec::Command.from_body(frame.body)
|
|
157
|
+
case cmd.name
|
|
158
|
+
when "PING"
|
|
159
|
+
_, context = cmd.ping_ttl_and_context
|
|
160
|
+
send_command(Codec::Command.pong(context: context))
|
|
161
|
+
next
|
|
162
|
+
when "PONG"
|
|
163
|
+
next
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
return frame
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Records that a frame was received (for heartbeat expiry tracking).
|
|
171
|
+
def touch_heartbeat
|
|
172
|
+
@last_received_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Returns true if no frame has been received within +timeout+ seconds.
|
|
176
|
+
#
|
|
177
|
+
# @param timeout [Numeric] seconds
|
|
178
|
+
# @return [Boolean]
|
|
179
|
+
def heartbeat_expired?(timeout)
|
|
180
|
+
return false unless @last_received_at
|
|
181
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_received_at) > timeout
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Closes the connection.
|
|
185
|
+
def close
|
|
186
|
+
@io.close
|
|
187
|
+
rescue IOError
|
|
188
|
+
# already closed
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private
|
|
192
|
+
|
|
193
|
+
# Writes message parts as ZMTP frames, encrypting if needed.
|
|
194
|
+
def write_frames(parts)
|
|
195
|
+
parts.each_with_index do |part, i|
|
|
196
|
+
more = i < parts.size - 1
|
|
197
|
+
if @mechanism.encrypted?
|
|
198
|
+
@io.write(@mechanism.encrypt(part.b, more: more))
|
|
199
|
+
else
|
|
200
|
+
@io.write(Codec::Frame.new(part, more: more).to_wire)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protocol
|
|
4
|
+
module ZMTP
|
|
5
|
+
module Mechanism
|
|
6
|
+
# CurveZMQ security mechanism (RFC 26).
|
|
7
|
+
#
|
|
8
|
+
# Provides Curve25519-XSalsa20-Poly1305 encryption and authentication
|
|
9
|
+
# for ZMTP 3.1 connections.
|
|
10
|
+
#
|
|
11
|
+
# Crypto-backend-agnostic: pass any module that provides the NaCl API
|
|
12
|
+
# (RbNaCl or Nuckle) via the +crypto:+ parameter.
|
|
13
|
+
#
|
|
14
|
+
# The crypto backend must provide:
|
|
15
|
+
# backend::PrivateKey.new(bytes) / .generate
|
|
16
|
+
# backend::PublicKey.new(bytes)
|
|
17
|
+
# backend::Box.new(peer_pub, my_secret) → #encrypt(nonce, pt) / #decrypt(nonce, ct)
|
|
18
|
+
# backend::SecretBox.new(key) → #encrypt(nonce, pt) / #decrypt(nonce, ct)
|
|
19
|
+
# backend::Random.random_bytes(n)
|
|
20
|
+
# backend::Util.verify32(a, b) / .verify64(a, b)
|
|
21
|
+
# backend::CryptoError (exception class)
|
|
22
|
+
#
|
|
23
|
+
class Curve
|
|
24
|
+
MECHANISM_NAME = "CURVE"
|
|
25
|
+
|
|
26
|
+
# Nonce prefixes.
|
|
27
|
+
NONCE_PREFIX_HELLO = "CurveZMQHELLO---"
|
|
28
|
+
NONCE_PREFIX_WELCOME = "WELCOME-"
|
|
29
|
+
NONCE_PREFIX_INITIATE = "CurveZMQINITIATE"
|
|
30
|
+
NONCE_PREFIX_READY = "CurveZMQREADY---"
|
|
31
|
+
NONCE_PREFIX_MESSAGE_C = "CurveZMQMESSAGEC"
|
|
32
|
+
NONCE_PREFIX_MESSAGE_S = "CurveZMQMESSAGES"
|
|
33
|
+
NONCE_PREFIX_VOUCH = "VOUCH---"
|
|
34
|
+
NONCE_PREFIX_COOKIE = "COOKIE--"
|
|
35
|
+
|
|
36
|
+
BOX_OVERHEAD = 16
|
|
37
|
+
MAX_NONCE = (2**64) - 1
|
|
38
|
+
|
|
39
|
+
# Creates a CURVE server mechanism.
|
|
40
|
+
#
|
|
41
|
+
# @param public_key [String] 32 bytes
|
|
42
|
+
# @param secret_key [String] 32 bytes
|
|
43
|
+
# @param crypto [Module] NaCl-compatible backend (RbNaCl or Nuckle)
|
|
44
|
+
# @param authenticator [#include?, #call, nil] client key authenticator
|
|
45
|
+
# @return [Curve]
|
|
46
|
+
def self.server(public_key, secret_key, crypto:, authenticator: nil)
|
|
47
|
+
new(public_key:, secret_key:, crypto:, as_server: true, authenticator:)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Creates a CURVE client mechanism.
|
|
51
|
+
#
|
|
52
|
+
# @param public_key [String] 32 bytes
|
|
53
|
+
# @param secret_key [String] 32 bytes
|
|
54
|
+
# @param server_key [String] 32 bytes
|
|
55
|
+
# @param crypto [Module] NaCl-compatible backend (RbNaCl or Nuckle)
|
|
56
|
+
# @return [Curve]
|
|
57
|
+
def self.client(public_key, secret_key, server_key:, crypto:)
|
|
58
|
+
new(public_key:, secret_key:, server_key:, crypto:, as_server: false)
|
|
59
|
+
end
|
|
60
|
+
|
|
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
|
+
|
|
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
|
|
70
|
+
|
|
71
|
+
if as_server
|
|
72
|
+
@cookie_key = crypto::Random.random_bytes(32)
|
|
73
|
+
else
|
|
74
|
+
validate_key!(server_key, "server_key")
|
|
75
|
+
@server_public = crypto::PublicKey.new(server_key.b)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@session_box = nil
|
|
79
|
+
@send_nonce = 0
|
|
80
|
+
@recv_nonce = -1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def initialize_dup(source)
|
|
84
|
+
super
|
|
85
|
+
@session_box = nil
|
|
86
|
+
@send_nonce = 0
|
|
87
|
+
@recv_nonce = -1
|
|
88
|
+
@send_nonce_buf = nil
|
|
89
|
+
@recv_nonce_buf = nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def encrypted? = true
|
|
93
|
+
|
|
94
|
+
def handshake!(io, as_server:, socket_type:, identity:)
|
|
95
|
+
if @as_server
|
|
96
|
+
server_handshake!(io, socket_type:, identity:)
|
|
97
|
+
else
|
|
98
|
+
client_handshake!(io, socket_type:, identity:)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def encrypt(body, more: false, command: false)
|
|
103
|
+
flags = 0
|
|
104
|
+
flags |= 0x01 if more
|
|
105
|
+
flags |= 0x04 if command
|
|
106
|
+
|
|
107
|
+
plaintext = String.new(encoding: Encoding::BINARY, capacity: 1 + body.bytesize)
|
|
108
|
+
plaintext << flags << body
|
|
109
|
+
|
|
110
|
+
nonce = make_send_nonce
|
|
111
|
+
ciphertext = @session_box.encrypt(nonce, plaintext)
|
|
112
|
+
short_nonce = nonce.byteslice(16, 8)
|
|
113
|
+
|
|
114
|
+
msg_body_size = 16 + ciphertext.bytesize
|
|
115
|
+
if msg_body_size > 255
|
|
116
|
+
wire = String.new(encoding: Encoding::BINARY, capacity: 9 + msg_body_size)
|
|
117
|
+
wire << "\x02" << [msg_body_size].pack("Q>")
|
|
118
|
+
else
|
|
119
|
+
wire = String.new(encoding: Encoding::BINARY, capacity: 2 + msg_body_size)
|
|
120
|
+
wire << "\x00" << msg_body_size
|
|
121
|
+
end
|
|
122
|
+
wire << "\x07MESSAGE" << short_nonce << ciphertext
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
MESSAGE_PREFIX = "\x07MESSAGE".b.freeze
|
|
126
|
+
MESSAGE_PREFIX_SIZE = MESSAGE_PREFIX.bytesize
|
|
127
|
+
|
|
128
|
+
def decrypt(frame)
|
|
129
|
+
body = frame.body
|
|
130
|
+
unless body.start_with?(MESSAGE_PREFIX)
|
|
131
|
+
raise Error, "expected MESSAGE command"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
data = body.byteslice(MESSAGE_PREFIX_SIZE..)
|
|
135
|
+
raise Error, "MESSAGE too short" if data.bytesize < 8 + BOX_OVERHEAD
|
|
136
|
+
|
|
137
|
+
short_nonce = data.byteslice(0, 8)
|
|
138
|
+
ciphertext = data.byteslice(8..)
|
|
139
|
+
|
|
140
|
+
nonce_value = short_nonce.unpack1("Q>")
|
|
141
|
+
unless nonce_value > @recv_nonce
|
|
142
|
+
raise Error, "MESSAGE nonce not strictly incrementing"
|
|
143
|
+
end
|
|
144
|
+
@recv_nonce = nonce_value
|
|
145
|
+
|
|
146
|
+
@recv_nonce_buf[16, 8] = short_nonce
|
|
147
|
+
begin
|
|
148
|
+
plaintext = @session_box.decrypt(@recv_nonce_buf, ciphertext)
|
|
149
|
+
rescue @crypto::CryptoError
|
|
150
|
+
raise Error, "MESSAGE decryption failed"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
flags = plaintext.getbyte(0)
|
|
154
|
+
body = plaintext.byteslice(1..) || "".b
|
|
155
|
+
Codec::Frame.new(body, more: (flags & 0x01) != 0, command: (flags & 0x04) != 0)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
# ----------------------------------------------------------------
|
|
161
|
+
# Client-side handshake
|
|
162
|
+
# ----------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def client_handshake!(io, socket_type:, identity:)
|
|
165
|
+
cn_secret = @crypto::PrivateKey.generate
|
|
166
|
+
cn_public = cn_secret.public_key
|
|
167
|
+
|
|
168
|
+
io.write(Codec::Greeting.encode(mechanism: MECHANISM_NAME, as_server: false))
|
|
169
|
+
io.flush
|
|
170
|
+
peer_greeting = Codec::Greeting.decode(io.read_exactly(Codec::Greeting::SIZE))
|
|
171
|
+
unless peer_greeting[:mechanism] == MECHANISM_NAME
|
|
172
|
+
raise Error, "expected CURVE mechanism, got #{peer_greeting[:mechanism]}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# --- HELLO ---
|
|
176
|
+
short_nonce = [1].pack("Q>")
|
|
177
|
+
nonce = NONCE_PREFIX_HELLO + short_nonce
|
|
178
|
+
hello_box = @crypto::Box.new(@server_public, cn_secret)
|
|
179
|
+
signature = hello_box.encrypt(nonce, "\x00" * 64)
|
|
180
|
+
|
|
181
|
+
hello = "".b
|
|
182
|
+
hello << "\x05HELLO"
|
|
183
|
+
hello << "\x01\x00"
|
|
184
|
+
hello << ("\x00" * 72)
|
|
185
|
+
hello << cn_public.to_s
|
|
186
|
+
hello << short_nonce
|
|
187
|
+
hello << signature
|
|
188
|
+
|
|
189
|
+
io.write(Codec::Frame.new(hello, command: true).to_wire)
|
|
190
|
+
io.flush
|
|
191
|
+
|
|
192
|
+
# --- Read WELCOME ---
|
|
193
|
+
welcome_frame = Codec::Frame.read_from(io)
|
|
194
|
+
raise Error, "expected command frame" unless welcome_frame.command?
|
|
195
|
+
welcome_cmd = Codec::Command.from_body(welcome_frame.body)
|
|
196
|
+
raise Error, "expected WELCOME, got #{welcome_cmd.name}" unless welcome_cmd.name == "WELCOME"
|
|
197
|
+
|
|
198
|
+
wdata = welcome_cmd.data
|
|
199
|
+
raise Error, "WELCOME wrong size" unless wdata.bytesize == 16 + 144
|
|
200
|
+
|
|
201
|
+
w_short_nonce = wdata.byteslice(0, 16)
|
|
202
|
+
w_box_data = wdata.byteslice(16, 144)
|
|
203
|
+
w_nonce = NONCE_PREFIX_WELCOME + w_short_nonce
|
|
204
|
+
|
|
205
|
+
begin
|
|
206
|
+
w_plaintext = @crypto::Box.new(@server_public, cn_secret).decrypt(w_nonce, w_box_data)
|
|
207
|
+
rescue @crypto::CryptoError
|
|
208
|
+
raise Error, "WELCOME decryption failed"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
sn_public = @crypto::PublicKey.new(w_plaintext.byteslice(0, 32))
|
|
212
|
+
cookie = w_plaintext.byteslice(32, 96)
|
|
213
|
+
|
|
214
|
+
session = @crypto::Box.new(sn_public, cn_secret)
|
|
215
|
+
|
|
216
|
+
# --- INITIATE ---
|
|
217
|
+
vouch_nonce = NONCE_PREFIX_VOUCH + @crypto::Random.random_bytes(16)
|
|
218
|
+
vouch_plaintext = cn_public.to_s + @server_public.to_s
|
|
219
|
+
vouch = @crypto::Box.new(sn_public, @permanent_secret).encrypt(vouch_nonce, vouch_plaintext)
|
|
220
|
+
|
|
221
|
+
metadata = Codec::Command.encode_properties(
|
|
222
|
+
"Socket-Type" => socket_type,
|
|
223
|
+
"Identity" => identity,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
initiate_box_plaintext = "".b
|
|
227
|
+
initiate_box_plaintext << @permanent_public.to_s
|
|
228
|
+
initiate_box_plaintext << vouch_nonce.byteslice(8, 16)
|
|
229
|
+
initiate_box_plaintext << vouch
|
|
230
|
+
initiate_box_plaintext << metadata
|
|
231
|
+
|
|
232
|
+
init_short_nonce = [1].pack("Q>")
|
|
233
|
+
init_nonce = NONCE_PREFIX_INITIATE + init_short_nonce
|
|
234
|
+
init_ciphertext = session.encrypt(init_nonce, initiate_box_plaintext)
|
|
235
|
+
|
|
236
|
+
initiate = "".b
|
|
237
|
+
initiate << "\x08INITIATE"
|
|
238
|
+
initiate << cookie
|
|
239
|
+
initiate << init_short_nonce
|
|
240
|
+
initiate << init_ciphertext
|
|
241
|
+
|
|
242
|
+
io.write(Codec::Frame.new(initiate, command: true).to_wire)
|
|
243
|
+
io.flush
|
|
244
|
+
|
|
245
|
+
# --- Read READY ---
|
|
246
|
+
ready_frame = Codec::Frame.read_from(io)
|
|
247
|
+
raise Error, "expected command frame" unless ready_frame.command?
|
|
248
|
+
ready_cmd = Codec::Command.from_body(ready_frame.body)
|
|
249
|
+
raise Error, "expected READY, got #{ready_cmd.name}" unless ready_cmd.name == "READY"
|
|
250
|
+
|
|
251
|
+
rdata = ready_cmd.data
|
|
252
|
+
raise Error, "READY too short" if rdata.bytesize < 8 + BOX_OVERHEAD
|
|
253
|
+
|
|
254
|
+
r_short_nonce = rdata.byteslice(0, 8)
|
|
255
|
+
r_ciphertext = rdata.byteslice(8..)
|
|
256
|
+
r_nonce = NONCE_PREFIX_READY + r_short_nonce
|
|
257
|
+
|
|
258
|
+
begin
|
|
259
|
+
r_plaintext = session.decrypt(r_nonce, r_ciphertext)
|
|
260
|
+
rescue @crypto::CryptoError
|
|
261
|
+
raise Error, "READY decryption failed"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
props = Codec::Command.decode_properties(r_plaintext)
|
|
265
|
+
peer_socket_type = props["Socket-Type"]
|
|
266
|
+
peer_identity = props["Identity"] || ""
|
|
267
|
+
|
|
268
|
+
@session_box = session
|
|
269
|
+
@send_nonce = 1
|
|
270
|
+
@recv_nonce = 0
|
|
271
|
+
init_nonce_buffers!
|
|
272
|
+
|
|
273
|
+
{ peer_socket_type: peer_socket_type, peer_identity: peer_identity }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# ----------------------------------------------------------------
|
|
277
|
+
# Server-side handshake
|
|
278
|
+
# ----------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
def server_handshake!(io, socket_type:, identity:)
|
|
281
|
+
io.write(Codec::Greeting.encode(mechanism: MECHANISM_NAME, as_server: true))
|
|
282
|
+
io.flush
|
|
283
|
+
peer_greeting = Codec::Greeting.decode(io.read_exactly(Codec::Greeting::SIZE))
|
|
284
|
+
unless peer_greeting[:mechanism] == MECHANISM_NAME
|
|
285
|
+
raise Error, "expected CURVE mechanism, got #{peer_greeting[:mechanism]}"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# --- Read HELLO ---
|
|
289
|
+
hello_frame = Codec::Frame.read_from(io)
|
|
290
|
+
raise Error, "expected command frame" unless hello_frame.command?
|
|
291
|
+
hello_cmd = Codec::Command.from_body(hello_frame.body)
|
|
292
|
+
raise Error, "expected HELLO, got #{hello_cmd.name}" unless hello_cmd.name == "HELLO"
|
|
293
|
+
|
|
294
|
+
hdata = hello_cmd.data
|
|
295
|
+
raise Error, "HELLO wrong size (#{hdata.bytesize})" unless hdata.bytesize == 194
|
|
296
|
+
|
|
297
|
+
cn_public = @crypto::PublicKey.new(hdata.byteslice(74, 32))
|
|
298
|
+
h_short_nonce = hdata.byteslice(106, 8)
|
|
299
|
+
h_signature = hdata.byteslice(114, 80)
|
|
300
|
+
|
|
301
|
+
h_nonce = NONCE_PREFIX_HELLO + h_short_nonce
|
|
302
|
+
begin
|
|
303
|
+
plaintext = @crypto::Box.new(cn_public, @permanent_secret).decrypt(h_nonce, h_signature)
|
|
304
|
+
rescue @crypto::CryptoError
|
|
305
|
+
raise Error, "HELLO signature verification failed"
|
|
306
|
+
end
|
|
307
|
+
unless @crypto::Util.verify64(plaintext, "\x00" * 64)
|
|
308
|
+
raise Error, "HELLO signature content invalid"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# --- WELCOME ---
|
|
312
|
+
sn_secret = @crypto::PrivateKey.generate
|
|
313
|
+
sn_public = sn_secret.public_key
|
|
314
|
+
|
|
315
|
+
cookie_nonce = NONCE_PREFIX_COOKIE + @crypto::Random.random_bytes(16)
|
|
316
|
+
cookie_plaintext = cn_public.to_s + sn_secret.to_s
|
|
317
|
+
cookie = cookie_nonce.byteslice(8, 16) +
|
|
318
|
+
@crypto::SecretBox.new(@cookie_key).encrypt(cookie_nonce, cookie_plaintext)
|
|
319
|
+
|
|
320
|
+
w_plaintext = sn_public.to_s + cookie
|
|
321
|
+
w_short_nonce = @crypto::Random.random_bytes(16)
|
|
322
|
+
w_nonce = NONCE_PREFIX_WELCOME + w_short_nonce
|
|
323
|
+
w_ciphertext = @crypto::Box.new(cn_public, @permanent_secret).encrypt(w_nonce, w_plaintext)
|
|
324
|
+
|
|
325
|
+
welcome = "".b
|
|
326
|
+
welcome << "\x07WELCOME"
|
|
327
|
+
welcome << w_short_nonce
|
|
328
|
+
welcome << w_ciphertext
|
|
329
|
+
|
|
330
|
+
io.write(Codec::Frame.new(welcome, command: true).to_wire)
|
|
331
|
+
io.flush
|
|
332
|
+
|
|
333
|
+
# --- Read INITIATE ---
|
|
334
|
+
init_frame = Codec::Frame.read_from(io)
|
|
335
|
+
raise Error, "expected command frame" unless init_frame.command?
|
|
336
|
+
init_cmd = Codec::Command.from_body(init_frame.body)
|
|
337
|
+
raise Error, "expected INITIATE, got #{init_cmd.name}" unless init_cmd.name == "INITIATE"
|
|
338
|
+
|
|
339
|
+
idata = init_cmd.data
|
|
340
|
+
raise Error, "INITIATE too short" if idata.bytesize < 96 + 8 + BOX_OVERHEAD
|
|
341
|
+
|
|
342
|
+
recv_cookie = idata.byteslice(0, 96)
|
|
343
|
+
i_short_nonce = idata.byteslice(96, 8)
|
|
344
|
+
i_ciphertext = idata.byteslice(104..)
|
|
345
|
+
|
|
346
|
+
cookie_short_nonce = recv_cookie.byteslice(0, 16)
|
|
347
|
+
cookie_ciphertext = recv_cookie.byteslice(16, 80)
|
|
348
|
+
cookie_decrypt_nonce = NONCE_PREFIX_COOKIE + cookie_short_nonce
|
|
349
|
+
begin
|
|
350
|
+
cookie_contents = @crypto::SecretBox.new(@cookie_key).decrypt(cookie_decrypt_nonce, cookie_ciphertext)
|
|
351
|
+
rescue @crypto::CryptoError
|
|
352
|
+
raise Error, "INITIATE cookie verification failed"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
cn_public = @crypto::PublicKey.new(cookie_contents.byteslice(0, 32))
|
|
356
|
+
sn_secret = @crypto::PrivateKey.new(cookie_contents.byteslice(32, 32))
|
|
357
|
+
|
|
358
|
+
session = @crypto::Box.new(cn_public, sn_secret)
|
|
359
|
+
i_nonce = NONCE_PREFIX_INITIATE + i_short_nonce
|
|
360
|
+
|
|
361
|
+
begin
|
|
362
|
+
i_plaintext = session.decrypt(i_nonce, i_ciphertext)
|
|
363
|
+
rescue @crypto::CryptoError
|
|
364
|
+
raise Error, "INITIATE decryption failed"
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
raise Error, "INITIATE plaintext too short" if i_plaintext.bytesize < 32 + 16 + 80
|
|
368
|
+
|
|
369
|
+
client_permanent = @crypto::PublicKey.new(i_plaintext.byteslice(0, 32))
|
|
370
|
+
vouch_short_nonce = i_plaintext.byteslice(32, 16)
|
|
371
|
+
vouch_ciphertext = i_plaintext.byteslice(48, 80)
|
|
372
|
+
metadata_bytes = i_plaintext.byteslice(128..) || "".b
|
|
373
|
+
|
|
374
|
+
vouch_nonce = NONCE_PREFIX_VOUCH + vouch_short_nonce
|
|
375
|
+
begin
|
|
376
|
+
vouch_plaintext = @crypto::Box.new(client_permanent, sn_secret).decrypt(vouch_nonce, vouch_ciphertext)
|
|
377
|
+
rescue @crypto::CryptoError
|
|
378
|
+
raise Error, "INITIATE vouch verification failed"
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
raise Error, "vouch wrong size" unless vouch_plaintext.bytesize == 64
|
|
382
|
+
|
|
383
|
+
vouch_cn = vouch_plaintext.byteslice(0, 32)
|
|
384
|
+
vouch_server = vouch_plaintext.byteslice(32, 32)
|
|
385
|
+
|
|
386
|
+
unless @crypto::Util.verify32(vouch_cn, cn_public.to_s)
|
|
387
|
+
raise Error, "vouch client transient key mismatch"
|
|
388
|
+
end
|
|
389
|
+
unless @crypto::Util.verify32(vouch_server, @permanent_public.to_s)
|
|
390
|
+
raise Error, "vouch server key mismatch"
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
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
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# --- READY ---
|
|
404
|
+
ready_metadata = Codec::Command.encode_properties(
|
|
405
|
+
"Socket-Type" => socket_type,
|
|
406
|
+
"Identity" => identity,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
r_short_nonce = [1].pack("Q>")
|
|
410
|
+
r_nonce = NONCE_PREFIX_READY + r_short_nonce
|
|
411
|
+
r_ciphertext = session.encrypt(r_nonce, ready_metadata)
|
|
412
|
+
|
|
413
|
+
ready = "".b
|
|
414
|
+
ready << "\x05READY"
|
|
415
|
+
ready << r_short_nonce
|
|
416
|
+
ready << r_ciphertext
|
|
417
|
+
|
|
418
|
+
io.write(Codec::Frame.new(ready, command: true).to_wire)
|
|
419
|
+
io.flush
|
|
420
|
+
|
|
421
|
+
props = Codec::Command.decode_properties(metadata_bytes)
|
|
422
|
+
|
|
423
|
+
@session_box = session
|
|
424
|
+
@send_nonce = 1
|
|
425
|
+
@recv_nonce = 0
|
|
426
|
+
init_nonce_buffers!
|
|
427
|
+
|
|
428
|
+
{
|
|
429
|
+
peer_socket_type: props["Socket-Type"],
|
|
430
|
+
peer_identity: props["Identity"] || "",
|
|
431
|
+
}
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# ----------------------------------------------------------------
|
|
435
|
+
# Nonce helpers
|
|
436
|
+
# ----------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
def init_nonce_buffers!
|
|
439
|
+
send_pfx = @as_server ? NONCE_PREFIX_MESSAGE_S : NONCE_PREFIX_MESSAGE_C
|
|
440
|
+
recv_pfx = @as_server ? NONCE_PREFIX_MESSAGE_C : NONCE_PREFIX_MESSAGE_S
|
|
441
|
+
@send_nonce_buf = String.new(send_pfx + ("\x00" * 8), encoding: Encoding::BINARY)
|
|
442
|
+
@recv_nonce_buf = String.new(recv_pfx + ("\x00" * 8), encoding: Encoding::BINARY)
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def make_send_nonce
|
|
446
|
+
@send_nonce += 1
|
|
447
|
+
raise Error, "nonce counter exhausted" if @send_nonce > MAX_NONCE
|
|
448
|
+
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
|
|
456
|
+
@send_nonce_buf.setbyte(16, n & 0xFF)
|
|
457
|
+
@send_nonce_buf
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def validate_key!(key, name)
|
|
461
|
+
raise ArgumentError, "#{name} is required" if key.nil?
|
|
462
|
+
raise ArgumentError, "#{name} must be 32 bytes (got #{key.b.bytesize})" unless key.b.bytesize == 32
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protocol
|
|
4
|
+
module ZMTP
|
|
5
|
+
module Mechanism
|
|
6
|
+
# NULL security mechanism — no encryption, no authentication.
|
|
7
|
+
#
|
|
8
|
+
# Performs the ZMTP 3.1 greeting exchange and READY command handshake.
|
|
9
|
+
#
|
|
10
|
+
class Null
|
|
11
|
+
MECHANISM_NAME = "NULL"
|
|
12
|
+
|
|
13
|
+
# Performs the full NULL handshake over +io+.
|
|
14
|
+
#
|
|
15
|
+
# 1. Exchange 64-byte greetings
|
|
16
|
+
# 2. Validate peer greeting (version, mechanism)
|
|
17
|
+
# 3. Exchange READY commands (socket type + identity)
|
|
18
|
+
#
|
|
19
|
+
# @param io [#read_exactly, #write, #flush] transport IO
|
|
20
|
+
# @param as_server [Boolean]
|
|
21
|
+
# @param socket_type [String]
|
|
22
|
+
# @param identity [String]
|
|
23
|
+
# @return [Hash] { peer_socket_type:, peer_identity: }
|
|
24
|
+
# @raise [Error]
|
|
25
|
+
def handshake!(io, as_server:, socket_type:, identity:)
|
|
26
|
+
io.write(Codec::Greeting.encode(mechanism: MECHANISM_NAME, as_server: as_server))
|
|
27
|
+
io.flush
|
|
28
|
+
|
|
29
|
+
greeting_data = io.read_exactly(Codec::Greeting::SIZE)
|
|
30
|
+
peer_greeting = Codec::Greeting.decode(greeting_data)
|
|
31
|
+
|
|
32
|
+
unless peer_greeting[:mechanism] == MECHANISM_NAME
|
|
33
|
+
raise Error, "unsupported mechanism: #{peer_greeting[:mechanism]}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
ready_cmd = Codec::Command.ready(socket_type: socket_type, identity: identity)
|
|
37
|
+
io.write(ready_cmd.to_frame.to_wire)
|
|
38
|
+
io.flush
|
|
39
|
+
|
|
40
|
+
frame = Codec::Frame.read_from(io)
|
|
41
|
+
unless frame.command?
|
|
42
|
+
raise Error, "expected command frame, got data frame"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
peer_cmd = Codec::Command.from_body(frame.body)
|
|
46
|
+
unless peer_cmd.name == "READY"
|
|
47
|
+
raise Error, "expected READY command, got #{peer_cmd.name}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
props = peer_cmd.properties
|
|
51
|
+
peer_socket_type = props["Socket-Type"]
|
|
52
|
+
peer_identity = props["Identity"] || ""
|
|
53
|
+
|
|
54
|
+
unless peer_socket_type
|
|
55
|
+
raise Error, "peer READY missing Socket-Type"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
{ peer_socket_type: peer_socket_type, peer_identity: peer_identity }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Boolean] false — NULL does not encrypt frames
|
|
62
|
+
def encrypted? = false
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protocol
|
|
4
|
+
module ZMTP
|
|
5
|
+
# Valid socket type peer combinations per ZMTP spec.
|
|
6
|
+
VALID_PEERS = {
|
|
7
|
+
PAIR: %i[PAIR].freeze,
|
|
8
|
+
REQ: %i[REP ROUTER].freeze,
|
|
9
|
+
REP: %i[REQ DEALER].freeze,
|
|
10
|
+
DEALER: %i[REP DEALER ROUTER].freeze,
|
|
11
|
+
ROUTER: %i[REQ DEALER ROUTER].freeze,
|
|
12
|
+
PUB: %i[SUB XSUB].freeze,
|
|
13
|
+
SUB: %i[PUB XPUB].freeze,
|
|
14
|
+
XPUB: %i[SUB XSUB].freeze,
|
|
15
|
+
XSUB: %i[PUB XPUB].freeze,
|
|
16
|
+
PUSH: %i[PULL].freeze,
|
|
17
|
+
PULL: %i[PUSH].freeze,
|
|
18
|
+
CLIENT: %i[SERVER].freeze,
|
|
19
|
+
SERVER: %i[CLIENT].freeze,
|
|
20
|
+
RADIO: %i[DISH].freeze,
|
|
21
|
+
DISH: %i[RADIO].freeze,
|
|
22
|
+
SCATTER: %i[GATHER].freeze,
|
|
23
|
+
GATHER: %i[SCATTER].freeze,
|
|
24
|
+
PEER: %i[PEER].freeze,
|
|
25
|
+
CHANNEL: %i[CHANNEL].freeze,
|
|
26
|
+
}.freeze
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protocol
|
|
4
|
+
module ZMTP
|
|
5
|
+
# Z85 encoding/decoding (ZeroMQ RFC 32).
|
|
6
|
+
#
|
|
7
|
+
# Encodes binary data in printable ASCII using an 85-character alphabet.
|
|
8
|
+
# Input length must be a multiple of 4 bytes; output is 5/4 the size.
|
|
9
|
+
#
|
|
10
|
+
module Z85
|
|
11
|
+
CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-:+=^!/*?&<>()[]{}@%$#".freeze
|
|
12
|
+
DECODE = Array.new(128, -1)
|
|
13
|
+
CHARS.each_byte.with_index { |b, i| DECODE[b] = i }
|
|
14
|
+
DECODE.freeze
|
|
15
|
+
|
|
16
|
+
BASE = 85
|
|
17
|
+
|
|
18
|
+
def self.encode(data)
|
|
19
|
+
data = data.b
|
|
20
|
+
raise ArgumentError, "data length must be a multiple of 4 (got #{data.bytesize})" unless (data.bytesize % 4).zero?
|
|
21
|
+
|
|
22
|
+
out = String.new(capacity: data.bytesize * 5 / 4)
|
|
23
|
+
i = 0
|
|
24
|
+
while i < data.bytesize
|
|
25
|
+
value = data.getbyte(i) << 24 | data.getbyte(i + 1) << 16 |
|
|
26
|
+
data.getbyte(i + 2) << 8 | data.getbyte(i + 3)
|
|
27
|
+
4.downto(0) do |j|
|
|
28
|
+
out << CHARS[(value / (BASE**j)) % BASE]
|
|
29
|
+
end
|
|
30
|
+
i += 4
|
|
31
|
+
end
|
|
32
|
+
out
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.decode(string)
|
|
36
|
+
raise ArgumentError, "string length must be a multiple of 5 (got #{string.bytesize})" unless (string.bytesize % 5).zero?
|
|
37
|
+
|
|
38
|
+
out = String.new(capacity: string.bytesize * 4 / 5, encoding: Encoding::BINARY)
|
|
39
|
+
i = 0
|
|
40
|
+
while i < string.bytesize
|
|
41
|
+
value = 0
|
|
42
|
+
5.times do |j|
|
|
43
|
+
byte = string.getbyte(i + j)
|
|
44
|
+
d = byte < 128 ? DECODE[byte] : -1
|
|
45
|
+
raise ArgumentError, "invalid Z85 character: #{string[i + j].inspect}" if d == -1
|
|
46
|
+
value = value * BASE + d
|
|
47
|
+
end
|
|
48
|
+
out << ((value >> 24) & 0xFF).chr
|
|
49
|
+
out << ((value >> 16) & 0xFF).chr
|
|
50
|
+
out << ((value >> 8) & 0xFF).chr
|
|
51
|
+
out << (value & 0xFF).chr
|
|
52
|
+
i += 5
|
|
53
|
+
end
|
|
54
|
+
out
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "zmtp/version"
|
|
4
|
+
require_relative "zmtp/error"
|
|
5
|
+
require_relative "zmtp/valid_peers"
|
|
6
|
+
require_relative "zmtp/codec"
|
|
7
|
+
require_relative "zmtp/connection"
|
|
8
|
+
require_relative "zmtp/mechanism/null"
|
|
9
|
+
require_relative "zmtp/z85"
|
|
10
|
+
|
|
11
|
+
module Protocol
|
|
12
|
+
module ZMTP
|
|
13
|
+
# Autoload CURVE mechanism — requires a crypto backend (rbnacl or nuckle).
|
|
14
|
+
autoload :Curve, File.expand_path("zmtp/mechanism/curve", __dir__)
|
|
15
|
+
end
|
|
16
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: protocol-zmtp
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Patrik Wenger
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Pure Ruby implementation of the ZMTP 3.1 wire protocol (ZeroMQ Message
|
|
13
|
+
Transport Protocol). Includes frame codec, greeting, commands, NULL and CURVE mechanisms,
|
|
14
|
+
and connection management. No runtime dependencies.
|
|
15
|
+
email:
|
|
16
|
+
- paddor@gmail.com
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- LICENSE
|
|
22
|
+
- README.md
|
|
23
|
+
- lib/protocol/zmtp.rb
|
|
24
|
+
- lib/protocol/zmtp/codec.rb
|
|
25
|
+
- lib/protocol/zmtp/codec/command.rb
|
|
26
|
+
- lib/protocol/zmtp/codec/frame.rb
|
|
27
|
+
- lib/protocol/zmtp/codec/greeting.rb
|
|
28
|
+
- lib/protocol/zmtp/connection.rb
|
|
29
|
+
- lib/protocol/zmtp/error.rb
|
|
30
|
+
- lib/protocol/zmtp/mechanism/curve.rb
|
|
31
|
+
- lib/protocol/zmtp/mechanism/null.rb
|
|
32
|
+
- lib/protocol/zmtp/valid_peers.rb
|
|
33
|
+
- lib/protocol/zmtp/version.rb
|
|
34
|
+
- lib/protocol/zmtp/z85.rb
|
|
35
|
+
homepage: https://github.com/paddor/protocol-zmtp
|
|
36
|
+
licenses:
|
|
37
|
+
- ISC
|
|
38
|
+
metadata: {}
|
|
39
|
+
rdoc_options: []
|
|
40
|
+
require_paths:
|
|
41
|
+
- lib
|
|
42
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.3'
|
|
47
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
48
|
+
requirements:
|
|
49
|
+
- - ">="
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '0'
|
|
52
|
+
requirements: []
|
|
53
|
+
rubygems_version: 4.0.6
|
|
54
|
+
specification_version: 4
|
|
55
|
+
summary: ZMTP 3.1 wire protocol codec and connection
|
|
56
|
+
test_files: []
|