pq_crypto 0.3.1 → 0.4.2

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.
Files changed (117) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +56 -0
  3. data/CHANGELOG.md +50 -0
  4. data/GET_STARTED.md +374 -30
  5. data/README.md +59 -195
  6. data/SECURITY.md +101 -82
  7. data/ext/pqcrypto/extconf.rb +85 -9
  8. data/ext/pqcrypto/mldsa_api.h +71 -1
  9. data/ext/pqcrypto/mlkem_api.h +24 -0
  10. data/ext/pqcrypto/pq_externalmu.c +310 -0
  11. data/ext/pqcrypto/pqcrypto_ruby_secure.c +784 -85
  12. data/ext/pqcrypto/pqcrypto_secure.c +179 -72
  13. data/ext/pqcrypto/pqcrypto_secure.h +103 -7
  14. data/ext/pqcrypto/pqcrypto_version.h +7 -0
  15. data/ext/pqcrypto/vendor/.vendored +1 -1
  16. data/ext/pqcrypto/vendor/pqclean/common/keccak4x/Makefile +8 -0
  17. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/LICENSE +5 -0
  18. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/Makefile +19 -0
  19. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/Makefile.Microsoft_nmake +23 -0
  20. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/api.h +18 -0
  21. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/cbd.c +83 -0
  22. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/cbd.h +11 -0
  23. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/indcpa.c +327 -0
  24. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/indcpa.h +22 -0
  25. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/kem.c +164 -0
  26. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/kem.h +23 -0
  27. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/ntt.c +146 -0
  28. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/ntt.h +14 -0
  29. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/params.h +36 -0
  30. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/poly.c +311 -0
  31. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/poly.h +37 -0
  32. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/polyvec.c +198 -0
  33. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/polyvec.h +26 -0
  34. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/reduce.c +41 -0
  35. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/reduce.h +13 -0
  36. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/symmetric-shake.c +71 -0
  37. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/symmetric.h +30 -0
  38. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/verify.c +67 -0
  39. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-1024/clean/verify.h +13 -0
  40. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/LICENSE +5 -0
  41. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/Makefile +19 -0
  42. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/Makefile.Microsoft_nmake +23 -0
  43. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/api.h +18 -0
  44. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/cbd.c +108 -0
  45. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/cbd.h +11 -0
  46. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/indcpa.c +327 -0
  47. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/indcpa.h +22 -0
  48. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/kem.c +164 -0
  49. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/kem.h +23 -0
  50. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/ntt.c +146 -0
  51. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/ntt.h +14 -0
  52. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/params.h +36 -0
  53. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/poly.c +299 -0
  54. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/poly.h +37 -0
  55. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/polyvec.c +188 -0
  56. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/polyvec.h +26 -0
  57. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/reduce.c +41 -0
  58. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/reduce.h +13 -0
  59. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/symmetric-shake.c +71 -0
  60. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/symmetric.h +30 -0
  61. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/verify.c +67 -0
  62. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-512/clean/verify.h +13 -0
  63. data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-768/clean/Makefile +19 -0
  64. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/LICENSE +5 -0
  65. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/Makefile +19 -0
  66. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/Makefile.Microsoft_nmake +23 -0
  67. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/api.h +50 -0
  68. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/ntt.c +98 -0
  69. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/ntt.h +10 -0
  70. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/packing.c +261 -0
  71. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/packing.h +31 -0
  72. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/params.h +44 -0
  73. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/poly.c +848 -0
  74. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/poly.h +52 -0
  75. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/polyvec.c +415 -0
  76. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/polyvec.h +65 -0
  77. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/reduce.c +69 -0
  78. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/reduce.h +17 -0
  79. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/rounding.c +98 -0
  80. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/rounding.h +14 -0
  81. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/sign.c +407 -0
  82. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/sign.h +47 -0
  83. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/symmetric-shake.c +26 -0
  84. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-44/clean/symmetric.h +34 -0
  85. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-65/clean/Makefile +19 -0
  86. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/LICENSE +5 -0
  87. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/Makefile +19 -0
  88. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/Makefile.Microsoft_nmake +23 -0
  89. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/api.h +50 -0
  90. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/ntt.c +98 -0
  91. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/ntt.h +10 -0
  92. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/packing.c +261 -0
  93. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/packing.h +31 -0
  94. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/params.h +44 -0
  95. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/poly.c +823 -0
  96. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/poly.h +52 -0
  97. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/polyvec.c +415 -0
  98. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/polyvec.h +65 -0
  99. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/reduce.c +69 -0
  100. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/reduce.h +17 -0
  101. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/rounding.c +92 -0
  102. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/rounding.h +14 -0
  103. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/sign.c +407 -0
  104. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/sign.h +47 -0
  105. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/symmetric-shake.c +26 -0
  106. data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-87/clean/symmetric.h +34 -0
  107. data/lib/pq_crypto/algorithm_registry.rb +200 -0
  108. data/lib/pq_crypto/hybrid_kem.rb +1 -12
  109. data/lib/pq_crypto/kem.rb +104 -13
  110. data/lib/pq_crypto/pkcs8.rb +387 -0
  111. data/lib/pq_crypto/serialization.rb +1 -14
  112. data/lib/pq_crypto/signature.rb +231 -13
  113. data/lib/pq_crypto/spki.rb +131 -0
  114. data/lib/pq_crypto/version.rb +1 -1
  115. data/lib/pq_crypto.rb +90 -19
  116. data/script/vendor_libs.rb +4 -0
  117. metadata +99 -3
