pq_crypto 0.3.0 → 0.3.1

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: 15356ea593b25eefd75360992b4b716df348601a5198b8085e271e866e431371
4
- data.tar.gz: b9667b1d123009b44009b8819f02c1f2855bd5117bd6142faa36cef3bcd4bf22
3
+ metadata.gz: 534c420f62323e9ddb49b48acdfa7af869731e3273bda56637d6bfee753de8d7
4
+ data.tar.gz: f003494b4d33702a1b718dc6ded7a9f0612b315e41f0d739a5b2fe3ebba9d1d1
5
5
  SHA512:
6
- metadata.gz: 722560e27ea481d4f9acaee6798b3a34c06796d5d365a2164cb6ca612917e1b6db0e06e36c74b7624b1f33a78ab3e91930f3e67b5e67988363c4498cba24e585
7
- data.tar.gz: 843fdee9d26cca30bfe5e6013a759713a062f9d63b08544ea5727b86b3b94e9b58dab0a201d52d0fe2e94c6aedb6ec7c770743bab65ea6d1fbf8545a71d81abc
6
+ metadata.gz: 1a039325a13cf8c074741fb70edc1e66f4e4939727b4c1369665794bc6ed71ef1ed2ef50d9debd9064d850e4856fac166f325889a9df4bfd3d8849a2d7a918e9
7
+ data.tar.gz: 304888656181149eed84eca063e24eeb6c862819f6c54022e77b287ca4030b8602e9f61eb17809ad86ca0eea84796c6275a2afa12018ffdeaafc489c15c8f24a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.1] — 2026-04-24
4
+
5
+ ### Fixed — X-Wing draft-10 compatibility
6
+
7
+ - Changed `:ml_kem_768_x25519_xwing` secret keys to the draft-10 32-byte
8
+ X-Wing decapsulation seed and derive ML-KEM/X25519 private material with
9
+ SHAKE256 during key generation and decapsulation.
10
+ - Corrected the X-Wing combiner transcript to
11
+ `ss = SHA3-256( ss_M || ss_X || ct_X || pk_X || XWingLabel )`.
12
+ - Updated the hybrid serialization OID to the X-Wing draft OID
13
+ `1.3.6.1.4.1.62253.25722`.
14
+ - Redacted key `inspect` output, removed public secret-key fingerprints,
15
+ improved native extension load diagnostics, switched the extension build
16
+ flag to C11, and aligned docs with the implementation.
17
+
3
18
  ## [0.3.0] — 2026-04-24
4
19
 
5
20
  **Breaking release.** Hybrid KEM keys, ciphertexts, and `pqc_container_*`
@@ -9,17 +24,14 @@ and ML-DSA-65 material is unaffected.
9
24
  ### Changed — hybrid KEM (breaking)
10
25
 
11
26
  - 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 `\.//^\`.
27
+ with a SHA3-256 X-Wing-inspired combiner. This was later corrected in
28
+ `0.3.1` to match draft-10 transcript order and 32-byte secret keys.
16
29
  - Renamed the hybrid algorithm symbol
17
30
  `:ml_kem_768_x25519_hkdf_sha256` → `:ml_kem_768_x25519_xwing`.
18
31
  - 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`.
32
+ (`2.25.260242945110721168101139140490528778800`). 0.3.0 used
33
+ `2.25.318532651283923671095712569430174917109`; this was later replaced
34
+ in `0.3.1` by the X-Wing draft OID.
23
35
 
24
36
  ### Changed — native code hygiene
25
37
 
@@ -39,9 +51,8 @@ and ML-DSA-65 material is unaffected.
39
51
  `hybrid_public_key_t`, `hybrid_secret_key_t`, and
40
52
  `hybrid_ciphertext_t` so any future change that introduces padding
41
53
  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.
54
+ - Migrated PEM codec to OpenSSL `BIO_f_base64` with stricter PEM
55
+ header/footer framing and trailing-garbage checks.
45
56
  - Deleted the entire internal HKDF and SHA-256 helper paths that 0.2.0
46
57
  used for its combiner; the X-Wing combiner is a single SHA3-256
47
58
  invocation through `EVP_DigestUpdate`.
