pq_crypto 0.1.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dcc40bb87fcd74ce1d16bb32d99df894cb127e0c77c879cba7ec7d6ef8e099e8
4
- data.tar.gz: 6baf54010794cca2d1a9f81f884f1a6db16355515f83ed1812baa57feeaf63dc
3
+ metadata.gz: 15356ea593b25eefd75360992b4b716df348601a5198b8085e271e866e431371
4
+ data.tar.gz: b9667b1d123009b44009b8819f02c1f2855bd5117bd6142faa36cef3bcd4bf22
5
5
  SHA512:
6
- metadata.gz: feb14e5bc0aefc8fa3b9b3d24083cd93923466d9bc1c5a1e4e527325f0290124be785da63aca97e342c9885e1e56b92e2637007a93c47d85ac950bee2317af71
7
- data.tar.gz: 1a7773c9850dfea54df3897a6ee092f99258e25b0fa5393c977fa7e70c3eed309bca54044711501a6475f0d30ca56753eecaa768e87c9620a4e581b015c3332a
6
+ metadata.gz: 722560e27ea481d4f9acaee6798b3a34c06796d5d365a2164cb6ca612917e1b6db0e06e36c74b7624b1f33a78ab3e91930f3e67b5e67988363c4498cba24e585
7
+ data.tar.gz: 843fdee9d26cca30bfe5e6013a759713a062f9d63b08544ea5727b86b3b94e9b58dab0a201d52d0fe2e94c6aedb6ec7c770743bab65ea6d1fbf8545a71d81abc
@@ -13,7 +13,7 @@ jobs:
13
13
  fail-fast: false
14
14
  matrix:
15
15
  os: [ubuntu-latest, macos-latest]
16
- ruby: ["3.1", "3.2", "3.3", "3.4"]
16
+ ruby: ["3.4", "4.0"]
17
17
 
18
18
  steps:
19
19
  - name: Checkout