data/README.md CHANGED
@@ -1,237 +1,101 @@
1
1
  # pq_crypto
2
2
 
3
3
  `pq_crypto` is a primitive-first Ruby gem for post-quantum cryptography.
4
+ It provides small Ruby APIs for ML-KEM, ML-DSA, and one hybrid X-Wing KEM,
5
+ with standard key serialization where available.
4
6
 
5
- It exposes three public building blocks:
6
-
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
12
-
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`, `BIO_f_base64`) — nothing
17
- roll-your-own where a library primitive exists.
18
-
19
- ## Status
20
-
21
- - primitive-first API only
22
- - no protocol/session helpers in the public surface
23
- - serialization uses pq_crypto-specific `pqc_container_*` wrappers
24
- - not audited
25
- - not yet positioned as production-ready
7
+ The gem intentionally stays close to cryptographic primitives. It does not
8
+ provide protocol, session, transport, certificate-chain, or application-level
9
+ handshake helpers.
26
10
 
27
11
  ## Installation
28
12
 
29
- Add the gem to your project and compile the extension:
13
+ Add the gem to your `Gemfile`:
30
14
 
31
15
  ```ruby
32
16
  # Gemfile
33
17
  gem "pq_crypto"
34
18
  ```
35
19
 
20
+ Then install it:
21
+
36
22
  ```bash
37
23
  bundle install
38
- bundle exec rake compile
39
- ```
40
-
41
- ### Native dependencies
42
-
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 and SHAKE256 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
- ```
90
-
91
- ## Primitive API
92
-
93
- ### ML-KEM-768
94
-
95
- ```ruby
96
- keypair = PQCrypto::KEM.generate(:ml_kem_768)
97
- result = keypair.public_key.encapsulate
98
- shared_secret = keypair.secret_key.decapsulate(result.ciphertext)
99
- ```
100
-
101
- ### ML-DSA-65
102
-
103
- ```ruby
104
- keypair = PQCrypto::Signature.generate(:ml_dsa_65)
105
- signature = keypair.secret_key.sign("hello")
106
-
107
- keypair.public_key.verify("hello", signature) # => true / false
108
- keypair.public_key.verify!("hello", signature) # raises on mismatch
109
- ```
110
-
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)
116
-
117
- ```ruby
118
- keypair = PQCrypto::HybridKEM.generate(:ml_kem_768_x25519_xwing)
119
- result = keypair.public_key.encapsulate
120
- shared_secret = keypair.secret_key.decapsulate(result.ciphertext)
121
24
  ```
122
25
 
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:
26
+ When working from a source checkout, compile the native extension before
27
+ running tests or examples:
126
28
 
