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 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
+ [![Gem Version](https://img.shields.io/gem/v/protocol-zmtp)](https://rubygems.org/gems/protocol-zmtp)
4
+ [![CI](https://github.com/paddor/protocol-zmtp/actions/workflows/ci.yml/badge.svg)](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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protocol
4
+ module ZMTP
5
+ # ZMTP 3.1 wire protocol codec.
6
+ module Codec
7
+ end
8
+ end
9
+ end
10
+
11
+ require_relative "codec/greeting"
12
+ require_relative "codec/frame"
13
+ require_relative "codec/command"
@@ -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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protocol
4
+ module ZMTP
5
+ # Raised on ZMTP protocol violations.
6
+ class Error < RuntimeError; end
7
+ end
8
+ 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protocol
4
+ module ZMTP
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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: []