data/CHANGELOG.md CHANGED
@@ -1,5 +1,107 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] — 2026-04-24
4
+
5
+ **Breaking release.** Hybrid KEM keys, ciphertexts, and `pqc_container_*`
6
+ blobs produced by 0.2.0 are not compatible with 0.3.0. Pure ML-KEM-768
7
+ and ML-DSA-65 material is unaffected.
8
+
9
+ ### Changed — hybrid KEM (breaking)
10
+
11
+ - Replaced the 0.2.0 ad-hoc `HKDF-SHA256`-with-double-transcript combiner
12
+ with the **X-Wing** construction from
13
+ [draft-connolly-cfrg-xwing-kem](https://datatracker.ietf.org/doc/draft-connolly-cfrg-xwing-kem/):
14
+ `ss = SHA3-256( XWingLabel || ss_M || ss_X || ct_X || pk_X )`, where
15
+ `XWingLabel` is the 6-byte ASCII string `\.//^\`.
16
+ - Renamed the hybrid algorithm symbol
17
+ `:ml_kem_768_x25519_hkdf_sha256` → `:ml_kem_768_x25519_xwing`.
18
+ - Retired the 0.2.0 project-local hybrid OID
19
+ (`2.25.260242945110721168101139140490528778800`). The new OID
20
+ (`2.25.318532651283923671095712569430174917109`) identifies the X-Wing
21
+ combiner. `pqc_container_*` blobs carry the new OID; decoding a 0.2.0
22
+ hybrid container now fails fast with `SerializationError`.
23
+
24
+ ### Changed — native code hygiene
25
+
26
+ - Removed the copy of PQClean internal ML-DSA keypair and signature logic
27
+ that 0.2.0 used to implement deterministic test hooks. Tests now drive
28
+ the stock PQClean `crypto_sign_keypair` / `crypto_sign_signature`
29
+ through a new `randombytes()` override
30
+ (`ext/pqcrypto/pq_randombytes.c`) that swaps in a thread-local
31
+ seed-replay mode for the duration of a deterministic call and delegates
32
+ to `OpenSSL RAND_bytes` otherwise.
33
+ - Deleted the non-FIPS 32-byte ML-KEM seed with HKDF expansion; the
34
+ deterministic ML-KEM keypair hook now accepts only the
35
+ FIPS 203 64-byte `d||z` seed.
36
+ - Replaced `uint8_t*` → `hybrid_*_t*` strict-aliasing casts with
37
+ explicit `memcpy` into typed stack locals throughout the hybrid path.
38
+ - Added `_Static_assert` guards on the byte-packed layout of
39
+ `hybrid_public_key_t`, `hybrid_secret_key_t`, and
40
+ `hybrid_ciphertext_t` so any future change that introduces padding
41
+ fails at compile time rather than silently shifting byte offsets.
42
+ - Migrated PEM codec from `EVP_EncodeBlock` / `EVP_DecodeBlock` to the
43
+ streaming `EVP_EncodeUpdate` / `EVP_DecodeUpdate` API, which rejects
44
+ invalid base64 characters rather than treating them as zeros.
45
+ - Deleted the entire internal HKDF and SHA-256 helper paths that 0.2.0
46
+ used for its combiner; the X-Wing combiner is a single SHA3-256
47
+ invocation through `EVP_DigestUpdate`.
48
+ - Tightened `extconf.rb`: the broad `-Wno-unused-parameter`
49
+ `-Wno-unused-function` `-Wno-strict-prototypes` `-Wno-pedantic`
50
+ `-Wno-c23-extensions` `-Wno-undef` suppressions now apply **only** to
51
+ vendored PQClean translation units; our own code compiles with the
52
+ strict warning set. Added a compile probe for `EVP_sha3_256`.
53
+
54
+ ### Changed — Ruby API
55
+
56
+ - `Signature::PublicKey#verify` now returns `true` / `false` for normal
57
+ cryptographic outcomes. Previously an invalid signature surfaced
58
+ through a caught `VerificationError`; the native entrypoint no longer
59
+ raises for this case. `verify!` still raises on mismatch.
60
+ - `PublicKey#==` / `SecretKey#==` on all key types now use OpenSSL
61
+ `CRYPTO_memcmp` through a new `PQCrypto.ct_equals` native helper, so
62
+ key equality checks no longer leak timing information about a
63
+ prefix-match.
64
+ - `SecretKey#hash` (and `PublicKey#hash` for symmetry) now hash a
65
+ SHA-256 fingerprint of the bytes instead of the raw bytes, and a
66
+ public `#fingerprint` method is exposed.
67
+ - Native entrypoints and their `native_*` aliases are installed once via
68
+ the new `PQCrypto::NativeBindings` module instead of the ad-hoc
69
+ `unless method_defined?` guards on the singleton.
70
+ - Renamed `Signature.validate_algorithm!` → `resolve_algorithm!` to
71
+ match `KEM` / `HybridKEM`.
72
+
73
+ ### Changed — packaging
74
+
75
+ - `required_ruby_version` from `">= 3.4.0.a"` to `">= 3.4"`.
76
+ - Version bumped to `0.3.0`.
77
+ - `VerificationError` class is still defined (and still raised by
78
+ `verify!`) for backward compatibility, but the native `verify`
79
+ entrypoint no longer raises it.
80
+
81
+ ### Migration notes
82
+
83
+ - Hybrid keys and ciphertexts must be regenerated with 0.3.0; old blobs
84
+ are rejected by the new container decoder.
85
+ - Code referencing the old hybrid symbol must update to
86
+ `:ml_kem_768_x25519_xwing`. Pure ML-KEM and ML-DSA symbols are
87
+ unchanged.
88
+ - Code relying on `verify` raising `VerificationError` should switch
89
+ to `verify!` or a `verify` + explicit `false` check.
90
+
91
+ ## [0.2.0]
92
+
93
+ ### Changed
94
+
95
+ - Raised the minimum supported Ruby to the 3.4 series.
96
+ - Switched `PQCrypto::Signature::SecretKey#sign` and `PQCrypto::Signature::PublicKey#verify` to Ruby 3.4's scheduler-aware `rb_nogvl(..., RB_NOGVL_OFFLOAD_SAFE)` path.
97
+ - Left the faster KEM and key-generation operations on the existing lower-overhead no-GVL path.
98
+ - Removed gem-specific scheduler configuration; runtime behavior now follows the active Ruby Fiber scheduler automatically.
99
+
100
+ ### Testing
101
+
102
+ - Added Async integration tests that verify sibling `task.async` work keeps making progress while `sign` and `verify` run under an Async worker-pool-enabled reactor.
103
+ - Updated CI to target the supported Ruby 3.4 series.
104
+
3
105
  ## [0.1.0]
4
106
 
5
107
  Initial public release.
data/GET_STARTED.md CHANGED
@@ -30,18 +30,21 @@ sig = PQCrypto::Signature.generate(:ml_dsa_65)
30
30
 
31
31
  ```ruby
32
32
  signature = sig.secret_key.sign("message")
33
+
34
+ sig.public_key.verify("message", signature)
33
35
  sig.public_key.verify!("message", signature)
34
36
  ```
35
37
 
36
- ## 6. Optional hybrid KEM
38
+ ## 6. Hybrid KEM (X-Wing)
37
39
 
38
40
  ```ruby
39
- hybrid = PQCrypto::HybridKEM.generate(:ml_kem_768_x25519_hkdf_sha256)
41
+ hybrid = PQCrypto::HybridKEM.generate(:ml_kem_768_x25519_xwing)
40
42
  result = hybrid.public_key.encapsulate
41
43
  shared_secret = hybrid.secret_key.decapsulate(result.ciphertext)
42
44
  ```
43
45
 
44
- This hybrid mode is pq_crypto-specific and not a general interoperability format.
46
+ The hybrid mode follows `draft-connolly-cfrg-xwing-kem`. See
47
+ `SECURITY.md` for audit status.
45
48
 
46
49
  ## 7. Serialize a key
47
50
 
@@ -50,16 +53,20 @@ der = keypair.public_key.to_pqc_container_der
50
53
  imported = PQCrypto::KEM.public_key_from_pqc_container_der(der)
51
54
  ```
52
55
 
56
+ `pqc_container_*` formats are pq_crypto-specific.
57
+
53
58
  ## 8. Inspect supported algorithms
54
59
 
55
60
  ```ruby
56
- PQCrypto.supported_kems
57
- PQCrypto.supported_hybrid_kems
58
- PQCrypto.supported_signatures
61
+ PQCrypto.supported_kems # => [:ml_kem_768]
62
+ PQCrypto.supported_hybrid_kems # => [:ml_kem_768_x25519_xwing]
63
+ PQCrypto.supported_signatures # => [:ml_dsa_65]
59
64
  ```
60
65
 
61
66
  ## 9. Practical notes
62
67
 
63
- - OpenSSL 3.0+ is required.
64
- - `pqc_container_*` formats are pq_crypto-specific.
65
- - `PQCrypto::Testing` exposes deterministic helpers only for regression tests.
68
+ - OpenSSL 3.0+ with SHA3-256 is required.
69
+ - `PQCrypto::Testing` exposes deterministic helpers only for
70
+ regression tests.
71
+ - Key equality uses constant-time comparison. `#hash` returns a
72
+ hash derived from a SHA-256 fingerprint, not the raw bytes.
data/README.md CHANGED
@@ -2,17 +2,22 @@
2
2
 
3
3
  `pq_crypto` is a primitive-first Ruby gem for post-quantum cryptography.
4
4
 
5
- It currently exposes three public building blocks:
5
+ It exposes three public building blocks:
6
6
 
7
- - `PQCrypto::KEM` — pure `ML-KEM-768`
8
- - `PQCrypto::Signature` — `ML-DSA-65`
9
- - `PQCrypto::HybridKEM` — an optional custom hybrid KEM that combines `ML-KEM-768` and `X25519` with transcript-bound `HKDF-SHA256`
7
+ - `PQCrypto::KEM` — pure `ML-KEM-768` (FIPS 203)
8
+ - `PQCrypto::Signature` — `ML-DSA-65` (FIPS 204)
9
+ - `PQCrypto::HybridKEM` — `ML-KEM-768 + X25519` combined via the
10
+ [X-Wing](https://datatracker.ietf.org/doc/draft-connolly-cfrg-xwing-kem/)
11
+ SHA3-256 combiner
10
12
 
11
- The gem is backed by vendored `PQClean` sources for `ML-KEM-768` / `ML-DSA-65` and OpenSSL for conventional primitives such as `X25519` and `HKDF-SHA256`.
13
+ The gem is backed by vendored `PQClean` sources for `ML-KEM-768` /
14
+ `ML-DSA-65` and by OpenSSL for `X25519` and `SHA3-256`. Every piece of
15
+ conventional-crypto functionality goes through standard library calls
16
+ (`EVP_*`, `RAND_bytes`, `CRYPTO_memcmp`, streaming `EVP_Encode*` /
17
+ `EVP_Decode*`) — nothing roll-your-own where a library primitive exists.
12
18
 
13
19
  ## Status
14
20
 
15
- - first public release
16
21
  - primitive-first API only
17
22
  - no protocol/session helpers in the public surface
18
23
  - serialization uses pq_crypto-specific `pqc_container_*` wrappers
@@ -35,9 +40,53 @@ bundle exec rake compile
35
40
 
36
41
  ### Native dependencies
37
42
 
38
- - Ruby 3.1+
39
- - a C toolchain
40
- - OpenSSL **3.0 or later**
43
+ - Ruby 3.4.x
44
+ - a C toolchain with C11 support (for `_Static_assert` / `_Thread_local`)
45
+ - OpenSSL **3.0 or later** with SHA3-256 available (default provider)
46
+
47
+ ## Async / Fiber scheduler support
48
+
49
+ `pq_crypto` does not require any gem-specific Async configuration. On
50
+ Ruby 3.4, `sign` and `verify` use Ruby's scheduler-aware
51
+ `rb_nogvl(..., RB_NOGVL_OFFLOAD_SAFE)` path automatically.
52
+
53
+ That means:
54
+
55
+ - without a Fiber scheduler, these methods fall back to the ordinary
56
+ no-GVL behavior;
57
+ - with a scheduler that implements `blocking_operation_wait` (for
58
+ example `Async` with a worker pool), the blocking native work can
59
+ be moved off the event loop.
60
+
61
+ This integration is intentionally limited to `sign` and `verify`; the
62
+ faster primitive operations keep the lower-overhead path.
63
+
64
+ Example with `Async`:
65
+
66
+ ```ruby
67
+ require "async"
68
+ require "pq_crypto"
69
+
70
+ keypair = PQCrypto::Signature.generate(:ml_dsa_65)
71
+ message = "hello" * 100_000
72
+
73
+ reactor = Async::Reactor.new(worker_pool: true)
74
+ root = reactor.async do |task|
75
+ task.async do
76
+ signature = keypair.secret_key.sign(message)
77
+ keypair.public_key.verify(message, signature)
78
+ end
79
+
80
+ task.async do
81
+ sleep 0.01
82
+ puts "event loop stayed responsive"
83
+ end
84
+ end
85
+
86
+ reactor.run
87
+ root.wait
88
+ reactor.close
89
+ ```
41
90
 
42
91
  ## Primitive API
43
92
 
@@ -54,18 +103,31 @@ shared_secret = keypair.secret_key.decapsulate(result.ciphertext)
54
103
  ```ruby
55
104
  keypair = PQCrypto::Signature.generate(:ml_dsa_65)
56
105
  signature = keypair.secret_key.sign("hello")
57
- keypair.public_key.verify!("hello", signature)
106
+
107
+ keypair.public_key.verify("hello", signature) # => true / false
108
+ keypair.public_key.verify!("hello", signature) # raises on mismatch
58
109
  ```
59
110
 
60
- ### Hybrid ML-KEM-768 + X25519
111
+ Note: `verify` returns a plain boolean for normal outcomes. `verify!`
112
+ raises `PQCrypto::VerificationError` when the signature does not
113
+ match.
114
+
115
+ ### Hybrid ML-KEM-768 + X25519 (X-Wing)
61
116
 
62
117
  ```ruby
63
- keypair = PQCrypto::HybridKEM.generate(:ml_kem_768_x25519_hkdf_sha256)
118
+ keypair = PQCrypto::HybridKEM.generate(:ml_kem_768_x25519_xwing)
64
119
  result = keypair.public_key.encapsulate
65
120
  shared_secret = keypair.secret_key.decapsulate(result.ciphertext)
66
121
  ```
67
122
 
68
- `PQCrypto::HybridKEM` is a **custom pq_crypto construction**. It is not advertised as compatible with HPKE, TLS hybrid drafts, X-Wing, OpenSSL native PQ APIs, or any other external wire format.
123
+ The combiner is exactly:
124
+
125
+ ```
126
+ ss = SHA3-256( "\.//^\" || ss_M || ss_X || ct_X || pk_X )
127
+ ```
128
+
129
+ as specified by `draft-connolly-cfrg-xwing-kem`. See `SECURITY.md` for
130
+ audit status and interoperability caveats.
69
131
 
70
132
  ## Serialization
71
133
 
@@ -84,7 +146,31 @@ der = keypair.public_key.to_pqc_container_der
84
146
  imported = PQCrypto::KEM.public_key_from_pqc_container_der(der)
85
147
  ```
86
148
 
87
- These containers are **not real ASN.1 SPKI or PKCS#8**. They are intended for stable import/export inside `pq_crypto` itself and are not advertised as interoperable with external PKI tooling.
149
+ These containers are **not real ASN.1 SPKI or PKCS#8**. They are
150
+ intended for stable import/export inside `pq_crypto` itself and are
151
+ not advertised as interoperable with external PKI tooling.
152
+
153
+ ## Secure wiping
154
+
155
+ `PQCrypto.secure_wipe(str)` zeros the bytes of a mutable Ruby string
156
+ in place. Key objects hold a private copy of their bytes, so `wipe!`
157
+ on a `SecretKey` zeroes **only** that internal copy — any prior Ruby
158
+ string the caller holds is untouched. If you need to wipe the
159
+ caller-side buffer, do so explicitly:
160
+
161
+ ```ruby
162
+ raw = File.binread(path)
163
+ key = PQCrypto::KEM.secret_key_from_bytes(:ml_kem_768, raw)
164
+ PQCrypto.secure_wipe(raw) # scrub the original input
165
+ # ... use key ...
166
+ key.wipe! # scrub the key's internal copy
167
+ ```
168
+
169
+ ## Constant-time comparison
170
+
171
+ `==` on `PublicKey` / `SecretKey` instances uses OpenSSL
172
+ `CRYPTO_memcmp` through a `PQCrypto.ct_equals` helper so comparisons
173
+ do not leak timing information about a prefix match.
88
174
 
89
175
  ## Introspection
90
176
 
@@ -95,20 +181,24 @@ PQCrypto.supported_kems
95
181
  PQCrypto.supported_hybrid_kems
96
182
  PQCrypto.supported_signatures
97
183
  PQCrypto::KEM.details(:ml_kem_768)
98
- PQCrypto::HybridKEM.details(:ml_kem_768_x25519_hkdf_sha256)
184
+ PQCrypto::HybridKEM.details(:ml_kem_768_x25519_xwing)
99
185
  PQCrypto::Signature.details(:ml_dsa_65)
100
186
  ```
101
187
 
102
188
  ## Testing helpers
103
189
 
104
- Deterministic test hooks are exposed under `PQCrypto::Testing` for regression coverage:
190
+ Deterministic test hooks are exposed under `PQCrypto::Testing` for
191
+ regression coverage:
105
192
 
106
- - `ml_kem_keypair_from_seed`
107
- - `ml_kem_encapsulate_from_seed`
108
- - `ml_dsa_keypair_from_seed`
109
- - `ml_dsa_sign_from_seed`
193
+ - `ml_kem_keypair_from_seed` — requires a 64-byte `d||z` seed (FIPS 203)
194
+ - `ml_kem_encapsulate_from_seed` — requires a 32-byte seed
195
+ - `ml_dsa_keypair_from_seed` — requires a 32-byte seed
196
+ - `ml_dsa_sign_from_seed` — requires a 32-byte seed
110
197
 
111
- These helpers are intended for tests only.
198
+ These helpers are intended for tests only. They work by installing a
199
+ thread-local seed-replay mode inside the gem's `randombytes()` for
200
+ the duration of the call, then call the stock PQClean entrypoints.
201
+ No internal PQClean algorithm logic is reimplemented in this gem.
112
202
 
113
203
  ## Development
114
204
 
@@ -118,13 +208,17 @@ Run the test suite with:
118
208
  bundle exec rake test
119
209
  ```
120
210
 
121
- Refresh vendored PQClean sources manually only when you intentionally update the vendor snapshot. The refresh script now has a safe pinned default and records the exact vendored snapshot in `ext/pqcrypto/vendor/.vendored`:
211
+ Refresh vendored PQClean sources manually only when you intentionally
212
+ update the vendor snapshot. The refresh script has a safe pinned
213
+ default and records the exact vendored snapshot in
214
+ `ext/pqcrypto/vendor/.vendored`:
122
215
 
123
216
  ```bash
124
217
  bundle exec ruby script/vendor_libs.rb
125
218
  ```
126
219
 
127
- To intentionally change the upstream snapshot, override all four pinning inputs together:
220
+ To intentionally change the upstream snapshot, override all four
221
+ pinning inputs together:
128
222
 
129
223
  ```bash
130
224
  PQCLEAN_VERSION=<full-git-commit> \
data/SECURITY.md CHANGED
@@ -2,14 +2,16 @@
2
2
 
3
3
  ## Scope of the public API
4
4
 
5
- `pq_crypto` 0.1.0 exposes a primitive-first public surface:
5
+ `pq_crypto` 0.3.0 exposes a primitive-first public surface:
6
6
 
7
7
  - `PQCrypto::KEM` (`ML-KEM-768`)
8
8
  - `PQCrypto::Signature` (`ML-DSA-65`)
9
- - `PQCrypto::HybridKEM` (custom `ML-KEM-768 + X25519 + HKDF-SHA256` combiner)
9
+ - `PQCrypto::HybridKEM` (`ML-KEM-768 + X25519` via the X-Wing combiner)
10
10
  - `PQCrypto.secure_wipe`
11
+ - `PQCrypto.ct_equals` (constant-time byte-string comparison)
11
12
 
12
- The gem does **not** publish protocol/session helpers as part of the supported public API in this release.
13
+ The gem does **not** publish protocol/session helpers as part of the
14
+ supported public API.
13
15
 
14
16
  ## Audit status
15
17
 
@@ -19,39 +21,96 @@ This project has not been audited. Treat it as experimental software.
19
21
 
20
22
  ### ML-KEM-768 / ML-DSA-65
21
23
 
22
- The core post-quantum primitives are backed by vendored `PQClean` sources.
24
+ The post-quantum primitives are backed by vendored `PQClean` sources
25
+ and called through PQClean's public `crypto_kem_*` and `crypto_sign_*`
26
+ entrypoints only. Internal PQClean symbols are not called from this
27
+ gem.
23
28
 
24
29
  ### HybridKEM
25
30
 
26
- `PQCrypto::HybridKEM` is a pq_crypto-specific hybrid combiner. It is **not** claimed to be HPKE-, TLS-, or X-Wing-compatible.
31
+ `PQCrypto::HybridKEM` implements the **X-Wing** construction from
32
+ [`draft-connolly-cfrg-xwing-kem`](https://datatracker.ietf.org/doc/draft-connolly-cfrg-xwing-kem/):
27
33
 
28
- Use it only if you explicitly want this project-local construction.
34
+ ss = SHA3-256( XWingLabel || ss_M || ss_X || ct_X || pk_X )
35
+
36
+ where `XWingLabel = "\.//^\"` (6 ASCII bytes). The hybrid public key,
37
+ secret key, and ciphertext are the byte concatenations of the ML-KEM
38
+ and X25519 halves exactly as specified by X-Wing.
39
+
40
+ X-Wing as specified has a proof of classical IND-CCA security under
41
+ the strong Diffie-Hellman assumption for X25519 (in the ROM), and
42
+ post-quantum IND-CCA security in the standard model assuming ML-KEM-768
43
+ is IND-CCA secure and SHA3-256 behaves as a PRF.
44
+
45
+ This gem is **not** yet part of a cross-language X-Wing interop test
46
+ suite; wire-format claims for this release are limited to "follows the
47
+ X-Wing draft as of version 10." External interoperability should be
48
+ verified against the reference implementation before relying on it.
49
+
50
+ ### Deterministic test hooks
51
+
52
+ `PQCrypto::Testing` deterministic helpers drive the stock PQClean
53
+ `crypto_sign_keypair` / `crypto_sign_signature` (for ML-DSA) and
54
+ `crypto_kem_keypair_derand` / `crypto_kem_enc_derand` (for ML-KEM)
55
+ against a caller-supplied seed. For ML-DSA, which has no derand API
56
+ upstream, the gem installs a thread-local seed-replay buffer inside
57
+ its `randombytes()` implementation; outside of a test call the same
58
+ `randombytes()` entry delegates directly to OpenSSL `RAND_bytes`. No
59
+ internal PQClean algorithm logic is reimplemented in this gem.
29
60
 
30
61
  ## Serialization
31
62
 
32
63
  `pqc_container_*` DER/PEM wrappers are pq_crypto-specific containers.
33
64
 
34
65
  They are:
66
+
35
67
  - not real SPKI
36
68
  - not real PKCS#8
37
69
  - not advertised as interoperable with OpenSSL, Go, Java, or PKI tooling
38
70
 
39
- The OIDs embedded in these containers are project-local UUID-derived OIDs under `2.25.*`. They are not registrations for interoperable standard key formats. Within the `pqc_container_*` format they are treated as part of pq_crypto's own serialized container schema, not as external interoperability identifiers.
71
+ The OIDs embedded in these containers are project-local UUID-derived
72
+ OIDs under `2.25.*`. They are part of pq_crypto's own serialized
73
+ container schema, not external interoperability identifiers.
40
74
 
41
- Future releases may replace these project-local identifiers if pq_crypto adopts standardized external container formats. Persisted `pqc_container_*` blobs should therefore be treated as pq_crypto-local artifacts, not as a long-term interoperability format.
75
+ The hybrid OID used by 0.2.0
76
+ (`2.25.260242945110721168101139140490528778800`) is retired in 0.3.0
77
+ because the combiner semantics changed (0.2.0 used an ad-hoc
78
+ HKDF-SHA256 combiner; 0.3.0 uses X-Wing / SHA3-256). The new hybrid
79
+ OID is `2.25.318532651283923671095712569430174917109`. A 0.2.0 hybrid
80
+ container is rejected at decode time in 0.3.0.
42
81
 
43
82
  ## Memory wiping
44
83
 
45
- `PQCrypto.secure_wipe` clears mutable Ruby strings in place. Ruby copies, GC behavior, and prior derived copies may still leave sensitive material elsewhere in process memory.
84
+ `PQCrypto.secure_wipe` clears mutable Ruby strings in place. Ruby key
85
+ objects (`PublicKey`, `SecretKey`) take a copy of the bytes passed into
86
+ their constructor and expose `#wipe!` to zero only that internal copy
87
+ — any prior Ruby string the caller still holds is untouched. Ruby
88
+ garbage collection and prior derived copies may still leave sensitive
89
+ material elsewhere in process memory.
46
90
 
47
91
  ## OpenSSL baseline
48
92
 
49
93
  `pq_crypto` requires OpenSSL **3.0 or later**.
50
94
 
51
- OpenSSL is used for conventional primitives and plumbing such as:
52
- - `X25519`
53
- - `HKDF-SHA256`
95
+ OpenSSL is used for:
96
+
97
+ - `X25519` key generation and key agreement (`EVP_PKEY_*`)
98
+ - `SHA3-256` (X-Wing combiner, via `EVP_sha3_256`)
99
+ - `RAND_bytes` (production entropy source for `randombytes()`)
100
+ - `CRYPTO_memcmp` (constant-time comparison used by `PQCrypto.ct_equals`)
101
+ - Base64 encode/decode for PEM via the streaming `EVP_Encode*` /
102
+ `EVP_Decode*` API, which rejects invalid base64 rather than treating
103
+ it as zeros.
54
104
 
55
105
  ## Threading
56
106
 
57
- Concurrent read-only operations on primitive key objects are supported. Native calls copy Ruby string inputs before releasing the GVL, so normal concurrent use does not rely on Ruby string storage remaining pinned in place. Deterministic testing helpers remain test-only utilities and should not be treated as a general multi-threading contract.
107
+ Concurrent read-only operations on primitive key objects are supported.
108
+ Native calls copy Ruby string inputs before releasing the GVL, so
109
+ normal concurrent use does not rely on Ruby string storage remaining
110
+ pinned in place.
111
+
112
+ The deterministic test hooks use a thread-local seed-replay mode
113
+ around `randombytes()`, so a test running on one thread does not
114
+ affect production callers on other threads. The deterministic helpers
115
+ remain test-only utilities and should not be relied on as a general
116
+ multi-threading contract.
@@ -5,8 +5,7 @@ require "mkmf"
5
5
 
6
6
  $CFLAGS << " -std=c99 -Wall -Wextra -O2"
7
7
  $CFLAGS << " -fstack-protector-strong -D_FORTIFY_SOURCE=2"
8
- $CFLAGS << " -Wno-c23-extensions -Wno-strict-prototypes -Wno-pedantic"
9
- $CFLAGS << " -Wno-unused-parameter -Wno-unused-function"
8
+ VENDOR_ONLY_CFLAGS = "-Wno-unused-parameter -Wno-unused-function -Wno-strict-prototypes -Wno-pedantic -Wno-c23-extensions -Wno-undef"
10
9
 
11
10
  $LDFLAGS << " -Wl,-no_warn_duplicate_libraries" if RbConfig::CONFIG["host_os"] =~ /darwin/
12
11
 
@@ -52,7 +51,7 @@ def configure_openssl!
52
51
  abort "OpenSSL libssl is required" unless have_library("ssl")
53
52
  abort "openssl/evp.h is required" unless have_header("openssl/evp.h")
54
53
  abort "openssl/rand.h is required" unless have_header("openssl/rand.h")
55
- abort "openssl/kdf.h is required" unless have_header("openssl/kdf.h")
54
+ abort "openssl/crypto.h is required" unless have_header("openssl/crypto.h")
56
55
 
57
56
  version_check = <<~SRC
58
57
  #include <openssl/opensslv.h>
@@ -64,7 +63,16 @@ def configure_openssl!
64
63
 
65
64
  abort "OpenSSL 3.0 or later is required" unless try_compile(version_check)
66
65
 
67
- $CFLAGS << " -DHAVE_OPENSSL_EVP_H -DHAVE_OPENSSL_RAND_H -DHAVE_OPENSSL_KDF_H"
66
+ sha3_check = <<~SRC
67
+ #include <openssl/evp.h>
68
+ int main(void) {
69
+ const EVP_MD *md = EVP_sha3_256();
70
+ return md == NULL ? 1 : 0;
71
+ }
72
+ SRC
73
+ abort "OpenSSL SHA3-256 is required (X-Wing combiner)" unless try_compile(sha3_check)
74
+
75
+ $CFLAGS << " -DHAVE_OPENSSL_EVP_H -DHAVE_OPENSSL_RAND_H"
68
76
  end
69
77
 
70
78
  def configure_pqclean(vendor_dir)
@@ -82,7 +90,7 @@ def configure_pqclean(vendor_dir)
82
90
 
83
91
  mlkem_sources = Dir.glob(File.join(mlkem_dir, "*.c")).sort
84
92
  mldsa_sources = Dir.glob(File.join(mldsa_dir, "*.c")).sort
85
- common_sources = %w[fips202.c sha2.c sp800-185.c randombytes.c].map { |name| File.join(common_dir, name) }
93
+ common_sources = %w[fips202.c sha2.c sp800-185.c].map { |name| File.join(common_dir, name) }
86
94
 
87
95
  source_groups = [
88
96
  ["pqclean_mlkem", mlkem_sources],
@@ -92,7 +100,7 @@ def configure_pqclean(vendor_dir)
92
100
 
93
101
  return nil unless source_groups.all? { |_, sources| sources.all? { |path| File.exist?(path) } }
94
102
 
95
- $CFLAGS << " -DHAVE_PQCLEAN -Wno-undef"
103
+ $CFLAGS << " -DHAVE_PQCLEAN"
96
104
  include_dirs.each { |dir| $CPPFLAGS << " -I#{dir}" }
97
105
 
98
106
  {
@@ -117,7 +125,7 @@ def inject_pqclean_sources!(pqclean_config)
117
125
  build_rules << <<~RULE
118
126
  #{object}: #{source}
119
127
  $(ECHO) compiling #{source}
120
- $(Q) $(CC) $(INCFLAGS) $(CPPFLAGS) $(CFLAGS) $(COUTFLAG)$@ -c $(CSRCFLAG)$<
128
+ $(Q) $(CC) $(INCFLAGS) $(CPPFLAGS) $(CFLAGS) #{VENDOR_ONLY_CFLAGS} $(COUTFLAG)$@ -c $(CSRCFLAG)$<
121
129
  RULE
122
130
  end
123
131
  end
@@ -138,9 +146,6 @@ def inject_pqclean_sources!(pqclean_config)
138
146
  File.write("Makefile", makefile)
139
147
  end
140
148
 
141
- have_func("getrandom", "sys/random.h")
142
- have_func("arc4random_buf", "stdlib.h")
143
-
144
149
  vendor_dir = USE_SYSTEM ? nil : find_vendor_dir
145
150
 
146
151
  puts
@@ -149,7 +154,7 @@ configure_openssl!
149
154
  pqclean_config = configure_pqclean(vendor_dir)
150
155
  puts "OpenSSL: system"
151
156
  abort "PQClean vendored sources are required. Run: bundle exec rake vendor" unless pqclean_config
152
- puts "PQClean: vendored"
157
+ puts "PQClean: vendored (randombytes overridden by pq_randombytes.c)"
153
158
  puts "Output: pqcrypto/pqcrypto_secure"
154
159
  puts "===================================="
155
160
 
@@ -0,0 +1,56 @@
1
+ #include "pqcrypto_secure.h"
2
+
3
+ #include <stdint.h>
4
+ #include <string.h>
5
+
6
+ #include <openssl/rand.h>
7
+ #include "randombytes.h"
8
+
9
+ #if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L
10
+ #define PQ_THREAD_LOCAL _Thread_local
11
+ #elif defined(__GNUC__) || defined(__clang__)
12
+ #define PQ_THREAD_LOCAL __thread
13
+ #else
14
+ #define PQ_THREAD_LOCAL
15
+ #endif
16
+
17
+ static PQ_THREAD_LOCAL const uint8_t *pq_test_seed_ptr = NULL;
18
+ static PQ_THREAD_LOCAL size_t pq_test_seed_remaining = 0;
19
+
20
+ void pq_testing_set_seed(const uint8_t *seed, size_t len) {
21
+ pq_test_seed_ptr = seed;
22
+ pq_test_seed_remaining = (seed != NULL) ? len : 0;
23
+ }
24
+
25
+ void pq_testing_clear_seed(void) {
26
+ pq_test_seed_ptr = NULL;
27
+ pq_test_seed_remaining = 0;
28
+ }
29
+
30
+ int pq_testing_seed_active(void) {
31
+ return pq_test_seed_ptr != NULL;
32
+ }
33
+
34
+ int randombytes(uint8_t *output, size_t n) {
35
+ if (output == NULL) {
36
+ return -1;
37
+ }
38
+
39
+ if (pq_test_seed_ptr != NULL) {
40
+ if (pq_test_seed_remaining < n) {
41
+ return -1;
42
+ }
43
+ memcpy(output, pq_test_seed_ptr, n);
44
+ pq_test_seed_ptr += n;
45
+ pq_test_seed_remaining -= n;
46
+ return 0;
47
+ }
48
+
49
+ if (n > INT_MAX) {
50
+ return -1;
51
+ }
52
+ if (RAND_bytes(output, (int)n) != 1) {
53
+ return -1;
54
+ }
55
+ return 0;
56
+ }