127
- ```
128
- ss = SHA3-256( ss_M || ss_X || ct_X || pk_X || "\.//^\" )
29
+ ```bash
30
+ bundle exec rake compile
31
+ bundle exec rake test
129
32
  ```
130
33
 
131
- as specified by `draft-connolly-cfrg-xwing-kem-10`. See `SECURITY.md`
132
- for audit status and interoperability caveats.
34
+ ## What this gem provides
133
35
 
134
- ## Serialization
36
+ | Area | Capabilities |
37
+ | --- | --- |
38
+ | ML-KEM | Key generation, encapsulation, decapsulation, raw key import/export, SPKI public keys, PKCS#8 private keys. |
39
+ | ML-DSA | Key generation, signing, verification, streaming signing/verification for large inputs, raw key import/export, SPKI public keys, PKCS#8 private keys. |
40
+ | Hybrid KEM | ML-KEM-768 + X25519 using the X-Wing combiner. |
41
+ | Serialization | Standard SPKI / PKCS#8 for NIST PQC keys, plus frozen `pqc_container_*` compatibility formats for the original algorithms. |
42
+ | Safety helpers | Best-effort secret wiping and constant-time equality for key comparisons. |
43
+ | Introspection | Supported algorithm lists, algorithm metadata, backend/version helpers. |
135
44
 
136
- Key import/export is available through pq_crypto-specific containers:
45
+ ## Supported algorithms
137
46
 
138
- - `to_pqc_container_der`
139
- - `to_pqc_container_pem`
140
- - `*_from_pqc_container_der`
141
- - `*_from_pqc_container_pem`
47
+ | Family | Algorithms | Notes |
48
+ | --- | --- | --- |
49
+ | KEM | `:ml_kem_512`, `:ml_kem_768`, `:ml_kem_1024` | FIPS 203 ML-KEM. Standard SPKI public keys and PKCS#8 private keys. |
50
+ | Signature | `:ml_dsa_44`, `:ml_dsa_65`, `:ml_dsa_87` | FIPS 204 ML-DSA. Standard SPKI public keys and PKCS#8 private keys. |
51
+ | Hybrid KEM | `:ml_kem_768_x25519_xwing` | ML-KEM-768 + X25519 hybrid KEM using the X-Wing construction. |
142
52
 
143
- Example:
53
+ Standard encodings use RFC 9935 OIDs for ML-KEM and RFC 9881 OIDs for
54
+ ML-DSA. `AlgorithmIdentifier.parameters` are omitted, not encoded as `NULL`.
144
55
 
145
- ```ruby
146
- keypair = PQCrypto::KEM.generate(:ml_kem_768)
147
- der = keypair.public_key.to_pqc_container_der
148
- imported = PQCrypto::KEM.public_key_from_pqc_container_der(der)
149
- ```
150
-
151
- These containers are **not real ASN.1 SPKI or PKCS#8**. They are
152
- intended for stable import/export inside `pq_crypto` itself and are
153
- not advertised as interoperable with external PKI tooling.
56
+ The `pqc_container_*` format is project-local and kept only for backward
57
+ compatibility. It is not ASN.1, SPKI, or PKCS#8. It remains limited to the
58
+ original algorithms:
154
59
 
155
- ## Secure wiping
60
+ - `:ml_kem_768`
61
+ - `:ml_dsa_65`
62
+ - `:ml_kem_768_x25519_xwing`
156
63
 
157
- `PQCrypto.secure_wipe(str)` zeros the bytes of a mutable Ruby string
158
- in place. Key objects hold a private copy of their bytes, so `wipe!`
159
- on a `SecretKey` zeroes **only** that internal copy — any prior Ruby
160
- string the caller holds is untouched. If you need to wipe the
161
- caller-side buffer, do so explicitly:
64
+ ## Requirements
162
65
 
163
- ```ruby
164
- raw = File.binread(path)
165
- key = PQCrypto::KEM.secret_key_from_bytes(:ml_kem_768, raw)
166
- PQCrypto.secure_wipe(raw) # scrub the original input
167
- # ... use key ...
168
- key.wipe! # scrub the key's internal copy
169
- ```
66
+ - Ruby 3.4 or later
67
+ - a C toolchain with C11 support
68
+ - OpenSSL 3.0 or later with SHA3-256 and SHAKE256 available
170
69
 
171
- ## Constant-time comparison
70
+ ## Security status
172
71
 
173
- `==` on `PublicKey` / `SecretKey` instances uses OpenSSL
174
- `CRYPTO_memcmp` through a `PQCrypto.ct_equals` helper so comparisons
175
- do not leak timing information about a prefix match.
72
+ `pq_crypto` is experimental and not audited. Treat it as a low-level primitive
73
+ library, not a complete security protocol. See [`SECURITY.md`](SECURITY.md) for
74
+ audit status, serialization caveats, hybrid-KEM notes, and interoperability
75
+ warnings.
176
76
 
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
-
183
- ## Introspection
77
+ ## Useful entry points
184
78
 
185
79
  ```ruby