@@ -61,9 +72,9 @@ and ML-DSA-65 material is unaffected.
61
72
  `CRYPTO_memcmp` through a new `PQCrypto.ct_equals` native helper, so
62
73
  key equality checks no longer leak timing information about a
63
74
  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.
75
+ - `PublicKey#hash` and `SecretKey#hash` now hash a SHA-256 fingerprint
76
+ of the bytes instead of the raw bytes. The public secret-key fingerprint
77
+ method is removed in `0.3.1` to reduce accidental logging risk.
67
78
  - Native entrypoints and their `native_*` aliases are installed once via
68
79
  the new `PQCrypto::NativeBindings` module instead of the ad-hoc
69
80
  `unless method_defined?` guards on the singleton.
@@ -72,7 +83,8 @@ and ML-DSA-65 material is unaffected.
72
83
 
73
84
  ### Changed — packaging
74
85
 
75
- - `required_ruby_version` from `">= 3.4.0.a"` to `">= 3.4"`.
86
+ - Intended to change `required_ruby_version` from `">= 3.4.0.a"` to
87
+ `">= 3.4"`; the gemspec is aligned in `0.3.1`.
76
88
  - Version bumped to `0.3.0`.
77
89
  - `VerificationError` class is still defined (and still raised by
78
90
  `verify!`) for backward compatibility, but the native `verify`
data/GET_STARTED.md CHANGED
@@ -43,6 +43,9 @@ result = hybrid.public_key.encapsulate
43
43
  shared_secret = hybrid.secret_key.decapsulate(result.ciphertext)
44
44
  ```
45
45
 
46
+ The raw X-Wing secret key exported by this API is the draft-10 32-byte
47
+ decapsulation seed, not the expanded ML-KEM/X25519 private material.
48
+
46
49
  The hybrid mode follows `draft-connolly-cfrg-xwing-kem`. See
47
50
  `SECURITY.md` for audit status.
48
51
 
data/README.md CHANGED
@@ -13,8 +13,8 @@ It exposes three public building blocks:
13
13
  The gem is backed by vendored `PQClean` sources for `ML-KEM-768` /
14
14
  `ML-DSA-65` and by OpenSSL for `X25519` and `SHA3-256`. Every piece of
15
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.
16
+ (`EVP_*`, `RAND_bytes`, `CRYPTO_memcmp`, `BIO_f_base64`) — nothing
17
+ roll-your-own where a library primitive exists.
18
18
 
19
19
  ## Status
20
20
 
@@ -42,7 +42,7 @@ bundle exec rake compile
42
42
 
43
43
  - Ruby 3.4.x
44
44
  - a C toolchain with C11 support (for `_Static_assert` / `_Thread_local`)
45
- - OpenSSL **3.0 or later** with SHA3-256 available (default provider)
45
+ - OpenSSL **3.0 or later** with SHA3-256 and SHAKE256 available (default provider)
46
46
 
47
47
  ## Async / Fiber scheduler support
48
48
 
@@ -120,14 +120,16 @@ result = keypair.public_key.encapsulate
120
120
  shared_secret = keypair.secret_key.decapsulate(result.ciphertext)
121
121
  ```
122
122
 
123
- The combiner is exactly:
123
+ The implementation follows draft-10 key expansion: the X-Wing secret
124
+ decapsulation key is a 32-byte seed expanded with SHAKE256 into ML-KEM
125
+ and X25519 private material. The combiner is exactly:
124
126
 
125
127
  ```
126
- ss = SHA3-256( "\.//^\" || ss_M || ss_X || ct_X || pk_X )
128
+ ss = SHA3-256( ss_M || ss_X || ct_X || pk_X || "\.//^\" )
127
129
  ```
128
130
 
129
- as specified by `draft-connolly-cfrg-xwing-kem`. See `SECURITY.md` for
130
- audit status and interoperability caveats.
131
+ as specified by `draft-connolly-cfrg-xwing-kem-10`. See `SECURITY.md`
132
+ for audit status and interoperability caveats.
131
133
 
132
134
  ## Serialization
133
135
 
@@ -172,6 +174,12 @@ key.wipe! # scrub the key's internal copy
172
174
  `CRYPTO_memcmp` through a `PQCrypto.ct_equals` helper so comparisons
173
175
  do not leak timing information about a prefix match.
174
176
 
