omq-curve 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: 48930d7cc53900ad0b80f8a1cbf67527d54f344b2007862ff7e4dfae3355c9d2
4
+ data.tar.gz: 63ac0a03357e5a25c105dcef908ab305a14ad82a6e722dd6e6af79dde4b1718f
5
+ SHA512:
6
+ metadata.gz: 006b7c5beb7168df20aa59d67e08f9ab17bd7ac26d46882582dd64d6641325e91e32bfebf09280e81912c2ff91bab0c043a28cf12009e636e2fa1427c1a45fff
7
+ data.tar.gz: e99ea6004f4a3dc693ae9792cd9be5420a1b059a07a5da79b736aae002a9d657378e0fe30c2754942c55d81654563d4084f176060660cd92a5814b5ba59b4baf
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-03-25
4
+
5
+ Initial release. CurveZMQ (RFC 26) encryption for OMQ.
6
+
7
+ - Curve25519-XSalsa20-Poly1305 encryption and authentication
8
+ - 4-step handshake (HELLO/WELCOME/INITIATE/READY)
9
+ - Anti-amplification and server statelessness per RFC 26
10
+ - Client authentication via allowlist or custom callable
11
+ - Z85 key encoding/decoding
12
+ - Requires libsodium via rbnacl
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,284 @@
1
+ # OMQ-CURVE
2
+
3
+ CurveZMQ ([RFC 26](https://rfc.zeromq.org/spec/26/)) encryption for [OMQ](https://github.com/paddor/omq). Adds Curve25519 authenticated encryption to any OMQ socket over tcp or ipc.
4
+
5
+ Interoperates with libzmq, CZMQ, pyzmq, and any other ZMTP 3.1 peer that speaks CURVE.
6
+
7
+ ## Install
8
+
9
+ Requires libsodium on the system (the `rbnacl` gem calls it via FFI).
10
+
11
+ ```sh
12
+ # Debian/Ubuntu
13
+ sudo apt install libsodium-dev
14
+
15
+ # macOS
16
+ brew install libsodium
17
+ ```
18
+
19
+ ```sh
20
+ gem install omq-curve
21
+ # or in Gemfile
22
+ gem 'omq-curve'
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ```ruby
28
+ require 'omq/curve'
29
+ require 'async'
30
+
31
+ # Generate keypairs (once, store securely)
32
+ server_key = RbNaCl::PrivateKey.generate
33
+ client_key = RbNaCl::PrivateKey.generate
34
+
35
+ Async do |task|
36
+ # --- Server ---
37
+ rep = OMQ::REP.new
38
+ rep.mechanism = :curve
39
+ rep.curve_server = true
40
+ rep.curve_public_key = server_key.public_key.to_s
41
+ rep.curve_secret_key = server_key.to_s
42
+ rep.bind('tcp://*:5555')
43
+
44
+ task.async do
45
+ msg = rep.receive
46
+ rep << msg.map(&:upcase)
47
+ end
48
+
49
+ # --- Client ---
50
+ req = OMQ::REQ.new
51
+ req.mechanism = :curve
52
+ req.curve_server_key = server_key.public_key.to_s # must know server's public key
53
+ req.curve_public_key = client_key.public_key.to_s
54
+ req.curve_secret_key = client_key.to_s
55
+ req.connect('tcp://localhost:5555')
56
+
57
+ req << 'hello'
58
+ puts req.receive.inspect # => ["HELLO"]
59
+ ensure
60
+ req&.close
61
+ rep&.close
62
+ end
63
+ ```
64
+
65
+ ## Key Generation
66
+
67
+ Keys are 32-byte Curve25519 keypairs. Generate them with rbnacl:
68
+
69
+ ```ruby
70
+ require 'omq/curve'
71
+
72
+ key = RbNaCl::PrivateKey.generate
73
+
74
+ # Binary (32 bytes each) — use for socket options
75
+ key.public_key.to_s # => "\xCC\xA9\x9F..." (32 bytes)
76
+ key.to_s # => "\xAE\x8E\xC4..." (32 bytes)
77
+ ```
78
+
79
+ ### Persisting keys
80
+
81
+ Never store secret keys in plaintext in source control. Options:
82
+
83
+ ```ruby
84
+ # Environment variables (hex-encoded)
85
+ ENV['OMQ_SERVER_SECRET'] = RbNaCl::Util.bin2hex(key.to_s)
86
+ # ... later ...
87
+ secret = RbNaCl::Util.hex2bin(ENV.fetch('OMQ_SERVER_SECRET'))
88
+
89
+ # Or use Z85 (ZeroMQ's printable encoding, 40 chars for 32 bytes)
90
+ z85_public = OMQ::Z85.encode(key.public_key.to_s) # => "rq5+e..." (40 chars)
91
+ z85_secret = OMQ::Z85.encode(key.to_s)
92
+
93
+ # Decode back to binary
94
+ OMQ::Z85.decode(z85_public) # => 32-byte binary string
95
+ ```
96
+
97
+ ### Key file convention
98
+
99
+ A simple pattern for file-based key storage:
100
+
101
+ ```ruby
102
+ # Generate and save (once)
103
+ key = RbNaCl::PrivateKey.generate
104
+ File.write('server.key', OMQ::Z85.encode(key.to_s), perm: 0o600)
105
+ File.write('server.pub', OMQ::Z85.encode(key.public_key.to_s))
106
+
107
+ # Load
108
+ secret = OMQ::Z85.decode(File.read('server.key'))
109
+ public = OMQ::Z85.decode(File.read('server.pub'))
110
+ ```
111
+
112
+ ## Z85 Encoding
113
+
114
+ [Z85](https://rfc.zeromq.org/spec/32/) is ZeroMQ's printable encoding for binary keys. It uses an 85-character alphabet and produces 40 characters for a 32-byte key — safe for config files, environment variables, and CLI arguments.
115
+
116
+ ```ruby
117
+ binary = RbNaCl::Random.random_bytes(32)
118
+ z85 = OMQ::Z85.encode(binary) # => 40-char ASCII string
119
+ binary = OMQ::Z85.decode(z85) # => 32-byte binary string
120
+ ```
121
+
122
+ Z85 keys are compatible with libzmq's `zmq_curve_keypair()` output and tools like `curve_keygen`.
123
+
124
+ ## Socket Options
125
+
126
+ | Option | Type | Description |
127
+ |--------|------|-------------|
128
+ | `mechanism` | `:null`, `:curve` | Security mechanism (default `:null`) |
129
+ | `curve_server` | Boolean | `true` for the CURVE server side |
130
+ | `curve_public_key` | String (32 bytes) | Our permanent public key |
131
+ | `curve_secret_key` | String (32 bytes) | Our permanent secret key |
132
+ | `curve_server_key` | String (32 bytes) | Server's public key (clients only) |
133
+ | `curve_authenticator` | Set, `#call`, nil | Client key authenticator (server only, see below) |
134
+
135
+ Set options before `bind`/`connect`:
136
+
137
+ ```ruby
138
+ sock = OMQ::REP.new
139
+ sock.mechanism = :curve
140
+ sock.curve_server = true
141
+ sock.curve_public_key = public_key
142
+ sock.curve_secret_key = secret_key
143
+ sock.bind('tcp://*:5555')
144
+ ```
145
+
146
+ ## Client vs Server
147
+
148
+ In CURVE, "server" and "client" refer to the **cryptographic roles**, not the network topology. The CURVE server is the side that clients authenticate against.
149
+
150
+ - **CURVE server**: has a well-known public key that clients must know in advance. Typically the `bind` side, but not necessarily.
151
+ - **CURVE client**: knows the server's public key and proves its own identity during the handshake.
152
+
153
+ Any socket type can be either the CURVE server or client:
154
+
155
+ ```ruby
156
+ # ROUTER as CURVE server (typical)
157
+ router = OMQ::ROUTER.new
158
+ router.mechanism = :curve
159
+ router.curve_server = true
160
+ # ...
161
+
162
+ # PUB as CURVE server
163
+ pub = OMQ::PUB.new
164
+ pub.mechanism = :curve
165
+ pub.curve_server = true
166
+ # ...
167
+ ```
168
+
169
+ ## Authentication
170
+
171
+ By default, any client that knows the server's public key can connect. Use `curve_authenticator` to restrict access.
172
+
173
+ ### Allowlist (Set of keys)
174
+
175
+ ```ruby
176
+ allowed = Set[client1_pub, client2_pub]
177
+
178
+ rep = OMQ::REP.new
179
+ rep.mechanism = :curve
180
+ rep.curve_server = true
181
+ rep.curve_public_key = server_pub
182
+ rep.curve_secret_key = server_sec
183
+ rep.curve_authenticator = allowed
184
+ rep.bind('tcp://*:5555')
185
+ ```
186
+
187
+ Unauthorized clients are disconnected during the handshake — no READY is sent and no messages are exchanged.
188
+
189
+ ### Custom authenticator (callable)
190
+
191
+ For dynamic lookups, logging, or rate limiting, pass anything that responds to `#call`:
192
+
193
+ ```ruby
194
+ rep.curve_authenticator = ->(client_public_key) {
195
+ # client_public_key is a 32-byte binary string
196
+ db_lookup(client_public_key) || false
197
+ }
198
+ ```
199
+
200
+ Return truthy to allow, falsy to reject. The authenticator runs during the CURVE handshake (after vouch verification, before READY), so rejected clients never reach the application layer.
201
+
202
+ ### Loading keys from files
203
+
204
+ ```ruby
205
+ allowed = Set.new(
206
+ Dir['keys/clients/*.pub'].map { |f| OMQ::Z85.decode(File.read(f)) }
207
+ )
208
+ rep.curve_authenticator = allowed
209
+ ```
210
+
211
+ ### Note on ZAP
212
+
213
+ libzmq uses [ZAP (RFC 27)](https://rfc.zeromq.org/spec/27/) for authentication — an inproc REQ/REP protocol between the socket and a ZAP handler. OMQ skips this indirection and lets you pass the authenticator directly. The effect is the same: client keys are checked during the handshake.
214
+
215
+ ## Managing Many Keys
216
+
217
+ ### One keypair per service
218
+
219
+ The simplest model: each service has one keypair. Clients are configured with the server's public key. Key rotation means deploying a new keypair and updating all clients.
220
+
221
+ ```ruby
222
+ # config/keys.yml (public keys only — safe to commit)
223
+ api_gateway: "rq5+eJ..."
224
+ worker_pool: "x8Kn2P..."
225
+ ```
226
+
227
+ ### Per-client keys with a key store
228
+
229
+ For finer-grained access control, give each client its own keypair and maintain a server-side allowlist:
230
+
231
+ ```ruby
232
+ # Server-side key store (flat file, database, vault, etc.)
233
+ ALLOWED_CLIENTS = Set.new(
234
+ File.readlines('authorized_keys.txt', chomp: true)
235
+ .map { |z85| OMQ::Z85.decode(z85) }
236
+ )
237
+
238
+ rep.curve_authenticator = ALLOWED_CLIENTS
239
+ ```
240
+
241
+ ### Key rotation
242
+
243
+ CURVE's perfect forward secrecy means rotating the permanent keypair doesn't compromise past traffic — each connection uses ephemeral session keys that are destroyed on disconnect.
244
+
245
+ To rotate a server key:
246
+
247
+ 1. Generate a new keypair
248
+ 2. Configure the server with the new key
249
+ 3. Update clients with the new server public key
250
+ 4. Restart — new connections use the new key, existing connections continue with the old session keys until they disconnect
251
+
252
+ ## Performance
253
+
254
+ CURVE adds ~70% latency overhead and ~35–40% throughput cost compared to NULL, dominated by libsodium FFI call overhead. See [bench/README.md](bench/README.md) for full results.
255
+
256
+ | | NULL | CURVE |
257
+ |---|---|---|
258
+ | Latency (ipc) | 113 µs | 195 µs |
259
+ | Throughput (ipc, 64 B) | 25.5k/s | 16.4k/s |
260
+
261
+ ## Interoperability
262
+
263
+ OMQ-CURVE interoperates with any ZMTP 3.1 CURVE implementation. Verified against libzmq 4.3.5 via CZTop in both directions (OMQ↔libzmq) with REQ/REP and DEALER/ROUTER.
264
+
265
+ ## How It Works
266
+
267
+ The [CurveZMQ](http://curvezmq.org/) handshake establishes a secure session in 4 steps:
268
+
269
+ 1. **HELLO** — client sends its transient public key + proof it knows the server's key
270
+ 2. **WELCOME** — server sends its transient public key in an encrypted cookie
271
+ 3. **INITIATE** — client echoes the cookie + proves its permanent identity via a vouch
272
+ 4. **READY** — server confirms, both sides have session keys
273
+
274
+ After the handshake, every ZMTP frame is encrypted as a CurveZMQ MESSAGE using Curve25519-XSalsa20-Poly1305 with strictly incrementing nonces.
275
+
276
+ Properties:
277
+ - **Perfect forward secrecy** — compromising permanent keys doesn't reveal past traffic
278
+ - **Server statelessness** — between WELCOME and INITIATE, the server holds no per-connection state (cookie-based recovery)
279
+ - **Anti-amplification** — HELLO (200 bytes) > WELCOME (168 bytes)
280
+ - **Replay protection** — strictly incrementing nonces, verified on every message
281
+
282
+ ## License
283
+
284
+ [ISC](../LICENSE)
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module Curve
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/omq/curve.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "omq"
4
+ require "rbnacl"
5
+
6
+ require_relative "curve/version"
7
+ require_relative "z85"
8
+ require_relative "zmtp/mechanism/curve"
data/lib/omq/z85.rb ADDED
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ # Z85 encoding/decoding (ZeroMQ RFC 32).
5
+ #
6
+ # Encodes binary data in printable ASCII using an 85-character alphabet.
7
+ # Input length must be a multiple of 4 bytes; output is 5/4 the size.
8
+ #
9
+ module Z85
10
+ CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-:+=^!/*?&<>()[]{}@%$#".freeze
11
+ DECODE = Array.new(128, -1)
12
+ CHARS.each_byte.with_index { |b, i| DECODE[b] = i }
13
+ DECODE.freeze
14
+
15
+ BASE = 85
16
+
17
+ # Encodes binary data to a Z85 string.
18
+ #
19
+ # @param data [String] binary data (length must be multiple of 4)
20
+ # @return [String] Z85-encoded ASCII string
21
+ # @raise [ArgumentError] if length is not a multiple of 4
22
+ #
23
+ def self.encode(data)
24
+ data = data.b
25
+ raise ArgumentError, "data length must be a multiple of 4 (got #{data.bytesize})" unless (data.bytesize % 4).zero?
26
+
27
+ out = String.new(capacity: data.bytesize * 5 / 4)
28
+ i = 0
29
+ while i < data.bytesize
30
+ # Read 4 bytes as a big-endian 32-bit unsigned integer
31
+ value = data.getbyte(i) << 24 | data.getbyte(i + 1) << 16 |
32
+ data.getbyte(i + 2) << 8 | data.getbyte(i + 3)
33
+ # Encode as 5 Z85 characters (most significant first)
34
+ 4.downto(0) do |j|
35
+ out << CHARS[(value / (BASE**j)) % BASE]
36
+ end
37
+ i += 4
38
+ end
39
+ out
40
+ end
41
+
42
+ # Decodes a Z85 string to binary data.
43
+ #
44
+ # @param string [String] Z85-encoded ASCII string (length must be multiple of 5)
45
+ # @return [String] binary data
46
+ # @raise [ArgumentError] if length is not a multiple of 5 or contains invalid characters
47
+ #
48
+ def self.decode(string)
49
+ raise ArgumentError, "string length must be a multiple of 5 (got #{string.bytesize})" unless (string.bytesize % 5).zero?
50
+
51
+ out = String.new(capacity: string.bytesize * 4 / 5, encoding: Encoding::BINARY)
52
+ i = 0
53
+ while i < string.bytesize
54
+ value = 0
55
+ 5.times do |j|
56
+ byte = string.getbyte(i + j)
57
+ d = byte < 128 ? DECODE[byte] : -1
58
+ raise ArgumentError, "invalid Z85 character: #{string[i + j].inspect}" if d == -1
59
+ value = value * BASE + d
60
+ end
61
+ out << ((value >> 24) & 0xFF).chr
62
+ out << ((value >> 16) & 0xFF).chr
63
+ out << ((value >> 8) & 0xFF).chr
64
+ out << (value & 0xFF).chr
65
+ i += 5
66
+ end
67
+ out
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,467 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
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 using the RbNaCl gem.
10
+ #
11
+ # After the 4-step handshake (HELLO/WELCOME/INITIATE/READY), all
12
+ # frames are encrypted as CurveZMQ MESSAGE commands using the
13
+ # transient session keys.
14
+ #
15
+ # DoS resistance (per RFC 26):
16
+ # - Anti-amplification: HELLO (200 bytes) > WELCOME (168 bytes)
17
+ # - Server statelessness: after sending WELCOME the server forgets
18
+ # all per-connection state. On INITIATE, it recovers cn_public and
19
+ # sn_secret from the cookie (which precedes the encrypted box in
20
+ # cleartext). Only the socket-wide @cookie_key is needed.
21
+ # - Cookie verification prevents replay of stale INITIATEs.
22
+ #
23
+ class Curve
24
+ MECHANISM_NAME = "CURVE"
25
+
26
+ # Nonce prefixes. Most are 16 bytes (prefix + 8-byte counter on wire).
27
+ # WELCOME, COOKIE, and VOUCH use 8-byte prefixes with 16-byte random nonces.
28
+ NONCE_PREFIX_HELLO = "CurveZMQHELLO---" # 16 + 8
29
+ NONCE_PREFIX_WELCOME = "WELCOME-" # 8 + 16
30
+ NONCE_PREFIX_INITIATE = "CurveZMQINITIATE" # 16 + 8
31
+ NONCE_PREFIX_READY = "CurveZMQREADY---" # 16 + 8
32
+ NONCE_PREFIX_MESSAGE_C = "CurveZMQMESSAGEC" # 16 + 8, client → server
33
+ NONCE_PREFIX_MESSAGE_S = "CurveZMQMESSAGES" # 16 + 8, server → client
34
+ NONCE_PREFIX_VOUCH = "VOUCH---" # 8 + 16
35
+ NONCE_PREFIX_COOKIE = "COOKIE--" # 8 + 16
36
+
37
+ # Crypto overhead: 16 bytes Poly1305 authenticator
38
+ BOX_OVERHEAD = 16
39
+
40
+ # Maximum nonce value (2^64 - 1). Exceeding this would reuse nonces.
41
+ MAX_NONCE = (2**64) - 1
42
+
43
+ # @param public_key [String] our permanent public key (32 bytes)
44
+ # @param secret_key [String] our permanent secret key (32 bytes)
45
+ # @param as_server [Boolean] whether we are the CURVE server
46
+ # @param server_key [String, nil] server's permanent public key (32 bytes, required for clients)
47
+ # @param authenticator [#include?, #call, nil] client key authenticator (server only).
48
+ # Set/Array → checked via #include?. Proc/lambda → called with the 32-byte
49
+ # client public key, must return truthy to allow. nil → allow all.
50
+ #
51
+ def initialize(server_key: nil, public_key:, secret_key:, as_server: false, authenticator: nil)
52
+ validate_key!(public_key, "public_key")
53
+ validate_key!(secret_key, "secret_key")
54
+
55
+ @permanent_public = RbNaCl::PublicKey.new(public_key.b)
56
+ @permanent_secret = RbNaCl::PrivateKey.new(secret_key.b)
57
+ @as_server = as_server
58
+ @authenticator = authenticator
59
+
60
+ if as_server
61
+ # One cookie key per socket — enables server statelessness per-connection
62
+ @cookie_key = RbNaCl::Random.random_bytes(32)
63
+ else
64
+ validate_key!(server_key, "server_key")
65
+ @server_public = RbNaCl::PublicKey.new(server_key.b)
66
+ end
67
+
68
+ # Session state (set during handshake)
69
+ @session_box = nil # RbNaCl::Box for MESSAGE encryption
70
+ @send_nonce = 0 # outgoing MESSAGE nonce counter
71
+ @recv_nonce = -1 # last received MESSAGE nonce (for replay detection)
72
+ end
73
+
74
+ # @return [Boolean] true — CURVE encrypts all post-handshake frames
75
+ #
76
+ def encrypted? = true
77
+
78
+ # Performs the CurveZMQ handshake.
79
+ #
80
+ # @param io [#read, #write] transport IO
81
+ # @param as_server [Boolean] (unused — tracked via @as_server)
82
+ # @param socket_type [String]
83
+ # @param identity [String]
84
+ # @return [Hash] { peer_socket_type:, peer_identity: }
85
+ # @raise [ProtocolError]
86
+ #
87
+ def handshake!(io, as_server:, socket_type:, identity:)
88
+ if @as_server
89
+ server_handshake!(io, socket_type: socket_type, identity: identity)
90
+ else
91
+ client_handshake!(io, socket_type: socket_type, identity: identity)
92
+ end
93
+ end
94
+
95
+ # Encrypts a frame body as a CurveZMQ MESSAGE command.
96
+ #
97
+ # The MESSAGE plaintext is: flags_byte + body.
98
+ # This replaces ZMTP framing — there is no ZMTP frame header inside.
99
+ #
100
+ # @param body [String] frame body
101
+ # @param more [Boolean] MORE flag
102
+ # @param command [Boolean] COMMAND flag
103
+ # @return [String] MESSAGE command frame wire bytes (ready to write)
104
+ #
105
+ def encrypt(body, more: false, command: false)
106
+ flags = 0
107
+ flags |= 0x01 if more
108
+ flags |= 0x04 if command
109
+ plaintext = flags.chr.b + body.b
110
+ nonce = make_send_nonce
111
+ ciphertext = @session_box.encrypt(nonce, plaintext)
112
+ short_nonce = nonce.byteslice(16, 8)
113
+
114
+ msg_body = "\x07MESSAGE".b + short_nonce + ciphertext
115
+ Codec::Frame.new(msg_body).to_wire
116
+ end
117
+
118
+ # Decrypts a CurveZMQ MESSAGE command into a Frame.
119
+ #
120
+ # @param frame [Codec::Frame] a command frame containing a MESSAGE
121
+ # @return [Codec::Frame] decrypted frame with flags and body
122
+ # @raise [ProtocolError] on decryption failure or nonce replay
123
+ #
124
+ def decrypt(frame)
125
+ cmd = Codec::Command.from_body(frame.body)
126
+ raise ProtocolError, "expected MESSAGE command, got #{cmd.name}" unless cmd.name == "MESSAGE"
127
+
128
+ data = cmd.data
129
+ raise ProtocolError, "MESSAGE too short" if data.bytesize < 8 + BOX_OVERHEAD
130
+
131
+ short_nonce = data.byteslice(0, 8)
132
+ ciphertext = data.byteslice(8..)
133
+
134
+ # Verify strictly incrementing nonce
135
+ nonce_value = short_nonce.unpack1("Q>")
136
+ unless nonce_value > @recv_nonce
137
+ raise ProtocolError, "MESSAGE nonce not strictly incrementing"
138
+ end
139
+ @recv_nonce = nonce_value
140
+
141
+ nonce = recv_nonce_prefix + short_nonce
142
+ begin
143
+ plaintext = @session_box.decrypt(nonce, ciphertext)
144
+ rescue RbNaCl::CryptoError
145
+ raise ProtocolError, "MESSAGE decryption failed"
146
+ end
147
+
148
+ flags = plaintext.getbyte(0)
149
+ body = plaintext.byteslice(1..) || "".b
150
+ Codec::Frame.new(body, more: (flags & 0x01) != 0, command: (flags & 0x04) != 0)
151
+ end
152
+
153
+ private
154
+
155
+ # ----------------------------------------------------------------
156
+ # Client-side handshake
157
+ # ----------------------------------------------------------------
158
+
159
+ def client_handshake!(io, socket_type:, identity:)
160
+ cn_secret = RbNaCl::PrivateKey.generate
161
+ cn_public = cn_secret.public_key
162
+
163
+ # --- Exchange greetings ---
164
+ io.write(Codec::Greeting.encode(mechanism: MECHANISM_NAME, as_server: false))
165
+ peer_greeting = Codec::Greeting.decode(io.read_exactly(Codec::Greeting::SIZE))
166
+ unless peer_greeting[:mechanism] == MECHANISM_NAME
167
+ raise ProtocolError, "expected CURVE mechanism, got #{peer_greeting[:mechanism]}"
168
+ end
169
+
170
+ # --- HELLO ---
171
+ short_nonce = [1].pack("Q>")
172
+ nonce = NONCE_PREFIX_HELLO + short_nonce
173
+ hello_box = RbNaCl::Box.new(@server_public, cn_secret)
174
+ signature = hello_box.encrypt(nonce, "\x00" * 64)
175
+
176
+ hello = "".b
177
+ hello << "\x05HELLO"
178
+ hello << "\x01\x00" # version 1.0
179
+ hello << ("\x00" * 72) # anti-amplification padding
180
+ hello << cn_public.to_s # 32 bytes
181
+ hello << short_nonce # 8 bytes
182
+ hello << signature # 80 bytes (64 + 16 MAC)
183
+
184
+ io.write(Codec::Frame.new(hello, command: true).to_wire)
185
+
186
+ # --- Read WELCOME ---
187
+ welcome_frame = Codec::Frame.read_from(io)
188
+ raise ProtocolError, "expected command frame" unless welcome_frame.command?
189
+ welcome_cmd = Codec::Command.from_body(welcome_frame.body)
190
+ raise ProtocolError, "expected WELCOME, got #{welcome_cmd.name}" unless welcome_cmd.name == "WELCOME"
191
+
192
+ wdata = welcome_cmd.data
193
+ # WELCOME: 16-byte random nonce + 144-byte box = 160 bytes
194
+ raise ProtocolError, "WELCOME wrong size" unless wdata.bytesize == 16 + 144
195
+
196
+ w_short_nonce = wdata.byteslice(0, 16)
197
+ w_box_data = wdata.byteslice(16, 144)
198
+ w_nonce = NONCE_PREFIX_WELCOME + w_short_nonce
199
+
200
+ # WELCOME box is encrypted from server permanent to client transient
201
+ begin
202
+ w_plaintext = RbNaCl::Box.new(@server_public, cn_secret).decrypt(w_nonce, w_box_data)
203
+ rescue RbNaCl::CryptoError
204
+ raise ProtocolError, "WELCOME decryption failed"
205
+ end
206
+
207
+ sn_public = RbNaCl::PublicKey.new(w_plaintext.byteslice(0, 32))
208
+ cookie = w_plaintext.byteslice(32, 96)
209
+
210
+ # Session box: client transient ↔ server transient
211
+ session = RbNaCl::Box.new(sn_public, cn_secret)
212
+
213
+ # --- INITIATE ---
214
+ # Per RFC 26, the cookie precedes the encrypted box in cleartext.
215
+ vouch_nonce = NONCE_PREFIX_VOUCH + RbNaCl::Random.random_bytes(16)
216
+ vouch_plaintext = cn_public.to_s + @server_public.to_s
217
+ vouch = RbNaCl::Box.new(sn_public, @permanent_secret).encrypt(vouch_nonce, vouch_plaintext)
218
+
219
+ metadata = Codec::Command.encode_properties(
220
+ "Socket-Type" => socket_type,
221
+ "Identity" => identity,
222
+ )
223
+
224
+ # Box contents per libzmq: client_permanent_pub + vouch_nonce_short + vouch + metadata
225
+ initiate_box_plaintext = "".b
226
+ initiate_box_plaintext << @permanent_public.to_s # 32 bytes
227
+ initiate_box_plaintext << vouch_nonce.byteslice(8, 16) # 16-byte short vouch nonce
228
+ initiate_box_plaintext << vouch # 80 bytes (64 + 16 MAC)
229
+ initiate_box_plaintext << metadata
230
+
231
+ init_short_nonce = [1].pack("Q>")
232
+ init_nonce = NONCE_PREFIX_INITIATE + init_short_nonce
233
+ init_ciphertext = session.encrypt(init_nonce, initiate_box_plaintext)
234
+
235
+ # Wire format: cookie (cleartext) + short_nonce + encrypted box
236
+ initiate = "".b
237
+ initiate << "\x08INITIATE"
238
+ initiate << cookie # 96 bytes, cleartext
239
+ initiate << init_short_nonce # 8 bytes
240
+ initiate << init_ciphertext
241
+
242
+ io.write(Codec::Frame.new(initiate, command: true).to_wire)
243
+
244
+ # --- Read READY ---
245
+ ready_frame = Codec::Frame.read_from(io)
246
+ raise ProtocolError, "expected command frame" unless ready_frame.command?
247
+ ready_cmd = Codec::Command.from_body(ready_frame.body)
248
+ raise ProtocolError, "expected READY, got #{ready_cmd.name}" unless ready_cmd.name == "READY"
249
+
250
+ rdata = ready_cmd.data
251
+ raise ProtocolError, "READY too short" if rdata.bytesize < 8 + BOX_OVERHEAD
252
+
253
+ r_short_nonce = rdata.byteslice(0, 8)
254
+ r_ciphertext = rdata.byteslice(8..)
255
+ r_nonce = NONCE_PREFIX_READY + r_short_nonce
256
+
257
+ begin
258
+ r_plaintext = session.decrypt(r_nonce, r_ciphertext)
259
+ rescue RbNaCl::CryptoError
260
+ raise ProtocolError, "READY decryption failed"
261
+ end
262
+
263
+ props = Codec::Command.decode_properties(r_plaintext)
264
+ peer_socket_type = props["Socket-Type"]
265
+ peer_identity = props["Identity"] || ""
266
+
267
+ @session_box = session
268
+ @send_nonce = 1 # READY consumed nonce 1
269
+ @recv_nonce = 0 # peer's READY consumed their nonce 1
270
+
271
+ { peer_socket_type: peer_socket_type, peer_identity: peer_identity }
272
+ end
273
+
274
+ # ----------------------------------------------------------------
275
+ # Server-side handshake
276
+ # ----------------------------------------------------------------
277
+
278
+ def server_handshake!(io, socket_type:, identity:)
279
+ # --- Exchange greetings ---
280
+ io.write(Codec::Greeting.encode(mechanism: MECHANISM_NAME, as_server: true))
281
+ peer_greeting = Codec::Greeting.decode(io.read_exactly(Codec::Greeting::SIZE))
282
+ unless peer_greeting[:mechanism] == MECHANISM_NAME
283
+ raise ProtocolError, "expected CURVE mechanism, got #{peer_greeting[:mechanism]}"
284
+ end
285
+
286
+ # --- Read HELLO ---
287
+ hello_frame = Codec::Frame.read_from(io)
288
+ raise ProtocolError, "expected command frame" unless hello_frame.command?
289
+ hello_cmd = Codec::Command.from_body(hello_frame.body)
290
+ raise ProtocolError, "expected HELLO, got #{hello_cmd.name}" unless hello_cmd.name == "HELLO"
291
+
292
+ hdata = hello_cmd.data
293
+ # version(2) + padding(72) + cn_public(32) + short_nonce(8) + signature(80) = 194
294
+ raise ProtocolError, "HELLO wrong size (#{hdata.bytesize})" unless hdata.bytesize == 194
295
+
296
+ cn_public = RbNaCl::PublicKey.new(hdata.byteslice(74, 32))
297
+ h_short_nonce = hdata.byteslice(106, 8)
298
+ h_signature = hdata.byteslice(114, 80)
299
+
300
+ h_nonce = NONCE_PREFIX_HELLO + h_short_nonce
301
+ begin
302
+ plaintext = RbNaCl::Box.new(cn_public, @permanent_secret).decrypt(h_nonce, h_signature)
303
+ rescue RbNaCl::CryptoError
304
+ raise ProtocolError, "HELLO signature verification failed"
305
+ end
306
+ unless RbNaCl::Util.verify64(plaintext, "\x00" * 64)
307
+ raise ProtocolError, "HELLO signature content invalid"
308
+ end
309
+
310
+ # --- WELCOME ---
311
+ sn_secret = RbNaCl::PrivateKey.generate
312
+ sn_public = sn_secret.public_key
313
+
314
+ # Cookie: encrypt(cn_public + sn_secret) with socket-wide cookie key
315
+ cookie_nonce = NONCE_PREFIX_COOKIE + RbNaCl::Random.random_bytes(16)
316
+ cookie_plaintext = cn_public.to_s + sn_secret.to_s
317
+ cookie = cookie_nonce.byteslice(8, 16) +
318
+ RbNaCl::SecretBox.new(@cookie_key).encrypt(cookie_nonce, cookie_plaintext)
319
+ # cookie = 16 (short nonce) + 64 (plaintext) + 16 (MAC) = 96 bytes
320
+
321
+ w_plaintext = sn_public.to_s + cookie
322
+ w_short_nonce = RbNaCl::Random.random_bytes(16) # 16-byte random nonce
323
+ w_nonce = NONCE_PREFIX_WELCOME + w_short_nonce
324
+ w_ciphertext = RbNaCl::Box.new(cn_public, @permanent_secret).encrypt(w_nonce, w_plaintext)
325
+
326
+ welcome = "".b
327
+ welcome << "\x07WELCOME"
328
+ welcome << w_short_nonce # 16 bytes
329
+ welcome << w_ciphertext # 128 + 16 = 144 bytes
330
+
331
+ io.write(Codec::Frame.new(welcome, command: true).to_wire)
332
+
333
+ # --- Read INITIATE ---
334
+ # Server recovers cn_public and sn_secret from the cookie below.
335
+ # Only @cookie_key (socket-wide) is needed to process INITIATE.
336
+ init_frame = Codec::Frame.read_from(io)
337
+ raise ProtocolError, "expected command frame" unless init_frame.command?
338
+ init_cmd = Codec::Command.from_body(init_frame.body)
339
+ raise ProtocolError, "expected INITIATE, got #{init_cmd.name}" unless init_cmd.name == "INITIATE"
340
+
341
+ idata = init_cmd.data
342
+ # cookie(96) + short_nonce(8) + box(at least BOX_OVERHEAD)
343
+ raise ProtocolError, "INITIATE too short" if idata.bytesize < 96 + 8 + BOX_OVERHEAD
344
+
345
+ # Cookie is in cleartext, preceding the encrypted box (per RFC 26)
346
+ recv_cookie = idata.byteslice(0, 96)
347
+ i_short_nonce = idata.byteslice(96, 8)
348
+ i_ciphertext = idata.byteslice(104..)
349
+
350
+ # Recover cn_public and sn_secret from the cookie
351
+ cookie_short_nonce = recv_cookie.byteslice(0, 16)
352
+ cookie_ciphertext = recv_cookie.byteslice(16, 80)
353
+ cookie_decrypt_nonce = NONCE_PREFIX_COOKIE + cookie_short_nonce
354
+ begin
355
+ cookie_contents = RbNaCl::SecretBox.new(@cookie_key).decrypt(cookie_decrypt_nonce, cookie_ciphertext)
356
+ rescue RbNaCl::CryptoError
357
+ raise ProtocolError, "INITIATE cookie verification failed"
358
+ end
359
+
360
+ cn_public = RbNaCl::PublicKey.new(cookie_contents.byteslice(0, 32))
361
+ sn_secret = RbNaCl::PrivateKey.new(cookie_contents.byteslice(32, 32))
362
+
363
+ # Now decrypt the INITIATE box with the recovered transient keys
364
+ session = RbNaCl::Box.new(cn_public, sn_secret)
365
+ i_nonce = NONCE_PREFIX_INITIATE + i_short_nonce
366
+
367
+ begin
368
+ i_plaintext = session.decrypt(i_nonce, i_ciphertext)
369
+ rescue RbNaCl::CryptoError
370
+ raise ProtocolError, "INITIATE decryption failed"
371
+ end
372
+
373
+ # Parse: client_permanent(32) + vouch_nonce_short(16) + vouch(80) + metadata
374
+ raise ProtocolError, "INITIATE plaintext too short" if i_plaintext.bytesize < 32 + 16 + 80
375
+
376
+ client_permanent = RbNaCl::PublicKey.new(i_plaintext.byteslice(0, 32))
377
+ vouch_short_nonce = i_plaintext.byteslice(32, 16)
378
+ vouch_ciphertext = i_plaintext.byteslice(48, 80)
379
+ metadata_bytes = i_plaintext.byteslice(128..) || "".b
380
+
381
+ # Decrypt vouch: from client permanent to server transient
382
+ vouch_nonce = NONCE_PREFIX_VOUCH + vouch_short_nonce
383
+ begin
384
+ vouch_plaintext = RbNaCl::Box.new(client_permanent, sn_secret).decrypt(vouch_nonce, vouch_ciphertext)
385
+ rescue RbNaCl::CryptoError
386
+ raise ProtocolError, "INITIATE vouch verification failed"
387
+ end
388
+
389
+ raise ProtocolError, "vouch wrong size" unless vouch_plaintext.bytesize == 64
390
+
391
+ vouch_cn = vouch_plaintext.byteslice(0, 32)
392
+ vouch_server = vouch_plaintext.byteslice(32, 32)
393
+
394
+ unless RbNaCl::Util.verify32(vouch_cn, cn_public.to_s)
395
+ raise ProtocolError, "vouch client transient key mismatch"
396
+ end
397
+ unless RbNaCl::Util.verify32(vouch_server, @permanent_public.to_s)
398
+ raise ProtocolError, "vouch server key mismatch"
399
+ end
400
+
401
+ # Authenticate client
402
+ if @authenticator
403
+ client_key = client_permanent.to_s
404
+ allowed = if @authenticator.respond_to?(:include?)
405
+ @authenticator.include?(client_key)
406
+ else
407
+ @authenticator.call(client_key)
408
+ end
409
+ raise ProtocolError, "client key not authorized" unless allowed
410
+ end
411
+
412
+ # --- READY ---
413
+ ready_metadata = Codec::Command.encode_properties(
414
+ "Socket-Type" => socket_type,
415
+ "Identity" => identity,
416
+ )
417
+
418
+ r_short_nonce = [1].pack("Q>")
419
+ r_nonce = NONCE_PREFIX_READY + r_short_nonce
420
+ r_ciphertext = session.encrypt(r_nonce, ready_metadata)
421
+
422
+ ready = "".b
423
+ ready << "\x05READY"
424
+ ready << r_short_nonce
425
+ ready << r_ciphertext
426
+
427
+ io.write(Codec::Frame.new(ready, command: true).to_wire)
428
+
429
+ props = Codec::Command.decode_properties(metadata_bytes)
430
+
431
+ @session_box = session
432
+ @send_nonce = 1 # READY consumed nonce 1
433
+ @recv_nonce = 0 # peer's INITIATE consumed their nonce 1
434
+
435
+ {
436
+ peer_socket_type: props["Socket-Type"],
437
+ peer_identity: props["Identity"] || "",
438
+ }
439
+ end
440
+
441
+ # ----------------------------------------------------------------
442
+ # Nonce helpers
443
+ # ----------------------------------------------------------------
444
+
445
+ def make_send_nonce
446
+ @send_nonce += 1
447
+ raise ProtocolError, "nonce counter exhausted" if @send_nonce > MAX_NONCE
448
+ short = [@send_nonce].pack("Q>")
449
+ send_nonce_prefix + short
450
+ end
451
+
452
+ def send_nonce_prefix
453
+ @as_server ? NONCE_PREFIX_MESSAGE_S : NONCE_PREFIX_MESSAGE_C
454
+ end
455
+
456
+ def recv_nonce_prefix
457
+ @as_server ? NONCE_PREFIX_MESSAGE_C : NONCE_PREFIX_MESSAGE_S
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
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: omq-curve
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
+ - !ruby/object:Gem::Dependency
13
+ name: omq
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rbnacl
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ description: Adds CURVE security (Curve25519 encryption and authentication) to OMQ
41
+ sockets. Requires libsodium via rbnacl.
42
+ email:
43
+ - paddor@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - CHANGELOG.md
49
+ - LICENSE
50
+ - README.md
51
+ - lib/omq/curve.rb
52
+ - lib/omq/curve/version.rb
53
+ - lib/omq/z85.rb
54
+ - lib/omq/zmtp/mechanism/curve.rb
55
+ homepage: https://github.com/paddor/omq-curve
56
+ licenses:
57
+ - ISC
58
+ metadata: {}
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '3.3'
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 4.0.6
74
+ specification_version: 4
75
+ summary: CurveZMQ (RFC 26) encryption for OMQ
76
+ test_files: []