186
80
  PQCrypto.version
187
81
  PQCrypto.backend
188
82
  PQCrypto.supported_kems
189
- PQCrypto.supported_hybrid_kems
190
83
  PQCrypto.supported_signatures
191
- PQCrypto::KEM.details(:ml_kem_768)
192
- PQCrypto::HybridKEM.details(:ml_kem_768_x25519_xwing)
193
- PQCrypto::Signature.details(:ml_dsa_65)
194
- ```
195
-
196
- ## Testing helpers
197
-
198
- Deterministic test hooks are exposed under `PQCrypto::Testing` for
199
- regression coverage:
200
-
201
- - `ml_kem_keypair_from_seed` — requires a 64-byte `d||z` seed (FIPS 203)
202
- - `ml_kem_encapsulate_from_seed` — requires a 32-byte seed
203
- - `ml_dsa_keypair_from_seed` — requires a 32-byte seed
204
- - `ml_dsa_sign_from_seed` — requires a 32-byte seed
205
-
206
- These helpers are intended for tests only. They work by installing a
207
- thread-local seed-replay mode inside the gem's `randombytes()` for
208
- the duration of the call, then call the stock PQClean entrypoints.
209
- No internal PQClean algorithm logic is reimplemented in this gem.
210
-
211
- ## Development
212
-
213
- Run the test suite with:
84
+ PQCrypto.supported_hybrid_kems
214
85
 
215
- ```bash
216
- bundle exec rake test
86
+ PQCrypto::KEM.generate(:ml_kem_768)
87
+ PQCrypto::Signature.generate(:ml_dsa_65)
88
+ PQCrypto::HybridKEM.generate(:ml_kem_768_x25519_xwing)
217
89
  ```
218
90
 
219
- Refresh vendored PQClean sources manually only when you intentionally
220
- update the vendor snapshot. The refresh script has a safe pinned
221
- default and records the exact vendored snapshot in
222
- `ext/pqcrypto/vendor/.vendored`:
91
+ ## More examples
223
92
 
224
- ```bash
225
- bundle exec ruby script/vendor_libs.rb
226
- ```
93
+ Detailed usage examples live in [`GET_STARTED.md`](GET_STARTED.md):
227
94
 
228
- To intentionally change the upstream snapshot, override all four
229
- pinning inputs together:
230
-
231
- ```bash
232
- PQCLEAN_VERSION=<full-git-commit> \
233
- PQCLEAN_URL=https://github.com/PQClean/PQClean/archive/<full-git-commit>.tar.gz \
234
- PQCLEAN_SHA256=<archive-sha256> \
235
- PQCLEAN_STRIP=PQClean-<full-git-commit> \
236
- bundle exec ruby script/vendor_libs.rb
237
- ```
95
+ - generating keys
96
+ - ML-KEM encapsulation / decapsulation
97
+ - ML-DSA signing / verification
98
+ - streaming ML-DSA for large files
99
+ - SPKI and PKCS#8 serialization
100
+ - `pqc_container_*` compatibility serialization
101
+ - secure wiping and practical safety notes
data/SECURITY.md CHANGED
@@ -4,125 +4,144 @@
4
4
 
5
5
  `pq_crypto` exposes a primitive-first public surface:
6
6
 
7
- - `PQCrypto::KEM` (`ML-KEM-768`)
8
- - `PQCrypto::Signature` (`ML-DSA-65`)
9
- - `PQCrypto::HybridKEM` (`ML-KEM-768 + X25519` via the X-Wing combiner)
7
+ - `PQCrypto::KEM` ML-KEM-512, ML-KEM-768, ML-KEM-1024
8
+ - `PQCrypto::Signature` ML-DSA-44, ML-DSA-65, ML-DSA-87
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
+ - `PQCrypto.ct_equals`
12
12
 
13
- The gem does **not** publish protocol/session helpers as part of the
14
- supported public API.
13
+ The gem does not publish protocol/session helpers as part of the supported
14
+ public API.
15
15
 
16
16
  ## Audit status
17
17
 
18
18
  This project has not been audited. Treat it as experimental software.
19
19
 
20
+ The test surface includes deterministic regression tests, NIST ACVP KAT test
21
+ infrastructure, and OpenSSL 3.5+ interoperability tests for standard SPKI /
22
+ PKCS#8 encodings where the linked OpenSSL exposes the corresponding ML-KEM /
23
+ ML-DSA EVP support. These tests improve compatibility coverage but are not a
24
+ substitute for a security audit.
25
+
20
26
  ## Algorithm notes
21
27
 
22
- ### ML-KEM-768 / ML-DSA-65
28
+ ### ML-KEM / ML-DSA
23
29
 
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.
30
+ The post-quantum primitives are backed by vendored `PQClean` sources and called
31
+ through PQClean's public `crypto_kem_*` and `crypto_sign_*` entrypoints. The gem
32
+ does not reimplement ML-KEM, ML-DSA, SHAKE, or Keccak.
28
33
 
29
34
  ### HybridKEM
30
35
 
31
- `PQCrypto::HybridKEM` implements the **X-Wing** construction from
32
- [`draft-connolly-cfrg-xwing-kem-10`](https://datatracker.ietf.org/doc/draft-connolly-cfrg-xwing-kem/).
33
-
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.
36
+ `PQCrypto::HybridKEM` implements the X-Wing construction from
37
+ `draft-connolly-cfrg-xwing-kem-10`.
38
38
 
39
- ss = SHA3-256( ss_M || ss_X || ct_X || pk_X || XWingLabel )
39
+ The X-Wing secret decapsulation key is a 32-byte seed. It is expanded with
40
+ SHAKE256 into the ML-KEM-768 and X25519 private material used internally for
41
+ decapsulation. The public key and ciphertext are the fixed-length
42
+ concatenations specified by the draft.
40
43
 
41
- where `XWingLabel = "\.//^\"` (6 ASCII bytes).
44
+ ```text
45
+ ss = SHA3-256( ss_M || ss_X || ct_X || pk_X || XWingLabel )
46
+ ```
42
47
 
