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 +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE +15 -0
- data/README.md +284 -0
- data/lib/omq/curve/version.rb +7 -0
- data/lib/omq/curve.rb +8 -0
- data/lib/omq/z85.rb +70 -0
- data/lib/omq/zmtp/mechanism/curve.rb +467 -0
- metadata +76 -0
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)
|
data/lib/omq/curve.rb
ADDED
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: []
|