177
+ Secret key `inspect` output is intentionally redacted and secret key
178
+ objects do not expose a public `fingerprint` method. `wipe!` remains
179
+ best-effort only: it clears the current Ruby string buffer owned by the
180
+ key object, not every possible copy made by Ruby, OpenSSL, serialization,
181
+ logging, or the garbage collector.
182
+
175
183
  ## Introspection
176
184
 
177
185
  ```ruby
data/SECURITY.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Scope of the public API
4
4
 
5
- `pq_crypto` 0.3.0 exposes a primitive-first public surface:
5
+ `pq_crypto` exposes a primitive-first public surface:
6
6
 
7
7
  - `PQCrypto::KEM` (`ML-KEM-768`)
8
8
  - `PQCrypto::Signature` (`ML-DSA-65`)
@@ -29,23 +29,25 @@ gem.
29
29
  ### HybridKEM
30
30
 
31
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/):
32
+ [`draft-connolly-cfrg-xwing-kem-10`](https://datatracker.ietf.org/doc/draft-connolly-cfrg-xwing-kem/).
33
33
 
34
- ss = SHA3-256( XWingLabel || ss_M || ss_X || ct_X || pk_X )
34
+ The X-Wing secret decapsulation key is a 32-byte seed. It is expanded
35
+ with SHAKE256 into the ML-KEM-768 and X25519 private material used
36
+ internally for decapsulation. The public key and ciphertext are the
37
+ fixed-length concatenations specified by the draft.
35
38
 
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
+ ss = SHA3-256( ss_M || ss_X || ct_X || pk_X || XWingLabel )
40
+
41
+ where `XWingLabel = "\.//^\"` (6 ASCII bytes).
39
42
 
40
43
  X-Wing as specified has a proof of classical IND-CCA security under
41
44
  the strong Diffie-Hellman assumption for X25519 (in the ROM), and
42
45
  post-quantum IND-CCA security in the standard model assuming ML-KEM-768
43
46
  is IND-CCA secure and SHA3-256 behaves as a PRF.
44
47
 
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.
48
+ This gem is intended to match the X-Wing draft as of version 10. External
49
+ interoperability should still be verified against the reference
50
+ implementation before relying on it.
49
51
 
50
52
  ### Deterministic test hooks
51
53
 
@@ -68,16 +70,16 @@ They are:
68
70
  - not real PKCS#8
69
71
  - not advertised as interoperable with OpenSSL, Go, Java, or PKI tooling
70
72
 
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.
73
+ The `pqc_container_*` envelope itself is project-specific. ML-KEM and
74
+ ML-DSA currently use project-local UUID-derived OIDs under `2.25.*`.
75
+ Hybrid X-Wing uses the draft X-Wing OID `1.3.6.1.4.1.62253.25722`.
74
76
 
75
77
  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.
78
+ (`2.25.260242945110721168101139140490528778800`) is retired. The
79
+ intermediate 0.3.0 project-local hybrid OID
80
+ (`2.25.318532651283923671095712569430174917109`) is also retired in
81
+ favor of the draft X-Wing OID. Older hybrid containers are rejected at
82
+ decode time.
81
83
 
82
84
  ## Memory wiping
83
85
 
@@ -98,9 +100,19 @@ OpenSSL is used for:
98
100
  - `SHA3-256` (X-Wing combiner, via `EVP_sha3_256`)
99
101
  - `RAND_bytes` (production entropy source for `randombytes()`)