43
- X-Wing as specified has a proof of classical IND-CCA security under
44
- the strong Diffie-Hellman assumption for X25519 (in the ROM), and
45
- post-quantum IND-CCA security in the standard model assuming ML-KEM-768
46
- is IND-CCA secure and SHA3-256 behaves as a PRF.
48
+ where `XWingLabel = "\.//^\"`.
47
49
 
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
+ External interoperability should be verified against the reference
50
51
  implementation before relying on it.
51
52
 
52
- ### Deterministic test hooks
53
+ ## Serialization formats
53
54
 
54
- `PQCrypto::Testing` deterministic helpers drive the stock PQClean
55
- `crypto_sign_keypair` / `crypto_sign_signature` (for ML-DSA) and
56
- `crypto_kem_keypair_derand` / `crypto_kem_enc_derand` (for ML-KEM)
57
- against a caller-supplied seed. For ML-DSA, which has no derand API
58
- upstream, the gem installs a thread-local seed-replay buffer inside
59
- its `randombytes()` implementation; outside of a test call the same
60
- `randombytes()` entry delegates directly to OpenSSL `RAND_bytes`. No
61
- internal PQClean algorithm logic is reimplemented in this gem.
55
+ ### pq_crypto-local `pqc_container_*`
62
56
 
63
- ## Serialization
57
+ `pqc_container_*` DER/PEM wrappers are pq_crypto-specific containers. They are:
64
58
 
65
- `pqc_container_*` DER/PEM wrappers are pq_crypto-specific containers.
59
+ - not ASN.1
60
+ - not SPKI
61
+ - not PKCS#8
62
+ - not advertised as interoperable with OpenSSL, Go, Java, or PKI tooling
66
63
 
67
- They are:
64
+ This format is frozen for backward compatibility and remains limited to the
65
+ original three algorithms:
68
66
 
