omq-blake3zmq 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6ad881032a9ee2f62f9109d025e7eed78115dd86b2360f40a1edfab3f66a9fba
4
+ data.tar.gz: a4632fbf7ee8346a84042064d917d46e6af3573779ee44a126a234be9895e782
5
+ SHA512:
6
+ metadata.gz: 56c18fb8e7f70d70a7ba1abfaa3e23a2874f4cf56b26c49ad35117634c23a6cd77093be4b06fb00f39371dc1ffa8cf51d9ccd80c839fd64ac23f68530ce5525e
7
+ data.tar.gz: 4cd8f8f1b27fcfcb1e6c209ded268c064f370d77d6dff5748d4eee324d5b5ed092b8876dc418b86c64e6cb17f87fdd74c07566dba95bb129bca89bbbbb690fc2
data/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2025-2026, Patrik Wenger
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # BLAKE3ZMQ
2
+
3
+ > [!WARNING]
4
+ > This is experimental and the gem is not maintained by cryptographers.
5
+ > It has not been independently audited. For production, use CurveZMQ instead.
6
+
7
+ BLAKE3ZMQ is a security mechanism for [OMQ](https://github.com/paddor/omq)
8
+ that replaces [CurveZMQ](https://rfc.zeromq.org/spec/26/) with modern
9
+ primitives:
10
+
11
+ * **X25519** key exchange (perfect forward secrecy)
12
+ * **ChaCha20-BLAKE3** AEAD (32-byte authentication tags)
13
+ * **BLAKE3** transcript hash and key derivation
14
+
15
+ It implements the `Protocol::ZMTP::Mechanism::Blake3` class for use with
16
+ [protocol-zmtp](https://github.com/paddor/protocol-zmtp).
17
+
18
+ See [RFC.md](RFC.md) for the full protocol specification.
19
+
20
+ ## Features
21
+
22
+ * 4-message handshake (HELLO, WELCOME, INITIATE, READY)
23
+ * Transcript hash binding across all handshake messages; all post-handshake frames (including commands) AEAD-encrypted, unlike CurveZMQ
24
+ * Deterministic nonces (no per-message randomness needed)
25
+ * 32 bytes overhead per message (no wire nonce, no command wrapper)
26
+ * Stateless server until client authentication (cookie mechanism)
27
+ * Mutual authentication or server-only (anonymous client) modes
28
+ * Works out of the box (native C X25519 + Rust AEAD included)
29
+ * Crypto-backend-agnostic (can substitute your own primitives)
30
+
31
+ ## Installation
32
+
33
+ ```ruby
34
+ gem "omq-blake3zmq"
35
+ ```
36
+
37
+ Batteries included: ships with [x25519](https://github.com/RubyCrypto/x25519)
38
+ (native C) and [chacha20blake3](https://github.com/paddor/chacha20blake3)
39
+ (Rust native).
40
+
41
+ ## Usage
42
+
43
+ ```ruby
44
+ require "omq"
45
+ require "omq/blake3zmq"
46
+
47
+ Crypto = OMQ::Blake3ZMQ::Crypto
48
+
49
+ # Generate or load keys
50
+ server_sk = Crypto::PrivateKey.generate
51
+ server_pk = server_sk.public_key.to_s
52
+
53
+ client_sk = Crypto::PrivateKey.generate
54
+ client_pk = client_sk.public_key.to_s
55
+
56
+ # Server socket
57
+ server = OMQ::REP.new
58
+ server.mechanism = Protocol::ZMTP::Mechanism::Blake3.server(
59
+ public_key: server_pk,
60
+ secret_key: server_sk.to_s,
61
+ authenticator: ->(peer) { peer.public_key.to_s == client_pk },
62
+ )
63
+ server.bind("tcp://127.0.0.1:9999")
64
+
65
+ # Client socket (keys optional — omit for anonymous/ephemeral identity)
66
+ client = OMQ::REQ.new
67
+ client.mechanism = Protocol::ZMTP::Mechanism::Blake3.client(
68
+ server_key: server_pk,
69
+ public_key: client_pk,
70
+ secret_key: client_sk.to_s,
71
+ )
72
+ client.connect("tcp://127.0.0.1:9999")
73
+ ```
74
+
75
+ ## Benchmarks
76
+
77
+ CurveZMQ (RbNaCl/libsodium) vs BLAKE3ZMQ (Rust native ChaCha20-BLAKE3 + C native X25519).
78
+
79
+ Ruby 4.0.2, x86_64 Linux.
80
+
81
+ ### Handshake latency (100 rounds)
82
+
83
+ | | Time | Per handshake |
84
+ |---|---:|---:|
85
+ | CurveZMQ/RbNaCl | 226 ms | 2.26 ms |
86
+ | BLAKE3ZMQ | 120 ms | 1.20 ms |
87
+ | **Speedup** | | **1.9x** |
88
+
89
+ ### Message encrypt + decrypt throughput (20,000 messages)
90
+
91
+ | Size | CurveZMQ/RbNaCl | BLAKE3ZMQ | Speedup |
92
+ |---:|---:|---:|---:|
93
+ | 64 B | 8.4 MB/s | 16.1 MB/s | 1.9x |
94
+ | 256 B | 38.0 MB/s | 51.4 MB/s | 1.4x |
95
+ | 1 KB | 88.1 MB/s | 137.5 MB/s | 1.6x |
96
+ | 4 KB | 196.3 MB/s | 265.5 MB/s | 1.4x |
97
+ | 16 KB | 289.1 MB/s | 387.1 MB/s | 1.3x |
98
+ | 64 KB | 413.3 MB/s | 452.4 MB/s | 1.1x |
99
+ | 128 KB | 426.4 MB/s | 527.0 MB/s | 1.2x |
100
+ | 256 KB | 428.7 MB/s | 538.0 MB/s | 1.3x |
101
+
102
+ ### Full round-trip (handshake + 1,000 messages over UNIXSocket)
103
+
104
+ | Size | CurveZMQ/RbNaCl | BLAKE3ZMQ | Speedup |
105
+ |---:|---:|---:|---:|
106
+ | 64 B | 69.4 ms (0.9 MB/s) | 50.8 ms (1.3 MB/s) | 1.4x |
107
+ | 1 KB | 54.6 ms (18.7 MB/s) | 62.6 ms (16.3 MB/s) | 0.9x |
108
+ | 64 KB | 281.7 ms (232.6 MB/s) | 242.2 ms (270.6 MB/s) | 1.2x |
109
+
110
+ Run benchmarks yourself:
111
+
112
+ ```
113
+ OMQ_DEV=1 bundle exec ruby bench/throughput.rb
114
+ ```
115
+
116
+ ## Per-message overhead
117
+
118
+ | | BLAKE3ZMQ | CurveZMQ |
119
+ |---|---:|---:|
120
+ | Tag size | 32 bytes | 16 bytes |
121
+ | Counter on wire | 0 bytes | 8 bytes |
122
+ | Command wrapper | none | 17 bytes |
123
+ | **Total** | **32 bytes** | **41 bytes** |
124
+
125
+ ## Development
126
+
127
+ ```
128
+ bundle install
129
+ bundle exec rake test
130
+ ```
131
+
132
+ ## License
133
+
134
+ [ISC](LICENSE)
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "chacha20blake3"
4
+ require "digest/blake3"
5
+ require "x25519"
6
+ require "securerandom"
7
+
8
+ module OMQ
9
+ module Blake3ZMQ
10
+
11
+ # Default crypto backend: x25519 (native C) + chacha20blake3 (Rust native).
12
+ module Crypto
13
+ CryptoError = ChaCha20Blake3::DecryptionError
14
+ TAG_SIZE = ChaCha20Blake3::TAG_SIZE
15
+ Cipher = ChaCha20Blake3::Cipher
16
+ Stream = ChaCha20Blake3::Stream
17
+
18
+
19
+ # X25519 public key wrapper.
20
+ class PublicKey
21
+ attr_reader :key
22
+
23
+
24
+ # @param bytes [String] 32-byte public key
25
+ def initialize(bytes)
26
+ bytes = bytes.to_s if bytes.respond_to?(:to_bytes)
27
+ @key = X25519::MontgomeryU.new(bytes.b)
28
+ end
29
+
30
+
31
+ # Returns the raw 32-byte public key.
32
+ #
33
+ # @return [String] 32-byte binary string
34
+ def to_s
35
+ @key.to_bytes
36
+ end
37
+ end
38
+
39
+
40
+ # X25519 private key wrapper with key generation and Diffie-Hellman.
41
+ class PrivateKey
42
+ # Generates a new random private key.
43
+ #
44
+ # @return [PrivateKey]
45
+ def self.generate
46
+ new(X25519::Scalar.generate.to_bytes)
47
+ end
48
+
49
+
50
+ # @param bytes [String] 32-byte secret key
51
+ def initialize(bytes)
52
+ @key = X25519::Scalar.new(bytes.b)
53
+ end
54
+
55
+
56
+ # Returns the corresponding public key.
57
+ #
58
+ # @return [PublicKey]
59
+ def public_key
60
+ PublicKey.new(@key.public_key.to_bytes)
61
+ end
62
+
63
+
64
+ # Returns the raw 32-byte secret key.
65
+ #
66
+ # @return [String] 32-byte binary string
67
+ def to_s
68
+ @key.to_bytes
69
+ end
70
+
71
+
72
+ # Performs X25519 Diffie-Hellman with a peer's public key.
73
+ #
74
+ # @param peer_public_key [PublicKey] peer's public key
75
+ # @return [String] 32-byte shared secret
76
+ def diffie_hellman(peer_public_key)
77
+ pk = case peer_public_key
78
+ when PublicKey
79
+ peer_public_key.key
80
+ else
81
+ X25519::MontgomeryU.new(peer_public_key.to_s.b)
82
+ end
83
+
84
+ @key.diffie_hellman(pk).to_bytes
85
+ end
86
+ end
87
+
88
+
89
+ # BLAKE3 hashing and key derivation functions.
90
+ module Hash
91
+ module_function
92
+
93
+ # Computes a 32-byte BLAKE3 digest.
94
+ #
95
+ # @param input [String] data to hash
96
+ # @return [String] 32-byte binary digest
97
+ def digest(input)
98
+ Digest::Blake3.digest(input)
99
+ end
100
+
101
+
102
+ # Derives a key using BLAKE3 key derivation.
103
+ #
104
+ # @param context [String] domain separation context string
105
+ # @param material [String] input keying material
106
+ # @param size [Integer] output length in bytes (default 32)
107
+ # @return [String] derived key bytes
108
+ def derive_key(context, material, size: 32)
109
+ ChaCha20Blake3.derive_key(context, material, length: size)
110
+ end
111
+ end
112
+
113
+
114
+ module_function
115
+
116
+
117
+ # Generates cryptographically secure random bytes.
118
+ #
119
+ # @param n [Integer] number of bytes to generate
120
+ # @return [String] random binary string
121
+ def random_bytes(n)
122
+ SecureRandom.random_bytes(n)
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,687 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protocol
4
+ module ZMTP
5
+ module Mechanism
6
+ # BLAKE3ZMQ security mechanism.
7
+ #
8
+ # Provides X25519 key exchange, ChaCha20-BLAKE3 AEAD encryption, and
9
+ # BLAKE3 transcript hashing for ZMTP 3.1 connections.
10
+ #
11
+ # Crypto-backend-agnostic: pass any module that provides the required
12
+ # interface via the +crypto:+ parameter.
13
+ #
14
+ # The crypto backend must provide:
15
+ # backend::PrivateKey.generate / .new(bytes)
16
+ # #public_key -> PublicKey, #to_s -> 32 bytes, #diffie_hellman(pub) -> 32 bytes
17
+ # backend::PublicKey.new(bytes)
18
+ # #to_s -> 32 bytes
19
+ # backend::Cipher.new(key)
20
+ # #encrypt(nonce, plaintext, aad:) -> ciphertext+tag
21
+ # #decrypt(nonce, ciphertext+tag, aad:) -> plaintext
22
+ # backend::Stream.new(key, nonce)
23
+ # #encrypt(plaintext, aad:) -> ciphertext+tag
24
+ # #decrypt(ciphertext+tag, aad:) -> plaintext
25
+ # backend::Hash.digest(input) -> 32 bytes
26
+ # backend::Hash.derive_key(context, material) -> 32 bytes
27
+ # backend::Hash.derive_key(context, material, size: n) -> n bytes
28
+ # backend.random_bytes(n) -> n bytes
29
+ # backend::CryptoError (exception class)
30
+ # backend::TAG_SIZE = 32
31
+ #
32
+ class Blake3
33
+ MECHANISM_NAME = "BLAKE3"
34
+ PROTOCOL_ID = "BLAKE3ZMQ-1.0"
35
+ TAG_SIZE = 32
36
+ KEY_SIZE = 32
37
+ NONCE_SIZE = 24
38
+
39
+
40
+ # Creates a BLAKE3 server mechanism.
41
+ #
42
+ # @param public_key [String] 32 bytes
43
+ # @param secret_key [String] 32 bytes
44
+ # @param crypto [Module] crypto backend
45
+ # @param authenticator [#call, nil] called with a {PeerInfo} during
46
+ # authentication; must return truthy to allow the connection.
47
+ # When nil, any client with a valid vouch is accepted.
48
+ # @return [Blake3]
49
+ def self.server(public_key:, secret_key:, crypto: OMQ::Blake3ZMQ::Crypto, authenticator: nil)
50
+ new(public_key:, secret_key:, crypto:, as_server: true, authenticator:)
51
+ end
52
+
53
+
54
+ # Creates a BLAKE3 client mechanism.
55
+ #
56
+ # @param server_key [String] 32 bytes (server permanent public key)
57
+ # @param crypto [Module] crypto backend
58
+ # @param public_key [String, nil] 32 bytes (or nil for auto-generated ephemeral identity)
59
+ # @param secret_key [String, nil] 32 bytes (or nil for auto-generated ephemeral identity)
60
+ # @return [Blake3]
61
+ def self.client(server_key:, crypto: OMQ::Blake3ZMQ::Crypto, public_key: nil, secret_key: nil)
62
+ new(public_key:, secret_key:, server_key:, crypto:, as_server: false)
63
+ end
64
+
65
+
66
+ # Initializes a new BLAKE3 mechanism instance.
67
+ #
68
+ # @param public_key [String, nil] 32-byte permanent public key
69
+ # @param secret_key [String, nil] 32-byte permanent secret key
70
+ # @param server_key [String, nil] 32-byte server permanent public key (client only)
71
+ # @param crypto [Module] crypto backend module
72
+ # @param as_server [Boolean] whether this instance acts as a server
73
+ # @param authenticator [#call, nil] optional authenticator for server mode
74
+ def initialize(public_key: nil, secret_key: nil, server_key: nil, crypto: OMQ::Blake3ZMQ::Crypto, as_server: false, authenticator: nil)
75
+ @crypto = crypto
76
+ @as_server = as_server
77
+ @authenticator = authenticator
78
+
79
+ if as_server
80
+ validate_key!(public_key, "public_key")
81
+ validate_key!(secret_key, "secret_key")
82
+
83
+ @permanent_public = crypto::PublicKey.new(public_key.b)
84
+ @permanent_secret = crypto::PrivateKey.new(secret_key.b)
85
+ @cookie_key = crypto.random_bytes(KEY_SIZE)
86
+ else
87
+ validate_key!(server_key, "server_key")
88
+
89
+ @server_public = crypto::PublicKey.new(server_key.b)
90
+
91
+ if public_key && secret_key
92
+ validate_key!(public_key, "public_key")
93
+ validate_key!(secret_key, "secret_key")
94
+
95
+ @permanent_public = crypto::PublicKey.new(public_key.b)
96
+ @permanent_secret = crypto::PrivateKey.new(secret_key.b)
97
+ else
98
+ @permanent_secret = crypto::PrivateKey.generate
99
+ @permanent_public = @permanent_secret.public_key
100
+ end
101
+ end
102
+
103
+ @send_stream = nil
104
+ @recv_stream = nil
105
+ end
106
+
107
+
108
+ # Resets stream state when duplicating the mechanism.
109
+ #
110
+ # @param source [Blake3] the original mechanism being duplicated
111
+ def initialize_dup(source)
112
+ super
113
+ @send_stream = nil
114
+ @recv_stream = nil
115
+ end
116
+
117
+
118
+ # Whether this mechanism encrypts traffic.
119
+ #
120
+ # @return [Boolean] always true
121
+ def encrypted?
122
+ true
123
+ end
124
+
125
+
126
+ # Returns a maintenance task that rotates the server cookie key.
127
+ #
128
+ # @return [Hash, nil] a hash with +:interval+ (seconds) and +:task+ (Proc), or nil for clients
129
+ def maintenance
130
+ return unless @as_server
131
+
132
+ {
133
+ interval: 60,
134
+ task: -> { @cookie_key = @crypto.random_bytes(KEY_SIZE) }
135
+ }.freeze
136
+ end
137
+
138
+
139
+ # Performs the BLAKE3ZMQ handshake over the given IO.
140
+ #
141
+ # Delegates to the client or server handshake depending on role.
142
+ #
143
+ # @param io [#write, #read_exactly] transport IO
144
+ # @param as_server [Boolean] ignored (role is set at construction)
145
+ # @param socket_type [String] ZMTP socket type name
146
+ # @param identity [String] socket identity
147
+ # @param metadata [Hash{String => String}, nil] extra READY properties
148
+ # @return [Hash] peer metadata including +:peer_socket_type+, +:peer_identity+, +:peer_properties+
149
+ def handshake!(io, as_server:, socket_type:, identity:, metadata: nil)
150
+ if @as_server
151
+ server_handshake!(io, socket_type:, identity:, metadata:)
152
+ else
153
+ client_handshake!(io, socket_type:, identity:, metadata:)
154
+ end
155
+ end
156
+
157
+
158
+ # Encrypts a ZMTP frame body for transmission.
159
+ #
160
+ # The AEAD AAD per RFC §10.3 is `flags_byte || length_bytes` —
161
+ # every wire byte that is not itself encrypted. This binds the
162
+ # full wire envelope (including the LONG bit and the size
163
+ # field) so any single-bit modification fails verification.
164
+ #
165
+ # @param body [String] plaintext frame body
166
+ # @param more [Boolean] whether the MORE flag is set
167
+ # @param command [Boolean] whether this is a command frame
168
+ # @return [String] wire-encoded encrypted frame (header + ciphertext)
169
+ def encrypt(body, more: false, command: false)
170
+ flags = 0
171
+ flags |= 0x01 if more
172
+ flags |= 0x04 if command
173
+
174
+ # ChaCha20-BLAKE3 ciphertext is plaintext + 32-byte tag.
175
+ frame_size = body.bytesize + TAG_SIZE
176
+
177
+ if frame_size > 255
178
+ wire_flags = (flags | 0x02).chr
179
+ length_bytes = [frame_size].pack("Q>")
180
+ else
181
+ wire_flags = flags.chr
182
+ length_bytes = frame_size.chr
183
+ end
184
+ aad = wire_flags + length_bytes
185
+
186
+ ct = @send_stream.encrypt(body, aad: aad)
187
+
188
+ wire = String.new(encoding: Encoding::BINARY, capacity: aad.bytesize + frame_size)
189
+ wire << aad << ct
190
+ end
191
+
192
+
193
+ # Decrypts an encrypted ZMTP frame.
194
+ #
195
+ # The AAD is reconstructed from the exact wire bytes the codec
196
+ # parsed: the flags byte (with LONG bit if the frame was long)
197
+ # and the length encoding that followed it.
198
+ #
199
+ # @param frame [Codec::Frame] encrypted frame with body, more?, and command? attributes
200
+ # @return [Codec::Frame] decrypted frame
201
+ # @raise [Error] if decryption fails
202
+ def decrypt(frame)
203
+ flags = 0
204
+ flags |= 0x01 if frame.more?
205
+ flags |= 0x04 if frame.command?
206
+
207
+ frame_size = frame.body.bytesize
208
+ if frame_size > 255
209
+ wire_flags = (flags | 0x02).chr
210
+ length_bytes = [frame_size].pack("Q>")
211
+ else
212
+ wire_flags = flags.chr
213
+ length_bytes = frame_size.chr
214
+ end
215
+ aad = wire_flags + length_bytes
216
+
217
+ begin
218
+ pt = @recv_stream.decrypt(frame.body, aad: aad)
219
+ rescue @crypto::CryptoError
220
+ raise Error, "decryption failed"
221
+ end
222
+
223
+ Codec::Frame.new(pt, more: frame.more?, command: frame.command?)
224
+ end
225
+
226
+
227
+ private
228
+
229
+
230
+ # ----------------------------------------------------------------
231
+ # Client-side handshake
232
+ # ----------------------------------------------------------------
233
+
234
+ def client_handshake!(io, socket_type:, identity:, metadata: nil)
235
+ # Generate ephemeral keypair
236
+ cn_secret = @crypto::PrivateKey.generate
237
+ cn_public = cn_secret.public_key
238
+
239
+ # Exchange greetings
240
+ our_greeting = Codec::Greeting.encode(mechanism: MECHANISM_NAME, as_server: false)
241
+ io.write(our_greeting)
242
+ io.flush
243
+ peer_greeting_bytes = io.read_exactly(Codec::Greeting::SIZE)
244
+ peer_greeting = Codec::Greeting.decode(peer_greeting_bytes)
245
+
246
+ unless peer_greeting[:mechanism] == MECHANISM_NAME
247
+ raise Error, "expected #{MECHANISM_NAME} mechanism, got #{peer_greeting[:mechanism]}"
248
+ end
249
+
250
+
251
+ # h0 = H("BLAKE3ZMQ-1.0" || client_greeting || server_greeting)
252
+ h = hash(PROTOCOL_ID + our_greeting + peer_greeting_bytes)
253
+
254
+ # --- HELLO ---
255
+ dh1 = cn_secret.diffie_hellman(@server_public)
256
+ validate_dh!(dh1, "dh1")
257
+ hello_key = kdf("#{PROTOCOL_ID} HELLO key", dh1)
258
+ hello_nonce = kdf24("#{PROTOCOL_ID} HELLO nonce", cn_public.to_s)
259
+ hello_box = @crypto::Cipher.new(hello_key).encrypt(hello_nonce, "\x00" * 64, aad: "HELLO")
260
+
261
+ hello = "".b
262
+ hello << "\x05HELLO"
263
+ hello << "\x01\x00" # version 1.0
264
+ hello << cn_public.to_s # 32 bytes
265
+ hello << ("\x00" * 96) # padding (HELLO 232 ≥ WELCOME 224, anti-amplification)
266
+ hello << hello_box # 64 + 32(tag) = 96 bytes
267
+
268
+ hello_wire = Codec::Frame.new(hello, command: true).to_wire
269
+ io.write(hello_wire)
270
+ io.flush
271
+
272
+ # h1 = H(h0 || HELLO_wire_bytes)
273
+ h = hash(h + hello_wire)
274
+
275
+ # --- Read WELCOME ---
276
+ welcome_frame = Codec::Frame.read_from(io)
277
+ raise Error, "expected command frame" unless welcome_frame.command?
278
+ welcome_cmd = Codec::Command.from_body(welcome_frame.body)
279
+ raise Error, "expected WELCOME, got #{welcome_cmd.name}" unless welcome_cmd.name == "WELCOME"
280
+
281
+ welcome_data = welcome_cmd.data
282
+ welcome_box_size = KEY_SIZE + 152 + TAG_SIZE # S'(32) + cookie(152) + tag(32) = 216
283
+ raise Error, "WELCOME wrong size" unless welcome_data.bytesize == welcome_box_size
284
+
285
+ welcome_key = kdf("#{PROTOCOL_ID} WELCOME key", dh1)
286
+ welcome_nonce = kdf24("#{PROTOCOL_ID} WELCOME nonce", h)
287
+ begin
288
+ welcome_plain = @crypto::Cipher.new(welcome_key).decrypt(welcome_nonce, welcome_data, aad: "WELCOME")
289
+ rescue @crypto::CryptoError
290
+ raise Error, "WELCOME decryption failed"
291
+ end
292
+
293
+ sn_public = @crypto::PublicKey.new(welcome_plain.byteslice(0, KEY_SIZE))
294
+ cookie = welcome_plain.byteslice(KEY_SIZE..)
295
+
296
+ # h2 = H(h1 || WELCOME_wire_bytes)
297
+ h = hash(h + welcome_frame.to_wire)
298
+
299
+ # --- INITIATE ---
300
+ dh2 = cn_secret.diffie_hellman(sn_public)
301
+ validate_dh!(dh2, "dh2")
302
+
303
+ initiate_key = kdf("#{PROTOCOL_ID} INITIATE key", dh2 + h)
304
+ initiate_nonce = kdf24("#{PROTOCOL_ID} INITIATE nonce", dh2 + h)
305
+
306
+ props = { "Socket-Type" => socket_type, "Identity" => identity }
307
+ props.merge!(metadata) if metadata && !metadata.empty?
308
+ metadata_bytes = Codec::Command.encode_properties(props)
309
+
310
+ dh3 = @permanent_secret.diffie_hellman(sn_public)
311
+ validate_dh!(dh3, "dh3")
312
+
313
+ vouch_key = kdf("#{PROTOCOL_ID} VOUCH key", dh3)
314
+ vouch_nonce = kdf24("#{PROTOCOL_ID} VOUCH nonce", dh3)
315
+ vouch_box = @crypto::Cipher.new(vouch_key).encrypt(
316
+ vouch_nonce, cn_public.to_s + @server_public.to_s, aad: "VOUCH"
317
+ )
318
+
319
+ initiate_plaintext = @permanent_public.to_s + vouch_box + metadata_bytes
320
+
321
+ initiate_box = @crypto::Cipher.new(initiate_key).encrypt(
322
+ initiate_nonce, initiate_plaintext, aad: "INITIATE"
323
+ )
324
+
325
+ initiate = "".b
326
+ initiate << "\x08INITIATE"
327
+ initiate << cookie
328
+ initiate << initiate_box
329
+
330
+ initiate_wire = Codec::Frame.new(initiate, command: true).to_wire
331
+ io.write(initiate_wire)
332
+ io.flush
333
+
334
+ # h3 = H(h2 || INITIATE_wire_bytes)
335
+ h = hash(h + initiate_wire)
336
+
337
+ # --- Read READY ---
338
+ ready_frame = Codec::Frame.read_from(io)
339
+ raise Error, "expected command frame" unless ready_frame.command?
340
+ ready_cmd = Codec::Command.from_body(ready_frame.body)
341
+
342
+ if ready_cmd.name == "ERROR"
343
+ reason = ready_cmd.data.bytesize > 0 ? ready_cmd.data.byteslice(1..) : ""
344
+ raise Error, "server rejected: #{reason}"
345
+ end
346
+ raise Error, "expected READY, got #{ready_cmd.name}" unless ready_cmd.name == "READY"
347
+
348
+ ready_key = kdf("#{PROTOCOL_ID} READY key", dh2 + h)
349
+ ready_nonce = kdf24("#{PROTOCOL_ID} READY nonce", dh2 + h)
350
+ begin
351
+ ready_plain = @crypto::Cipher.new(ready_key).decrypt(ready_nonce, ready_cmd.data, aad: "READY")
352
+ rescue @crypto::CryptoError
353
+ raise Error, "READY decryption failed"
354
+ end
355
+
356
+ peer_props = Codec::Command.decode_properties(ready_plain)
357
+ peer_socket_type = peer_props["Socket-Type"]
358
+ peer_identity = peer_props["Identity"] || ""
359
+
360
+ # h4 = H(h3 || READY_wire_bytes)
361
+ h = hash(h + ready_frame.to_wire)
362
+
363
+ # Derive session keys
364
+ derive_session_keys!(h, dh2, as_client: true)
365
+
366
+ {
367
+ peer_socket_type:,
368
+ peer_identity:,
369
+ peer_properties: peer_props,
370
+ peer_major: peer_greeting[:major],
371
+ peer_minor: peer_greeting[:minor],
372
+ }
373
+ end
374
+
375
+
376
+ # ----------------------------------------------------------------
377
+ # Server-side handshake
378
+ # ----------------------------------------------------------------
379
+
380
+ def server_handshake!(io, socket_type:, identity:, metadata: nil)
381
+ # Exchange greetings
382
+ our_greeting = Codec::Greeting.encode(mechanism: MECHANISM_NAME, as_server: true)
383
+ io.write(our_greeting)
384
+ io.flush
385
+ peer_greeting_bytes = io.read_exactly(Codec::Greeting::SIZE)
386
+ peer_greeting = Codec::Greeting.decode(peer_greeting_bytes)
387
+ unless peer_greeting[:mechanism] == MECHANISM_NAME
388
+ raise Error, "expected #{MECHANISM_NAME} mechanism, got #{peer_greeting[:mechanism]}"
389
+ end
390
+
391
+
392
+ # h0 = H("BLAKE3ZMQ-1.0" || client_greeting || server_greeting)
393
+ # Note: peer is the client here
394
+ h = hash(PROTOCOL_ID + peer_greeting_bytes + our_greeting)
395
+
396
+ # --- Read HELLO ---
397
+ hello_frame = Codec::Frame.read_from(io)
398
+ raise Error, "expected command frame" unless hello_frame.command?
399
+ hello_cmd = Codec::Command.from_body(hello_frame.body)
400
+ raise Error, "expected HELLO, got #{hello_cmd.name}" unless hello_cmd.name == "HELLO"
401
+
402
+ hdata = hello_cmd.data
403
+ raise Error, "HELLO wrong size (#{hdata.bytesize})" unless hdata.bytesize == 226
404
+
405
+ # version(2) + C'(32) + padding(96) + hello_box(96)
406
+ cn_public_bytes = hdata.byteslice(2, KEY_SIZE)
407
+ hello_box_data = hdata.byteslice(130, 96)
408
+
409
+ cn_public = @crypto::PublicKey.new(cn_public_bytes)
410
+ dh1 = @permanent_secret.diffie_hellman(cn_public)
411
+ validate_dh!(dh1, "dh1")
412
+
413
+ hello_key = kdf("#{PROTOCOL_ID} HELLO key", dh1)
414
+ hello_nonce = kdf24("#{PROTOCOL_ID} HELLO nonce", cn_public_bytes)
415
+ begin
416
+ @crypto::Cipher.new(hello_key).decrypt(hello_nonce, hello_box_data, aad: "HELLO")
417
+ rescue @crypto::CryptoError
418
+ raise Error, "HELLO decryption failed"
419
+ end
420
+
421
+
422
+ # h1 = H(h0 || HELLO_wire_bytes)
423
+ h = hash(h + hello_frame.to_wire)
424
+
425
+ # --- WELCOME ---
426
+ sn_secret = @crypto::PrivateKey.generate
427
+ sn_public = sn_secret.public_key
428
+
429
+ # Cookie carries C' || s' || h1. Putting h2 in the cookie
430
+ # would be Catch-22 — h2 = H(h1 || welcome_wire) and
431
+ # welcome_wire embeds the cookie itself. By carrying h1
432
+ # instead, the server can reconstruct welcome_wire on
433
+ # INITIATE (chacha20-blake3 with fixed key+nonce+plaintext
434
+ # is deterministic) and recompute h2 = H(h1 || reconstructed)
435
+ # without holding any per-connection state across WELCOME.
436
+ cookie_nonce = @crypto.random_bytes(NONCE_SIZE)
437
+ cookie_key = kdf("#{PROTOCOL_ID} cookie", @cookie_key)
438
+ cookie_box = @crypto::Cipher.new(cookie_key).encrypt(
439
+ cookie_nonce, cn_public.to_s + sn_secret.to_s + h, aad: "COOKIE"
440
+ )
441
+ cookie = cookie_nonce + cookie_box # 24 + 96 + 32(tag) = 152 bytes
442
+
443
+ # Welcome box: encrypt S' || cookie
444
+ welcome_key = kdf("#{PROTOCOL_ID} WELCOME key", dh1)
445
+ welcome_nonce = kdf24("#{PROTOCOL_ID} WELCOME nonce", h)
446
+ welcome_box = @crypto::Cipher.new(welcome_key).encrypt(
447
+ welcome_nonce, sn_public.to_s + cookie, aad: "WELCOME"
448
+ )
449
+
450
+ welcome = "".b
451
+ welcome << "\x07WELCOME"
452
+ welcome << welcome_box
453
+
454
+ welcome_wire = Codec::Frame.new(welcome, command: true).to_wire
455
+ io.write(welcome_wire)
456
+ io.flush
457
+
458
+ # h2 = H(h1 || WELCOME_wire_bytes)
459
+ h = hash(h + welcome_wire)
460
+
461
+ # Server is stateless here — discard sn_secret.
462
+ # In practice we keep it for the test/simple path;
463
+ # a high-performance server would rely solely on the cookie.
464
+
465
+ # --- Read INITIATE ---
466
+ init_frame = Codec::Frame.read_from(io)
467
+ raise Error, "expected command frame" unless init_frame.command?
468
+ init_cmd = Codec::Command.from_body(init_frame.body)
469
+ raise Error, "expected INITIATE, got #{init_cmd.name}" unless init_cmd.name == "INITIATE"
470
+
471
+ idata = init_cmd.data
472
+ raise Error, "INITIATE too short" if idata.bytesize < 152 + TAG_SIZE
473
+
474
+ # Decrypt cookie to recover C' || s' || h1
475
+ recv_cookie = idata.byteslice(0, 152)
476
+ recv_cookie_nonce = recv_cookie.byteslice(0, NONCE_SIZE)
477
+ recv_cookie_box = recv_cookie.byteslice(NONCE_SIZE..)
478
+
479
+ begin
480
+ cookie_plain = @crypto::Cipher.new(cookie_key).decrypt(
481
+ recv_cookie_nonce, recv_cookie_box, aad: "COOKIE"
482
+ )
483
+ rescue @crypto::CryptoError
484
+ raise Error, "INITIATE cookie verification failed"
485
+ end
486
+
487
+ raise Error, "cookie plaintext wrong size" unless cookie_plain.bytesize == 96
488
+ cn_public = @crypto::PublicKey.new(cookie_plain.byteslice(0, KEY_SIZE))
489
+ sn_secret = @crypto::PrivateKey.new(cookie_plain.byteslice(KEY_SIZE, KEY_SIZE))
490
+ recovered_h1 = cookie_plain.byteslice(2 * KEY_SIZE, 32)
491
+
492
+ # Recompute h2 from the cookie's h1 + a deterministic
493
+ # reconstruction of welcome_wire. chacha20-blake3 with the
494
+ # same (key, nonce, plaintext, aad) yields the same
495
+ # ciphertext+tag, so we get back the exact bytes we sent.
496
+ recovered_dh1 = @permanent_secret.diffie_hellman(cn_public)
497
+ validate_dh!(recovered_dh1, "dh1 (cookie path)")
498
+ recovered_welcome_key = kdf("#{PROTOCOL_ID} WELCOME key", recovered_dh1)
499
+ recovered_welcome_nonce = kdf24("#{PROTOCOL_ID} WELCOME nonce", recovered_h1)
500
+ sn_public_bytes = sn_secret.public_key.to_s
501
+ recovered_welcome_box = @crypto::Cipher.new(recovered_welcome_key).encrypt(
502
+ recovered_welcome_nonce,
503
+ sn_public_bytes + recv_cookie,
504
+ aad: "WELCOME"
505
+ )
506
+ recovered_welcome = "".b
507
+ recovered_welcome << "\x07WELCOME" << recovered_welcome_box
508
+ recovered_welcome_wire = Codec::Frame.new(recovered_welcome, command: true).to_wire
509
+ h_recovered = hash(recovered_h1 + recovered_welcome_wire)
510
+ # Whether we trust our retained `h` or the recovered one
511
+ # depends on whether the implementation actually went stateless.
512
+ # Either should match; assert for safety in the test/simple
513
+ # path, then proceed with `h` either way. Truly stateless
514
+ # implementations would replace `h` with `h_recovered` here.
515
+ h = h_recovered
516
+
517
+ dh2 = sn_secret.diffie_hellman(cn_public)
518
+ validate_dh!(dh2, "dh2")
519
+
520
+ initiate_key = kdf("#{PROTOCOL_ID} INITIATE key", dh2 + h)
521
+ initiate_nonce = kdf24("#{PROTOCOL_ID} INITIATE nonce", dh2 + h)
522
+
523
+ initiate_ciphertext = idata.byteslice(152..)
524
+ begin
525
+ initiate_plain = @crypto::Cipher.new(initiate_key).decrypt(
526
+ initiate_nonce, initiate_ciphertext, aad: "INITIATE"
527
+ )
528
+ rescue @crypto::CryptoError
529
+ raise Error, "INITIATE decryption failed"
530
+ end
531
+
532
+
533
+ # Always parse C(32) + vouch_box(96) + metadata
534
+ raise Error, "INITIATE plaintext too short" if initiate_plain.bytesize < KEY_SIZE + 96
535
+
536
+ client_permanent = @crypto::PublicKey.new(initiate_plain.byteslice(0, KEY_SIZE))
537
+ vouch_box = initiate_plain.byteslice(KEY_SIZE, 96)
538
+ metadata_bytes = initiate_plain.byteslice(KEY_SIZE + 96..) || "".b
539
+
540
+ # Verify vouch
541
+ dh3 = sn_secret.diffie_hellman(client_permanent)
542
+ validate_dh!(dh3, "dh3")
543
+ vouch_key = kdf("#{PROTOCOL_ID} VOUCH key", dh3)
544
+ vouch_nonce = kdf24("#{PROTOCOL_ID} VOUCH nonce", dh3)
545
+ begin
546
+ vouch_plain = @crypto::Cipher.new(vouch_key).decrypt(vouch_nonce, vouch_box, aad: "VOUCH")
547
+ rescue @crypto::CryptoError
548
+ raise Error, "INITIATE vouch verification failed"
549
+ end
550
+
551
+ raise Error, "vouch wrong size" unless vouch_plain.bytesize == 64
552
+ vouch_cn = vouch_plain.byteslice(0, KEY_SIZE)
553
+ vouch_server = vouch_plain.byteslice(KEY_SIZE, KEY_SIZE)
554
+
555
+ unless vouch_cn == cn_public.to_s
556
+ raise Error, "vouch client transient key mismatch"
557
+ end
558
+ unless vouch_server == @permanent_public.to_s
559
+ raise Error, "vouch server key mismatch"
560
+ end
561
+
562
+ if @authenticator
563
+ # Authenticator runs against the long-term public key; the
564
+ # identity (a runtime metadata field) is parsed later from
565
+ # initiate_plain. Pass an empty identity, matching the
566
+ # CURVE mechanism's authenticator-side convention.
567
+ peer = PeerInfo.new(public_key: client_permanent, identity: "")
568
+ unless @authenticator.call(peer)
569
+ send_error(io, "client key not authorized")
570
+ raise Error, "client key not authorized"
571
+ end
572
+ end
573
+
574
+
575
+ # h3 = H(h2 || INITIATE_wire_bytes)
576
+ h = hash(h + init_frame.to_wire)
577
+
578
+ # --- READY ---
579
+ ready_props = { "Socket-Type" => socket_type, "Identity" => identity }
580
+ ready_props.merge!(metadata) if metadata && !metadata.empty?
581
+ ready_metadata = Codec::Command.encode_properties(ready_props)
582
+
583
+ ready_key = kdf("#{PROTOCOL_ID} READY key", dh2 + h)
584
+ ready_nonce = kdf24("#{PROTOCOL_ID} READY nonce", dh2 + h)
585
+ ready_box = @crypto::Cipher.new(ready_key).encrypt(ready_nonce, ready_metadata, aad: "READY")
586
+
587
+ ready = "".b
588
+ ready << "\x05READY"
589
+ ready << ready_box
590
+
591
+ ready_wire = Codec::Frame.new(ready, command: true).to_wire
592
+ io.write(ready_wire)
593
+ io.flush
594
+
595
+ # h4 = H(h3 || READY_wire_bytes)
596
+ h = hash(h + ready_wire)
597
+
598
+ props = Codec::Command.decode_properties(metadata_bytes)
599
+
600
+ derive_session_keys!(h, dh2, as_client: false)
601
+
602
+ {
603
+ peer_socket_type: props["Socket-Type"],
604
+ peer_identity: props["Identity"] || "",
605
+ peer_properties: props,
606
+ peer_major: peer_greeting[:major],
607
+ peer_minor: peer_greeting[:minor],
608
+ }
609
+ end
610
+
611
+
612
+ # ----------------------------------------------------------------
613
+ # Session key derivation
614
+ # ----------------------------------------------------------------
615
+
616
+ def derive_session_keys!(h4, dh2, as_client:)
617
+ ikm = h4 + dh2
618
+
619
+ c2s_key = kdf("#{PROTOCOL_ID} client->server key", ikm)
620
+ c2s_nonce = kdf24("#{PROTOCOL_ID} client->server nonce", ikm)
621
+ s2c_key = kdf("#{PROTOCOL_ID} server->client key", ikm)
622
+ s2c_nonce = kdf24("#{PROTOCOL_ID} server->client nonce", ikm)
623
+
624
+ if as_client
625
+ @send_stream = @crypto::Stream.new(c2s_key, c2s_nonce)
626
+ @recv_stream = @crypto::Stream.new(s2c_key, s2c_nonce)
627
+ else
628
+ @send_stream = @crypto::Stream.new(s2c_key, s2c_nonce)
629
+ @recv_stream = @crypto::Stream.new(c2s_key, c2s_nonce)
630
+ end
631
+ end
632
+
633
+
634
+ # ----------------------------------------------------------------
635
+ # Crypto helpers
636
+ # ----------------------------------------------------------------
637
+
638
+ def hash(input)
639
+ @crypto::Hash.digest(input)
640
+ end
641
+
642
+
643
+ def kdf(context, material)
644
+ @crypto::Hash.derive_key(context, material)
645
+ end
646
+
647
+
648
+ def kdf24(context, material)
649
+ @crypto::Hash.derive_key(context, material, size: NONCE_SIZE)
650
+ end
651
+
652
+
653
+ def send_error(io, reason)
654
+ error_body = "".b
655
+ error_body << "\x05ERROR"
656
+ error_body << reason.bytesize.chr << reason.b
657
+ io.write(Codec::Frame.new(error_body, command: true).to_wire)
658
+ io.flush
659
+ rescue IOError
660
+ # connection may already be broken
661
+ end
662
+
663
+
664
+ def validate_key!(key, name)
665
+ if key.nil?
666
+ raise ArgumentError, "#{name} is required"
667
+ end
668
+
669
+ unless key.b.bytesize == KEY_SIZE
670
+ raise ArgumentError, "#{name} must be 32 bytes (got #{key.b.bytesize})"
671
+ end
672
+ end
673
+
674
+
675
+ ZERO_DH = ("\x00" * KEY_SIZE).b.freeze
676
+
677
+
678
+ def validate_dh!(shared_secret, label)
679
+ if shared_secret == ZERO_DH
680
+ raise Error, "#{label} produced all-zero output (low-order point)"
681
+ end
682
+ end
683
+
684
+ end
685
+ end
686
+ end
687
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ # BLAKE3-based encryption mechanism for ZMTP connections.
5
+ module Blake3ZMQ
6
+ VERSION = "0.3.0"
7
+ end
8
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # BLAKE3ZMQ security mechanism for OMQ.
4
+ #
5
+ # Usage:
6
+ # require "omq/blake3zmq"
7
+
8
+ require "protocol/zmtp"
9
+
10
+ require_relative "blake3zmq/version"
11
+ require_relative "blake3zmq/crypto"
12
+ require_relative "blake3zmq/mechanism"
metadata ADDED
@@ -0,0 +1,103 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: omq-blake3zmq
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.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
+ - !ruby/object:Gem::Dependency
13
+ name: protocol-zmtp
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: blake3-rb
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '1.8'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '1.8'
40
+ - !ruby/object:Gem::Dependency
41
+ name: chacha20blake3
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0.2'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0.2'
54
+ - !ruby/object:Gem::Dependency
55
+ name: x25519
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '1.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '1.0'
68
+ description: BLAKE3ZMQ security mechanism (X25519 + ChaCha20-BLAKE3 AEAD) for the
69
+ OMQ pure-Ruby ZeroMQ library.
70
+ email:
71
+ - paddor@gmail.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - LICENSE
77
+ - README.md
78
+ - lib/omq/blake3zmq.rb
79
+ - lib/omq/blake3zmq/crypto.rb
80
+ - lib/omq/blake3zmq/mechanism.rb
81
+ - lib/omq/blake3zmq/version.rb
82
+ homepage: https://github.com/paddor/omq-blake3zmq
83
+ licenses:
84
+ - ISC
85
+ metadata: {}
86
+ rdoc_options: []
87
+ require_paths:
88
+ - lib
89
+ required_ruby_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '3.3'
94
+ required_rubygems_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '0'
99
+ requirements: []
100
+ rubygems_version: 4.0.10
101
+ specification_version: 4
102
+ summary: BLAKE3ZMQ security mechanism for OMQ
103
+ test_files: []