100
102
  - `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.
103
+ - Base64 encode/decode for PEM via OpenSSL `BIO_f_base64`, with strict
104
+ header/footer framing and trailing-garbage checks.
105
+
106
+ ## Secret key display and wiping
107
+
108
+ Secret key objects redact `inspect` output and intentionally do not expose
109
+ a public `fingerprint` method. This avoids accidental logging of raw secret
110
+ bytes or stable secret-derived identifiers.
111
+
112
+ `wipe!` is best-effort only. It wipes the current Ruby string buffer held
113
+ by the key object; it cannot guarantee erasure of copies made by Ruby,
114
+ OpenSSL, native wrapper buffers, serialization, logging, crash dumps, or
115
+ the garbage collector.
104
116
 
105
117
  ## Threading
106
118
 
@@ -3,7 +3,7 @@
3
3
 
4
4
  require "mkmf"
5
5
 
6
- $CFLAGS << " -std=c99 -Wall -Wextra -O2"
6
+ $CFLAGS << " -std=c11 -Wall -Wextra -O2"
7
7
  $CFLAGS << " -fstack-protector-strong -D_FORTIFY_SOURCE=2"
8
8
  VENDOR_ONLY_CFLAGS = "-Wno-unused-parameter -Wno-unused-function -Wno-strict-prototypes -Wno-pedantic -Wno-c23-extensions -Wno-undef"
9
9
 
@@ -15,6 +15,7 @@ SANITIZE = ENV["PQCRYPTO_SANITIZE"]
15
15
 
16
16
  if SANITIZE && !SANITIZE.strip.empty?
17
17
  sanitize = SANITIZE.strip
18
+ $CFLAGS.gsub!(/\s-D_FORTIFY_SOURCE=\d+/, "")
18
19
  $CFLAGS << " -O1 -g -fno-omit-frame-pointer -fsanitize=#{sanitize}"
19
20
  $LDFLAGS << " -fsanitize=#{sanitize}"
20
21
  end
@@ -72,6 +73,15 @@ def configure_openssl!
72
73
  SRC
73
74
  abort "OpenSSL SHA3-256 is required (X-Wing combiner)" unless try_compile(sha3_check)
74
75
 
76
+ shake_check = <<~SRC
77
+ #include <openssl/evp.h>
78
+ int main(void) {
79
+ const EVP_MD *md = EVP_shake256();
80
+ return md == NULL ? 1 : 0;
81
+ }
82
+ SRC
83
+ abort "OpenSSL SHAKE256 is required (X-Wing key expansion)" unless try_compile(shake_check)
84
+
75
85
  $CFLAGS << " -DHAVE_OPENSSL_EVP_H -DHAVE_OPENSSL_RAND_H"
76
86
  end
77
87
 
@@ -8,6 +8,10 @@
8
8
 
9
9
  #include "pqcrypto_secure.h"
10
10
 
11
+ #ifndef RB_NOGVL_OFFLOAD_SAFE
12
+ #define RB_NOGVL_OFFLOAD_SAFE 0
13
+ #endif
14
+
11
15
  typedef struct {
12
16
  int result;
13
17
  uint8_t *public_key;
@@ -99,6 +99,32 @@ cleanup:
99
99
  return ret;
100
100
  }
101
101
 
102
+ static int x25519_public_from_private(uint8_t *pk, const uint8_t *sk) {
103
+ EVP_PKEY *pkey = NULL;
104
+ size_t pklen = X25519_PUBLICKEYBYTES;
105
+ int ret = PQ_ERROR_KEYPAIR;
106
+
107
+ if (!pk || !sk) {
108
+ return PQ_ERROR_BUFFER;
109
+ }
110
+
111
+ pkey = EVP_PKEY_new_raw_private_key(EVP_PKEY_X25519, NULL, sk, X25519_SECRETKEYBYTES);
112
+ if (!pkey)
113
+ goto cleanup;
114
+
115
+ if (EVP_PKEY_get_raw_public_key(pkey, pk, &pklen) <= 0)
116
+ goto cleanup;
117
+ if (pklen != X25519_PUBLICKEYBYTES)
118
+ goto cleanup;
119
+
120
+ ret = PQ_SUCCESS;
121
+
122
+ cleanup:
123
+ if (pkey)
124
+ EVP_PKEY_free(pkey);
125
+ return ret;
126
+ }
127
+
102
128
  static int x25519_shared_secret(uint8_t *shared, const uint8_t *their_pk, const uint8_t *my_sk) {
103
129
  EVP_PKEY_CTX *ctx = NULL;
104
130
  EVP_PKEY *pkey = NULL;
@@ -159,8 +185,6 @@ static int xwing_combiner(uint8_t shared_secret[HYBRID_SHAREDSECRETBYTES],
159
185
 
160
186
  if (EVP_DigestInit_ex(ctx, EVP_sha3_256(), NULL) != 1)
161
187
  goto cleanup;
162
- if (EVP_DigestUpdate(ctx, XWING_LABEL, sizeof(XWING_LABEL)) != 1)
163
- goto cleanup;
164
188
  if (EVP_DigestUpdate(ctx, ss_M, MLKEM_SHAREDSECRETBYTES) != 1)
165
189
  goto cleanup;
166
190
  if (EVP_DigestUpdate(ctx, ss_X, X25519_SHAREDSECRETBYTES) != 1)
@@ -169,6 +193,8 @@ static int xwing_combiner(uint8_t shared_secret[HYBRID_SHAREDSECRETBYTES],
169
193
  goto cleanup;
170
194
  if (EVP_DigestUpdate(ctx, pk_X, X25519_PUBLICKEYBYTES) != 1)
171
195
  goto cleanup;
196
+ if (EVP_DigestUpdate(ctx, XWING_LABEL, sizeof(XWING_LABEL)) != 1)
197
+ goto cleanup;
172
198
  if (EVP_DigestFinal_ex(ctx, shared_secret, &out_len) != 1)
173
199
  goto cleanup;
174
200
  if (out_len != HYBRID_SHAREDSECRETBYTES)
@@ -181,6 +207,55 @@ cleanup:
181
207
  return ret;
182
208
  }
183
209
 
210
+ static int xwing_expand_secret_key(hybrid_expanded_secret_key_t *expanded_key,
211
+ const uint8_t seed[HYBRID_SECRETKEYBYTES]) {
212
+ EVP_MD_CTX *ctx = NULL;
213
+ uint8_t expanded[XWING_EXPANDEDBYTES];
214
+ int ret = PQ_ERROR_OPENSSL;
215
+
216
+ if (!expanded_key || !seed) {
217
+ return PQ_ERROR_BUFFER;
218
+ }
219
+
220
+ memset(expanded_key, 0, sizeof(*expanded_key));
221
+ memset(expanded, 0, sizeof(expanded));
222
+
223
+ ctx = EVP_MD_CTX_new();
224
+ if (!ctx)
225
+ goto cleanup;
226
+
227
+ if (EVP_DigestInit_ex(ctx, EVP_shake256(), NULL) != 1)
228
+ goto cleanup;
229
+ if (EVP_DigestUpdate(ctx, seed, HYBRID_SECRETKEYBYTES) != 1)
230
+ goto cleanup;
231
+ if (EVP_DigestFinalXOF(ctx, expanded, sizeof(expanded)) != 1)
232
+ goto cleanup;
233
+
234
+ ret = PQCLEAN_MLKEM768_CLEAN_crypto_kem_keypair_derand(expanded_key->mlkem_pk,
235
+ expanded_key->mlkem_sk, expanded);
236
+ if (ret != 0) {
237
+ ret = PQ_ERROR_KEYPAIR;
238
+ goto cleanup;
239
+ }
240
+
241
+ memcpy(expanded_key->x25519_sk, expanded + 64, X25519_SECRETKEYBYTES);
242
+ ret = x25519_public_from_private(expanded_key->x25519_pk, expanded_key->x25519_sk);
243
+ if (ret != PQ_SUCCESS) {
244
+ goto cleanup;
245
+ }
246
+
247
+ ret = PQ_SUCCESS;
248
+
249
+ cleanup:
250
+ if (ctx)
251
+ EVP_MD_CTX_free(ctx);
252
+ pq_secure_wipe(expanded, sizeof(expanded));
253
+ if (ret != PQ_SUCCESS && expanded_key) {
254
+ pq_secure_wipe(expanded_key, sizeof(*expanded_key));
255
+ }
256
+ return ret;
257
+ }
258
+
184
259
  int pq_mlkem_keypair(uint8_t *pk, uint8_t *sk) {
185
260
  return PQCLEAN_MLKEM768_CLEAN_crypto_kem_keypair(pk, sk) == 0 ? PQ_SUCCESS : PQ_ERROR_KEYPAIR;
186
261
  }
@@ -278,35 +353,36 @@ int pq_testing_mldsa_sign_from_seed(uint8_t *signature, size_t *signature_len,
278
353
 
279
354
  int pq_hybrid_kem_keypair(uint8_t *public_key, uint8_t *secret_key) {
280
355
  hybrid_public_key_t pk;
281
- hybrid_secret_key_t sk;
282
- int ret;
356
+ hybrid_expanded_secret_key_t expanded;
357
+ uint8_t seed[HYBRID_SECRETKEYBYTES];
358
+ int ret = PQ_SUCCESS;
283
359
 
284
360
  if (!public_key || !secret_key) {
285
361
  return PQ_ERROR_BUFFER;
286
362
  }
287
363
 
288
364
  memset(&pk, 0, sizeof(pk));
289
- memset(&sk, 0, sizeof(sk));
365
+ memset(&expanded, 0, sizeof(expanded));
366
+ memset(seed, 0, sizeof(seed));
290
367
 
291
- ret = PQCLEAN_MLKEM768_CLEAN_crypto_kem_keypair(pk.mlkem_pk, sk.mlkem_sk) == 0
292
- ? PQ_SUCCESS
293
- : PQ_ERROR_KEYPAIR;
294
- if (ret != PQ_SUCCESS) {
368
+ if (RAND_bytes(seed, sizeof(seed)) != 1) {
369
+ ret = PQ_ERROR_RANDOM;
295
370
  goto cleanup;
296
371
  }
297
372
 
298
- ret = x25519_keypair(pk.x25519_pk, sk.x25519_sk);
373
+ ret = xwing_expand_secret_key(&expanded, seed);
299
374
  if (ret != PQ_SUCCESS) {
300
375
  goto cleanup;
301
376
  }
302
377
 
378
+ memcpy(pk.mlkem_pk, expanded.mlkem_pk, MLKEM_PUBLICKEYBYTES);
379
+ memcpy(pk.x25519_pk, expanded.x25519_pk, X25519_PUBLICKEYBYTES);
303
380
  memcpy(public_key, &pk, HYBRID_PUBLICKEYBYTES);
304
- memcpy(secret_key, &sk, HYBRID_SECRETKEYBYTES);
381
+ memcpy(secret_key, seed, HYBRID_SECRETKEYBYTES);
305
382
 
306
383
  cleanup:
307
- pq_secure_wipe(&sk, sizeof(sk));
308
-
309
- pq_secure_wipe(&pk, sizeof(pk));
384
+ pq_secure_wipe(seed, sizeof(seed));
385
+ pq_secure_wipe(&expanded, sizeof(expanded));
310
386
  return ret;
311
387
  }
312
388
 
@@ -357,20 +433,15 @@ cleanup:
357
433
  pq_secure_wipe(mlkem_ss, sizeof(mlkem_ss));
358
434
  pq_secure_wipe(x25519_ss, sizeof(x25519_ss));
359
435
  pq_secure_wipe(x25519_ephemeral_sk, sizeof(x25519_ephemeral_sk));
360
- pq_secure_wipe(&pk, sizeof(pk));
361
- pq_secure_wipe(&ct, sizeof(ct));
362
436
  return ret;
363
437
  }
364
438
 
365
439
  int pq_hybrid_kem_decapsulate(uint8_t *shared_secret, const uint8_t *ciphertext,
366
440
  const uint8_t *secret_key) {
367
441
  hybrid_ciphertext_t ct;
368
- hybrid_secret_key_t sk;
369
- uint8_t recipient_x25519_pk[X25519_PUBLICKEYBYTES];
442
+ hybrid_expanded_secret_key_t expanded;
370
443
  uint8_t mlkem_ss[MLKEM_SHAREDSECRETBYTES];
371
444
  uint8_t x25519_ss[X25519_SHAREDSECRETBYTES];
372
- EVP_PKEY *pkey = NULL;
373
- size_t pklen = X25519_PUBLICKEYBYTES;
374
445
  int ret = PQ_SUCCESS;
375
446
 
376
447
  if (!shared_secret || !ciphertext || !secret_key) {
@@ -378,44 +449,34 @@ int pq_hybrid_kem_decapsulate(uint8_t *shared_secret, const uint8_t *ciphertext,
378
449
  }
379
450
 
380
451
  memcpy(&ct, ciphertext, HYBRID_CIPHERTEXTBYTES);
381
- memcpy(&sk, secret_key, HYBRID_SECRETKEYBYTES);
382
- memset(recipient_x25519_pk, 0, sizeof(recipient_x25519_pk));
452
+ memset(&expanded, 0, sizeof(expanded));
383
453
  memset(mlkem_ss, 0, sizeof(mlkem_ss));
384
454
  memset(x25519_ss, 0, sizeof(x25519_ss));
385
455
 
386
- if (PQCLEAN_MLKEM768_CLEAN_crypto_kem_dec(mlkem_ss, ct.mlkem_ct, sk.mlkem_sk) != 0) {
387
- ret = PQ_ERROR_DECAPSULATE;
388
- goto cleanup;
389
- }
390
-
391
- ret = x25519_shared_secret(x25519_ss, ct.x25519_ephemeral, sk.x25519_sk);
456
+ ret = xwing_expand_secret_key(&expanded, secret_key);
392
457
  if (ret != PQ_SUCCESS) {
393
458
  ret = PQ_ERROR_DECAPSULATE;
394
459
  goto cleanup;
395
460
  }
396
461
 
397
- pkey = EVP_PKEY_new_raw_private_key(EVP_PKEY_X25519, NULL, sk.x25519_sk, X25519_SECRETKEYBYTES);
398
- if (!pkey) {
462
+ if (PQCLEAN_MLKEM768_CLEAN_crypto_kem_dec(mlkem_ss, ct.mlkem_ct, expanded.mlkem_sk) != 0) {
399
463
  ret = PQ_ERROR_DECAPSULATE;
400
464
  goto cleanup;
401
465
  }
402
- if (EVP_PKEY_get_raw_public_key(pkey, recipient_x25519_pk, &pklen) <= 0 ||
403
- pklen != X25519_PUBLICKEYBYTES) {
466
+
467
+ ret = x25519_shared_secret(x25519_ss, ct.x25519_ephemeral, expanded.x25519_sk);
468
+ if (ret != PQ_SUCCESS) {
404
469
  ret = PQ_ERROR_DECAPSULATE;
405
470
  goto cleanup;
406
471
  }
407
472
 
408
- ret = xwing_combiner(shared_secret, mlkem_ss, x25519_ss, ct.x25519_ephemeral,
409
- recipient_x25519_pk);
473
+ ret =
474
+ xwing_combiner(shared_secret, mlkem_ss, x25519_ss, ct.x25519_ephemeral, expanded.x25519_pk);
410
475
 
411
476
  cleanup:
412
- if (pkey)
413
- EVP_PKEY_free(pkey);
414
- pq_secure_wipe(recipient_x25519_pk, sizeof(recipient_x25519_pk));
415
477
  pq_secure_wipe(mlkem_ss, sizeof(mlkem_ss));
416
478
  pq_secure_wipe(x25519_ss, sizeof(x25519_ss));
417
- pq_secure_wipe(&ct, sizeof(ct));
418
- pq_secure_wipe(&sk, sizeof(sk));
479
+ pq_secure_wipe(&expanded, sizeof(expanded));
419
480
  return ret;
420
481
  }
421
482
 
@@ -429,8 +490,7 @@ cleanup:
429
490
 
430
491
  static const char PQC_OID_ML_KEM_768[] = "2.25.186599352125448088867056807454444238446";
431
492
 
432
- static const char PQC_OID_ML_KEM_768_X25519_XWING[] =
433
- "2.25.318532651283923671095712569430174917109";
493
+ static const char PQC_OID_ML_KEM_768_X25519_XWING[] = "1.3.6.1.4.1.62253.25722";
434
494
  static const char PQC_OID_ML_DSA_65[] = "2.25.305232938483772195555080795650659207792";
435
495
  static const char PQC_PUBLIC_KEY_PEM_LABEL[] = "PQC PUBLIC KEY CONTAINER";
436
496
  static const char PQC_PRIVATE_KEY_PEM_LABEL[] = "PQC PRIVATE KEY CONTAINER";
@@ -23,9 +23,11 @@
23
23
  #define X25519_PUBLICKEYBYTES 32
24
24
  #define X25519_SECRETKEYBYTES 32
25
25
  #define X25519_SHAREDSECRETBYTES 32
26
+ #define XWING_SEEDBYTES 32
27
+ #define XWING_EXPANDEDBYTES 96
26
28
 
27
29
  #define HYBRID_PUBLICKEYBYTES (MLKEM_PUBLICKEYBYTES + X25519_PUBLICKEYBYTES)
28
- #define HYBRID_SECRETKEYBYTES (MLKEM_SECRETKEYBYTES + X25519_SECRETKEYBYTES)
30
+ #define HYBRID_SECRETKEYBYTES XWING_SEEDBYTES
29
31
  #define HYBRID_CIPHERTEXTBYTES (MLKEM_CIPHERTEXTBYTES + X25519_PUBLICKEYBYTES)
30
32
  #define HYBRID_SHAREDSECRETBYTES 32
31
33
 
@@ -48,10 +50,16 @@ typedef struct {
48
50
  uint8_t x25519_pk[X25519_PUBLICKEYBYTES];
49
51
  } hybrid_public_key_t;
50
52
 
53
+ typedef struct {
54
+ uint8_t seed[XWING_SEEDBYTES];
55
+ } hybrid_secret_key_t;
56
+
51
57
  typedef struct {
52
58
  uint8_t mlkem_sk[MLKEM_SECRETKEYBYTES];
53
59
  uint8_t x25519_sk[X25519_SECRETKEYBYTES];
54
- } hybrid_secret_key_t;
60
+ uint8_t mlkem_pk[MLKEM_PUBLICKEYBYTES];
61
+ uint8_t x25519_pk[X25519_PUBLICKEYBYTES];
62
+ } hybrid_expanded_secret_key_t;
55
63
 
56
64
  typedef struct {
57
65
  uint8_t mlkem_ct[MLKEM_CIPHERTEXTBYTES];
data/lib/pq_crypto/kem.rb CHANGED
@@ -184,11 +184,11 @@ module PQCrypto
184
184
  alias eql? ==
185
185
 
186
186
  def hash
187
- fingerprint.hash
187
+ object_id.hash
188
188
  end
189
189
 
190
- def fingerprint
191
- Digest::SHA256.digest(@bytes)
190
+ def inspect
191
+ "#<#{self.class}:0x#{object_id.to_s(16)} algorithm=#{algorithm.inspect}>"
192
192
  end
193
193
 
194
194
  private
@@ -206,6 +206,10 @@ module PQCrypto
206
206
  @ciphertext = String(ciphertext).b
207
207
  @shared_secret = String(shared_secret).b
208
208
  end
209
+
210
+ def inspect
211
+ "#<#{self.class}:0x#{object_id.to_s(16)} ciphertext_bytes=#{@ciphertext.bytesize} shared_secret_bytes=#{@shared_secret.bytesize}>"
212
+ end
209
213
  end
210
214
  end
211
215
  end
@@ -9,7 +9,7 @@ module PQCrypto
9
9
  }.freeze,
10
10
  ml_kem_768_x25519_xwing: {
11
11
  family: :ml_kem_hybrid,
12
- oid: "2.25.318532651283923671095712569430174917109",
12
+ oid: "1.3.6.1.4.1.62253.25722",
13
13
  }.freeze,
14
14
  ml_dsa_65: {
15
15
  family: :ml_dsa,
@@ -188,11 +188,11 @@ module PQCrypto
188
188
  alias eql? ==
189
189
 
190
190
  def hash
191
- fingerprint.hash
191
+ object_id.hash
192
192
  end
193
193
 
194
- def fingerprint
195
- Digest::SHA256.digest(@bytes)
194
+ def inspect
195
+ "#<#{self.class}:0x#{object_id.to_s(16)} algorithm=#{algorithm.inspect}>"
196
196
  end
197
197
 
198
198
  private
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PQCrypto
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.1"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pq_crypto
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Haydarov
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-04-24 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rake
@@ -163,14 +163,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
163
163
  requirements:
164
164
  - - ">="
165
165
  - !ruby/object:Gem::Version
166
- version: 3.4.0.a
166
+ version: 3.4.0
167
167
  required_rubygems_version: !ruby/object:Gem::Requirement
168
168
  requirements:
169
169
  - - ">="
170
170
  - !ruby/object:Gem::Version
171
171
  version: '0'
172
172
  requirements: []
173
- rubygems_version: 3.6.2
173
+ rubygems_version: 3.6.7
174
174
  specification_version: 4
175
175
  summary: Primitive-first post-quantum cryptography for Ruby
176
176
  test_files: []