69
- - not real SPKI
70
- - not real PKCS#8
71
- - not advertised as interoperable with OpenSSL, Go, Java, or PKI tooling
67
+ - `:ml_kem_768`
68
+ - `:ml_dsa_65`
69
+ - `:ml_kem_768_x25519_xwing`
70
+
71
+ ### Standard SPKI / PKCS#8
72
+
73
+ ML-KEM and ML-DSA use standard SPKI public-key and PKCS#8 private-key encodings
74
+ for the NIST parameter sets. AlgorithmIdentifier parameters are absent, not
75
+ encoded as `NULL`.
76
+
77
+ | Algorithm | Standard OID | Reference |
78
+ | --- | --- | --- |
79
+ | ML-KEM-512 | `2.16.840.1.101.3.4.4.1` | RFC 9935 |
80
+ | ML-KEM-768 | `2.16.840.1.101.3.4.4.2` | RFC 9935 |
81
+ | ML-KEM-1024 | `2.16.840.1.101.3.4.4.3` | RFC 9935 |
82
+ | ML-DSA-44 | `2.16.840.1.101.3.4.3.17` | RFC 9881 |
83
+ | ML-DSA-65 | `2.16.840.1.101.3.4.3.18` | RFC 9881 |
84
+ | ML-DSA-87 | `2.16.840.1.101.3.4.3.19` | RFC 9881 |
85
+
86
+ `PQCrypto::KEM.details` / `PQCrypto::Signature.details` keep `:oid` as the
87
+ legacy `pqc_container_*` OID for backward compatibility. Use
88
+ `PQCrypto::AlgorithmRegistry.standard_oid` for the standard OID.
72
89
 
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`.
90
+ ## ML-DSA seed-format imports
76
91
 
77
- The hybrid OID used by 0.2.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.
92
+ ML-DSA seed and both-form PKCS#8 imports are disabled by default. To import
93
+ these encodings, callers must explicitly set:
94
+
95
+ ```ruby
96
+ PQCrypto::PKCS8.allow_ml_dsa_seed_format = true
97
+ ```
98
+
99
+ This opt-in exists because PQClean exposes no public ML-DSA
100
+ `crypto_sign_keypair_derand` entrypoint. The implementation therefore reuses the
101
+ same thread-local seed-replay `randombytes()` path introduced for KAT tests to
102
+ expand the RFC 9881 seed into an expanded private key. The replay buffer is
103
+ thread-local, cleared immediately after expansion, and remains inactive for all
104
+ normal production randomness paths.
105
+
106
+ For `both` encodings, the decoder expands the seed and rejects the key if the
107
+ expandedKey half does not match the seed-derived key.
108
+
109
+ ## Deterministic test hooks
110
+
111
+ `PQCrypto::Testing` deterministic helpers drive the stock PQClean entrypoints
112
+ against caller-supplied seeds. For ML-DSA, which has no derand API upstream, the
113
+ gem installs a thread-local seed-replay buffer inside its `randombytes()`
114
+ implementation; outside of a test call the same `randombytes()` entry delegates
115
+ directly to OpenSSL `RAND_bytes`.
83
116
 
84
117
  ## Memory wiping
85
118
 
86
- `PQCrypto.secure_wipe` clears mutable Ruby strings in place. Ruby key
87
- objects (`PublicKey`, `SecretKey`) take a copy of the bytes passed into
88
- their constructor and expose `#wipe!` to zero only that internal copy
89
- any prior Ruby string the caller still holds is untouched. Ruby
90
- garbage collection and prior derived copies may still leave sensitive
91
- material elsewhere in process memory.
119
+ `PQCrypto.secure_wipe` clears mutable Ruby strings in place. Ruby key objects
120
+ take a copy of the bytes passed into their constructor and expose `#wipe!` to
121
+ zero only that internal copy. Ruby garbage collection and prior derived copies
122
+ may still leave sensitive material elsewhere in process memory.
123
+
124
+ Secret key objects redact `inspect` output and intentionally do not expose a
125
+ public `fingerprint` method. This avoids accidental logging of raw secret bytes
126
+ or stable secret-derived identifiers.
92
127
 
93
128
  ## OpenSSL baseline
94
129
 
95
- `pq_crypto` requires OpenSSL **3.0 or later**.
130
+ `pq_crypto` requires OpenSSL 3.0 or later.
96
131
 
97
132
  OpenSSL is used for:
98
133
 
99
- - `X25519` key generation and key agreement (`EVP_PKEY_*`)
100
- - `SHA3-256` (X-Wing combiner, via `EVP_sha3_256`)
101
- - `RAND_bytes` (production entropy source for `randombytes()`)
102
- - `CRYPTO_memcmp` (constant-time comparison used by `PQCrypto.ct_equals`)
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.
134
+ - X25519 key generation and key agreement
135
+ - SHA3-256 for the X-Wing combiner
136
+ - RAND_bytes as the production entropy source for `randombytes()`
137
+ - CRYPTO_memcmp for constant-time comparison
138
+ - Base64 encode/decode for PEM via OpenSSL BIOs
111
139
 
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.
140
+ OpenSSL 3.5+ is additionally used in interop tests when ML-KEM / ML-DSA EVP
141
+ support is available.
116
142
 
