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 +7 -0
- data/LICENSE +15 -0
- data/README.md +134 -0
- data/lib/omq/blake3zmq/crypto.rb +126 -0
- data/lib/omq/blake3zmq/mechanism.rb +687 -0
- data/lib/omq/blake3zmq/version.rb +8 -0
- data/lib/omq/blake3zmq.rb +12 -0
- metadata +103 -0
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,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: []
|