117
143
  ## Threading
118
144
 
119
145
  Concurrent read-only operations on primitive key objects are supported.
120
- Native calls copy Ruby string inputs before releasing the GVL, so
121
- normal concurrent use does not rely on Ruby string storage remaining
122
- pinned in place.
123
-
124
- The deterministic test hooks use a thread-local seed-replay mode
125
- around `randombytes()`, so a test running on one thread does not
126
- affect production callers on other threads. The deterministic helpers
127
- remain test-only utilities and should not be relied on as a general
128
- multi-threading contract.
146
+ Mutating operations such as `wipe!` must not race with other uses of the same
147
+ object.
@@ -2,6 +2,27 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "mkmf"
5
+ require_relative "../../lib/pq_crypto/version"
6
+
7
+ def generate_version_header!
8
+ version = PQCrypto::VERSION
9
+ unless version.match?(/\A[0-9A-Za-z][0-9A-Za-z._+-]*\z/)
10
+ abort "Invalid PQCrypto::VERSION for C header: #{version.inspect}"
11
+ end
12
+
13
+ header = File.join(__dir__, "pqcrypto_version.h")
14
+ File.write(header, <<~C)
15
+ /* Generated by extconf.rb from lib/pq_crypto/version.rb. Do not edit. */
16
+ #ifndef PQCRYPTO_VERSION_H
17
+ #define PQCRYPTO_VERSION_H
18
+
19
+ #define PQCRYPTO_VERSION #{version.dump}
20
+
21
+ #endif
22
+ C
23
+ end
24
+
25
+ generate_version_header!
5
26
 
6
27
  $CFLAGS << " -std=c11 -Wall -Wextra -O2"
7
28
  $CFLAGS << " -fstack-protector-strong -D_FORTIFY_SOURCE=2"
@@ -11,6 +32,9 @@ $LDFLAGS << " -Wl,-no_warn_duplicate_libraries" if RbConfig::CONFIG["host_os"] =
11
32
 
12
33
  USE_SYSTEM = arg_config("--use-system-libraries") || ENV["PQCRYPTO_USE_SYSTEM_LIBRARIES"]
13
34
 
35
+ KECCAK_BACKEND = (ENV["PQCRYPTO_KECCAK_BACKEND"] || "clean").strip.downcase
36
+ SUPPORTED_KECCAK_BACKENDS = %w[clean xkcp].freeze
37
+
14
38
  SANITIZE = ENV["PQCRYPTO_SANITIZE"]
15
39
 
16
40
  if SANITIZE && !SANITIZE.strip.empty?
@@ -85,27 +109,77 @@ def configure_openssl!
85
109
  $CFLAGS << " -DHAVE_OPENSSL_EVP_H -DHAVE_OPENSSL_RAND_H"
86
110
  end
87
111
 
112
+ def configure_keccak_backend(vendor_dir, common_dir)
113
+ abort "Unsupported PQCRYPTO_KECCAK_BACKEND=#{KECCAK_BACKEND.inspect}. Supported: #{SUPPORTED_KECCAK_BACKENDS.join(", ")}" unless SUPPORTED_KECCAK_BACKENDS.include?(KECCAK_BACKEND)
114
+
115
+ case KECCAK_BACKEND
116
+ when "clean"
117
+ {
118
+ name: "clean",
119
+ include_dirs: [],
120
+ source_group: ["pqclean_common", [File.join(common_dir, "fips202.c")]]
121
+ }
122
+ when "xkcp"
123
+ # The optimized backend must provide the same fips202.h-compatible API as
124
+ # PQClean's common/fips202.c. Do not substitute OpenSSL EVP SHAKE here: the
125
+ # PQClean SHAKE state layout is part of the ML-KEM/ML-DSA call graph.
126
+ xkcp_dir = File.join(vendor_dir, "xkcp")
127
+ adapter_source = File.join(xkcp_dir, "pqclean_fips202_xkcp.c")
128
+
129
+ abort <<~MSG unless File.exist?(adapter_source)
130
+ PQCRYPTO_KECCAK_BACKEND=xkcp was requested, but no reviewed XKCP adapter was found.
131
+
132
+ Expected:
133
+ #{adapter_source}
134
+
135
+ Refusing to fall back silently to the clean backend. Vendor a fips202.h-compatible
136
+ XKCP adapter first, then run the full SHAKE-dependent KAT/regression test matrix.
137
+ MSG
138
+
139
+ {
140
+ name: "xkcp",
141
+ include_dirs: [xkcp_dir],
142
+ source_group: ["xkcp_keccak", [adapter_source]]
143
+ }
144
+ end
145
+ end
146
+
88
147
  def configure_pqclean(vendor_dir)
89
148
  return nil unless vendor_dir
90
149
 
91
150
  pqclean_dir = File.join(vendor_dir, "pqclean")
92
151
  return nil unless Dir.exist?(pqclean_dir)
93
152
 
94
- mlkem_dir = File.join(pqclean_dir, "crypto_kem", "ml-kem-768", "clean")
95
- mldsa_dir = File.join(pqclean_dir, "crypto_sign", "ml-dsa-65", "clean")
153
+ mlkem_dirs = {
154
+ "pqclean_mlkem512" => File.join(pqclean_dir, "crypto_kem", "ml-kem-512", "clean"),
155
+ "pqclean_mlkem768" => File.join(pqclean_dir, "crypto_kem", "ml-kem-768", "clean"),
156
+ "pqclean_mlkem1024" => File.join(pqclean_dir, "crypto_kem", "ml-kem-1024", "clean")
157
+ }
158
+ mldsa_dirs = {
159
+ "pqclean_mldsa44" => File.join(pqclean_dir, "crypto_sign", "ml-dsa-44", "clean"),
160
+ "pqclean_mldsa65" => File.join(pqclean_dir, "crypto_sign", "ml-dsa-65", "clean"),
161
+ "pqclean_mldsa87" => File.join(pqclean_dir, "crypto_sign", "ml-dsa-87", "clean")
162
+ }
96
163
  common_dir = File.join(pqclean_dir, "common")
97
164
 
98
- include_dirs = [mlkem_dir, mldsa_dir, common_dir]
165
+ keccak_config = configure_keccak_backend(vendor_dir, common_dir)
166
+
167
+ include_dirs = [*mlkem_dirs.values, *mldsa_dirs.values, common_dir, *keccak_config[:include_dirs]]
99
168
  return nil unless include_dirs.all? { |dir| Dir.exist?(dir) }
100
169
 
101
- mlkem_sources = Dir.glob(File.join(mlkem_dir, "*.c")).sort
102
- mldsa_sources = Dir.glob(File.join(mldsa_dir, "*.c")).sort
103
- common_sources = %w[fips202.c sha2.c sp800-185.c].map { |name| File.join(common_dir, name) }
170
+ mlkem_source_groups = mlkem_dirs.map do |prefix, dir|
171
+ [prefix, Dir.glob(File.join(dir, "*.c")).sort]
172
+ end
173
+ mldsa_source_groups = mldsa_dirs.map do |prefix, dir|
174
+ [prefix, Dir.glob(File.join(dir, "*.c")).sort]
175
+ end
176
+ common_sources = %w[sha2.c sp800-185.c].map { |name| File.join(common_dir, name) }
104
177
 
105
178
  source_groups = [
106
- ["pqclean_mlkem", mlkem_sources],
107
- ["pqclean_mldsa", mldsa_sources],
108
- ["pqclean_common", common_sources]
179
+ *mlkem_source_groups,
180
+ *mldsa_source_groups,
181
+ ["pqclean_common", common_sources],
182
+ keccak_config[:source_group]
109
183
  ]
110
184
 
111
185
  return nil unless source_groups.all? { |_, sources| sources.all? { |path| File.exist?(path) } }
@@ -115,6 +189,7 @@ def configure_pqclean(vendor_dir)
115
189
 
116
190
  {
117
191
  include_dirs: include_dirs,
192
+ keccak_backend: keccak_config[:name],
118
193
  source_groups: source_groups
119
194
  }
120
195
  end
@@ -165,6 +240,7 @@ pqclean_config = configure_pqclean(vendor_dir)
165
240
  puts "OpenSSL: system"
166
241
  abort "PQClean vendored sources are required. Run: bundle exec rake vendor" unless pqclean_config
167
242
  puts "PQClean: vendored (randombytes overridden by pq_randombytes.c)"
243
+ puts "Keccak backend: #{pqclean_config[:keccak_backend]}"
168
244
  puts "Output: pqcrypto/pqcrypto_secure"
169
245
  puts "===================================="
170
246