pq_crypto 0.2.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 +4 -4
- data/CHANGELOG.md +100 -0
- data/GET_STARTED.md +19 -9
- data/README.md +90 -26
- data/SECURITY.md +84 -13
- data/ext/pqcrypto/extconf.rb +27 -12
- data/ext/pqcrypto/pq_randombytes.c +56 -0
- data/ext/pqcrypto/pqcrypto_ruby_secure.c +35 -20
- data/ext/pqcrypto/pqcrypto_secure.c +319 -525
- data/ext/pqcrypto/pqcrypto_secure.h +23 -4
- data/lib/pq_crypto/errors.rb +12 -6
- data/lib/pq_crypto/hybrid_kem.rb +2 -2
- data/lib/pq_crypto/kem.rb +20 -4
- data/lib/pq_crypto/serialization.rb +2 -2
- data/lib/pq_crypto/signature.rb +26 -18
- data/lib/pq_crypto/version.rb +1 -1
- data/lib/pq_crypto.rb +42 -73
- data/script/vendor_libs.rb +0 -1
- metadata +5 -7
- data/ext/pqcrypto/vendor/pqclean/common/keccak4x/Makefile +0 -8
- data/ext/pqcrypto/vendor/pqclean/crypto_kem/ml-kem-768/clean/Makefile +0 -19
- data/ext/pqcrypto/vendor/pqclean/crypto_sign/ml-dsa-65/clean/Makefile +0 -19
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 534c420f62323e9ddb49b48acdfa7af869731e3273bda56637d6bfee753de8d7
|
|
4
|
+
data.tar.gz: f003494b4d33702a1b718dc6ded7a9f0612b315e41f0d739a5b2fe3ebba9d1d1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1a039325a13cf8c074741fb70edc1e66f4e4939727b4c1369665794bc6ed71ef1ed2ef50d9debd9064d850e4856fac166f325889a9df4bfd3d8849a2d7a918e9
|
|
7
|
+
data.tar.gz: 304888656181149eed84eca063e24eeb6c862819f6c54022e77b287ca4030b8602e9f61eb17809ad86ca0eea84796c6275a2afa12018ffdeaafc489c15c8f24a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,105 @@
|
|
|
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
|
+
|
|
18
|
+
## [0.3.0] — 2026-04-24
|
|
19
|
+
|
|
20
|
+
**Breaking release.** Hybrid KEM keys, ciphertexts, and `pqc_container_*`
|
|
21
|
+
blobs produced by 0.2.0 are not compatible with 0.3.0. Pure ML-KEM-768
|
|
22
|
+
and ML-DSA-65 material is unaffected.
|
|
23
|
+
|
|
24
|
+
### Changed — hybrid KEM (breaking)
|
|
25
|
+
|
|
26
|
+
- Replaced the 0.2.0 ad-hoc `HKDF-SHA256`-with-double-transcript combiner
|
|
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.
|
|
29
|
+
- Renamed the hybrid algorithm symbol
|
|
30
|
+
`:ml_kem_768_x25519_hkdf_sha256` → `:ml_kem_768_x25519_xwing`.
|
|
31
|
+
- Retired the 0.2.0 project-local hybrid OID
|
|
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.
|
|
35
|
+
|
|
36
|
+
### Changed — native code hygiene
|
|
37
|
+
|
|
38
|
+
- Removed the copy of PQClean internal ML-DSA keypair and signature logic
|
|
39
|
+
that 0.2.0 used to implement deterministic test hooks. Tests now drive
|
|
40
|
+
the stock PQClean `crypto_sign_keypair` / `crypto_sign_signature`
|
|
41
|
+
through a new `randombytes()` override
|
|
42
|
+
(`ext/pqcrypto/pq_randombytes.c`) that swaps in a thread-local
|
|
43
|
+
seed-replay mode for the duration of a deterministic call and delegates
|
|
44
|
+
to `OpenSSL RAND_bytes` otherwise.
|
|
45
|
+
- Deleted the non-FIPS 32-byte ML-KEM seed with HKDF expansion; the
|
|
46
|
+
deterministic ML-KEM keypair hook now accepts only the
|
|
47
|
+
FIPS 203 64-byte `d||z` seed.
|
|
48
|
+
- Replaced `uint8_t*` → `hybrid_*_t*` strict-aliasing casts with
|
|
49
|
+
explicit `memcpy` into typed stack locals throughout the hybrid path.
|
|
50
|
+
- Added `_Static_assert` guards on the byte-packed layout of
|
|
51
|
+
`hybrid_public_key_t`, `hybrid_secret_key_t`, and
|
|
52
|
+
`hybrid_ciphertext_t` so any future change that introduces padding
|
|
53
|
+
fails at compile time rather than silently shifting byte offsets.
|
|
54
|
+
- Migrated PEM codec to OpenSSL `BIO_f_base64` with stricter PEM
|
|
55
|
+
header/footer framing and trailing-garbage checks.
|
|
56
|
+
- Deleted the entire internal HKDF and SHA-256 helper paths that 0.2.0
|
|
57
|
+
used for its combiner; the X-Wing combiner is a single SHA3-256
|
|
58
|
+
invocation through `EVP_DigestUpdate`.
|
|
59
|
+
- Tightened `extconf.rb`: the broad `-Wno-unused-parameter`
|
|
60
|
+
`-Wno-unused-function` `-Wno-strict-prototypes` `-Wno-pedantic`
|
|
61
|
+
`-Wno-c23-extensions` `-Wno-undef` suppressions now apply **only** to
|
|
62
|
+
vendored PQClean translation units; our own code compiles with the
|
|
63
|
+
strict warning set. Added a compile probe for `EVP_sha3_256`.
|
|
64
|
+
|
|
65
|
+
### Changed — Ruby API
|
|
66
|
+
|
|
67
|
+
- `Signature::PublicKey#verify` now returns `true` / `false` for normal
|
|
68
|
+
cryptographic outcomes. Previously an invalid signature surfaced
|
|
69
|
+
through a caught `VerificationError`; the native entrypoint no longer
|
|
70
|
+
raises for this case. `verify!` still raises on mismatch.
|
|
71
|
+
- `PublicKey#==` / `SecretKey#==` on all key types now use OpenSSL
|
|
72
|
+
`CRYPTO_memcmp` through a new `PQCrypto.ct_equals` native helper, so
|
|
73
|
+
key equality checks no longer leak timing information about a
|
|
74
|
+
prefix-match.
|
|
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.
|
|
78
|
+
- Native entrypoints and their `native_*` aliases are installed once via
|
|
79
|
+
the new `PQCrypto::NativeBindings` module instead of the ad-hoc
|
|
80
|
+
`unless method_defined?` guards on the singleton.
|
|
81
|
+
- Renamed `Signature.validate_algorithm!` → `resolve_algorithm!` to
|
|
82
|
+
match `KEM` / `HybridKEM`.
|
|
83
|
+
|
|
84
|
+
### Changed — packaging
|
|
85
|
+
|
|
86
|
+
- Intended to change `required_ruby_version` from `">= 3.4.0.a"` to
|
|
87
|
+
`">= 3.4"`; the gemspec is aligned in `0.3.1`.
|
|
88
|
+
- Version bumped to `0.3.0`.
|
|
89
|
+
- `VerificationError` class is still defined (and still raised by
|
|
90
|
+
`verify!`) for backward compatibility, but the native `verify`
|
|
91
|
+
entrypoint no longer raises it.
|
|
92
|
+
|
|
93
|
+
### Migration notes
|
|
94
|
+
|
|
95
|
+
- Hybrid keys and ciphertexts must be regenerated with 0.3.0; old blobs
|
|
96
|
+
are rejected by the new container decoder.
|
|
97
|
+
- Code referencing the old hybrid symbol must update to
|
|
98
|
+
`:ml_kem_768_x25519_xwing`. Pure ML-KEM and ML-DSA symbols are
|
|
99
|
+
unchanged.
|
|
100
|
+
- Code relying on `verify` raising `VerificationError` should switch
|
|
101
|
+
to `verify!` or a `verify` + explicit `false` check.
|
|
102
|
+
|
|
3
103
|
## [0.2.0]
|
|
4
104
|
|
|
5
105
|
### Changed
|
data/GET_STARTED.md
CHANGED
|
@@ -30,18 +30,24 @@ 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.
|
|
38
|
+
## 6. Hybrid KEM (X-Wing)
|
|
37
39
|
|
|
38
40
|
```ruby
|
|
39
|
-
hybrid = PQCrypto::HybridKEM.generate(:
|
|
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
|
-
|
|
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
|
+
|
|
49
|
+
The hybrid mode follows `draft-connolly-cfrg-xwing-kem`. See
|
|
50
|
+
`SECURITY.md` for audit status.
|
|
45
51
|
|
|
46
52
|
## 7. Serialize a key
|
|
47
53
|
|
|
@@ -50,16 +56,20 @@ der = keypair.public_key.to_pqc_container_der
|
|
|
50
56
|
imported = PQCrypto::KEM.public_key_from_pqc_container_der(der)
|
|
51
57
|
```
|
|
52
58
|
|
|
59
|
+
`pqc_container_*` formats are pq_crypto-specific.
|
|
60
|
+
|
|
53
61
|
## 8. Inspect supported algorithms
|
|
54
62
|
|
|
55
63
|
```ruby
|
|
56
|
-
PQCrypto.supported_kems
|
|
57
|
-
PQCrypto.supported_hybrid_kems
|
|
58
|
-
PQCrypto.supported_signatures
|
|
64
|
+
PQCrypto.supported_kems # => [:ml_kem_768]
|
|
65
|
+
PQCrypto.supported_hybrid_kems # => [:ml_kem_768_x25519_xwing]
|
|
66
|
+
PQCrypto.supported_signatures # => [:ml_dsa_65]
|
|
59
67
|
```
|
|
60
68
|
|
|
61
69
|
## 9. Practical notes
|
|
62
70
|
|
|
63
|
-
- OpenSSL 3.0+ is required.
|
|
64
|
-
- `
|
|
65
|
-
|
|
71
|
+
- OpenSSL 3.0+ with SHA3-256 is required.
|
|
72
|
+
- `PQCrypto::Testing` exposes deterministic helpers only for
|
|
73
|
+
regression tests.
|
|
74
|
+
- Key equality uses constant-time comparison. `#hash` returns a
|
|
75
|
+
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
|
|
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` —
|
|
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` /
|
|
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.
|
|
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
|
|
@@ -36,19 +41,25 @@ bundle exec rake compile
|
|
|
36
41
|
### Native dependencies
|
|
37
42
|
|
|
38
43
|
- Ruby 3.4.x
|
|
39
|
-
- a C toolchain
|
|
40
|
-
- OpenSSL **3.0 or later**
|
|
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)
|
|
41
46
|
|
|
42
47
|
## Async / Fiber scheduler support
|
|
43
48
|
|
|
44
|
-
`pq_crypto` does not require any gem-specific Async configuration. On
|
|
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.
|
|
45
52
|
|
|
46
53
|
That means:
|
|
47
54
|
|
|
48
|
-
- without a Fiber scheduler, these methods fall back to the ordinary
|
|
49
|
-
-
|
|
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.
|
|
50
60
|
|
|
51
|
-
This integration is intentionally limited to `sign` and `verify`; the
|
|
61
|
+
This integration is intentionally limited to `sign` and `verify`; the
|
|
62
|
+
faster primitive operations keep the lower-overhead path.
|
|
52
63
|
|
|
53
64
|
Example with `Async`:
|
|
54
65
|
|
|
@@ -92,18 +103,33 @@ shared_secret = keypair.secret_key.decapsulate(result.ciphertext)
|
|
|
92
103
|
```ruby
|
|
93
104
|
keypair = PQCrypto::Signature.generate(:ml_dsa_65)
|
|
94
105
|
signature = keypair.secret_key.sign("hello")
|
|
95
|
-
|
|
106
|
+
|
|
107
|
+
keypair.public_key.verify("hello", signature) # => true / false
|
|
108
|
+
keypair.public_key.verify!("hello", signature) # raises on mismatch
|
|
96
109
|
```
|
|
97
110
|
|
|
98
|
-
|
|
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)
|
|
99
116
|
|
|
100
117
|
```ruby
|
|
101
|
-
keypair = PQCrypto::HybridKEM.generate(:
|
|
118
|
+
keypair = PQCrypto::HybridKEM.generate(:ml_kem_768_x25519_xwing)
|
|
102
119
|
result = keypair.public_key.encapsulate
|
|
103
120
|
shared_secret = keypair.secret_key.decapsulate(result.ciphertext)
|
|
104
121
|
```
|
|
105
122
|
|
|
106
|
-
|
|
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:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
ss = SHA3-256( ss_M || ss_X || ct_X || pk_X || "\.//^\" )
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
as specified by `draft-connolly-cfrg-xwing-kem-10`. See `SECURITY.md`
|
|
132
|
+
for audit status and interoperability caveats.
|
|
107
133
|
|
|
108
134
|
## Serialization
|
|
109
135
|
|
|
@@ -122,7 +148,37 @@ der = keypair.public_key.to_pqc_container_der
|
|
|
122
148
|
imported = PQCrypto::KEM.public_key_from_pqc_container_der(der)
|
|
123
149
|
```
|
|
124
150
|
|
|
125
|
-
These containers are **not real ASN.1 SPKI or PKCS#8**. They are
|
|
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.
|
|
154
|
+
|
|
155
|
+
## Secure wiping
|
|
156
|
+
|
|
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:
|
|
162
|
+
|
|
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
|
+
```
|
|
170
|
+
|
|
171
|
+
## Constant-time comparison
|
|
172
|
+
|
|
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.
|
|
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.
|
|
126
182
|
|
|
127
183
|
## Introspection
|
|
128
184
|
|
|
@@ -133,20 +189,24 @@ PQCrypto.supported_kems
|
|
|
133
189
|
PQCrypto.supported_hybrid_kems
|
|
134
190
|
PQCrypto.supported_signatures
|
|
135
191
|
PQCrypto::KEM.details(:ml_kem_768)
|
|
136
|
-
PQCrypto::HybridKEM.details(:
|
|
192
|
+
PQCrypto::HybridKEM.details(:ml_kem_768_x25519_xwing)
|
|
137
193
|
PQCrypto::Signature.details(:ml_dsa_65)
|
|
138
194
|
```
|
|
139
195
|
|
|
140
196
|
## Testing helpers
|
|
141
197
|
|
|
142
|
-
Deterministic test hooks are exposed under `PQCrypto::Testing` for
|
|
198
|
+
Deterministic test hooks are exposed under `PQCrypto::Testing` for
|
|
199
|
+
regression coverage:
|
|
143
200
|
|
|
144
|
-
- `ml_kem_keypair_from_seed`
|
|
145
|
-
- `ml_kem_encapsulate_from_seed`
|
|
146
|
-
- `ml_dsa_keypair_from_seed`
|
|
147
|
-
- `ml_dsa_sign_from_seed`
|
|
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
|
|
148
205
|
|
|
149
|
-
These helpers are intended for tests only.
|
|
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.
|
|
150
210
|
|
|
151
211
|
## Development
|
|
152
212
|
|
|
@@ -156,13 +216,17 @@ Run the test suite with:
|
|
|
156
216
|
bundle exec rake test
|
|
157
217
|
```
|
|
158
218
|
|
|
159
|
-
Refresh vendored PQClean sources manually only when you intentionally
|
|
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`:
|
|
160
223
|
|
|
161
224
|
```bash
|
|
162
225
|
bundle exec ruby script/vendor_libs.rb
|
|
163
226
|
```
|
|
164
227
|
|
|
165
|
-
To intentionally change the upstream snapshot, override all four
|
|
228
|
+
To intentionally change the upstream snapshot, override all four
|
|
229
|
+
pinning inputs together:
|
|
166
230
|
|
|
167
231
|
```bash
|
|
168
232
|
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`
|
|
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`)
|
|
9
|
-
- `PQCrypto::HybridKEM` (
|
|
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
|
|
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,108 @@ 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
|
|
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`
|
|
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/).
|
|
27
33
|
|
|
28
|
-
|
|
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.
|
|
38
|
+
|
|
39
|
+
ss = SHA3-256( ss_M || ss_X || ct_X || pk_X || XWingLabel )
|
|
40
|
+
|
|
41
|
+
where `XWingLabel = "\.//^\"` (6 ASCII bytes).
|
|
42
|
+
|
|
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.
|
|
47
|
+
|
|
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.
|
|
51
|
+
|
|
52
|
+
### Deterministic test hooks
|
|
53
|
+
|
|
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.
|
|
29
62
|
|
|
30
63
|
## Serialization
|
|
31
64
|
|
|
32
65
|
`pqc_container_*` DER/PEM wrappers are pq_crypto-specific containers.
|
|
33
66
|
|
|
34
67
|
They are:
|
|
68
|
+
|
|
35
69
|
- not real SPKI
|
|
36
70
|
- not real PKCS#8
|
|
37
71
|
- not advertised as interoperable with OpenSSL, Go, Java, or PKI tooling
|
|
38
72
|
|
|
39
|
-
The
|
|
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`.
|
|
40
76
|
|
|
41
|
-
|
|
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.
|
|
42
83
|
|
|
43
84
|
## Memory wiping
|
|
44
85
|
|
|
45
|
-
`PQCrypto.secure_wipe` clears mutable Ruby strings in place. Ruby
|
|
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.
|
|
46
92
|
|
|
47
93
|
## OpenSSL baseline
|
|
48
94
|
|
|
49
95
|
`pq_crypto` requires OpenSSL **3.0 or later**.
|
|
50
96
|
|
|
51
|
-
OpenSSL is used for
|
|
52
|
-
|
|
53
|
-
- `
|
|
97
|
+
OpenSSL is used for:
|
|
98
|
+
|
|
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.
|
|
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.
|
|
54
116
|
|
|
55
117
|
## Threading
|
|
56
118
|
|
|
57
|
-
Concurrent read-only operations on primitive key objects are supported.
|
|
119
|
+
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.
|
data/ext/pqcrypto/extconf.rb
CHANGED
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
require "mkmf"
|
|
5
5
|
|
|
6
|
-
$CFLAGS << " -std=
|
|
6
|
+
$CFLAGS << " -std=c11 -Wall -Wextra -O2"
|
|
7
7
|
$CFLAGS << " -fstack-protector-strong -D_FORTIFY_SOURCE=2"
|
|
8
|
-
|
|
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
|
|
|
@@ -16,6 +15,7 @@ SANITIZE = ENV["PQCRYPTO_SANITIZE"]
|
|
|
16
15
|
|
|
17
16
|
if SANITIZE && !SANITIZE.strip.empty?
|
|
18
17
|
sanitize = SANITIZE.strip
|
|
18
|
+
$CFLAGS.gsub!(/\s-D_FORTIFY_SOURCE=\d+/, "")
|
|
19
19
|
$CFLAGS << " -O1 -g -fno-omit-frame-pointer -fsanitize=#{sanitize}"
|
|
20
20
|
$LDFLAGS << " -fsanitize=#{sanitize}"
|
|
21
21
|
end
|
|
@@ -52,7 +52,7 @@ def configure_openssl!
|
|
|
52
52
|
abort "OpenSSL libssl is required" unless have_library("ssl")
|
|
53
53
|
abort "openssl/evp.h is required" unless have_header("openssl/evp.h")
|
|
54
54
|
abort "openssl/rand.h is required" unless have_header("openssl/rand.h")
|
|
55
|
-
abort "openssl/
|
|
55
|
+
abort "openssl/crypto.h is required" unless have_header("openssl/crypto.h")
|
|
56
56
|
|
|
57
57
|
version_check = <<~SRC
|
|
58
58
|
#include <openssl/opensslv.h>
|
|
@@ -64,7 +64,25 @@ def configure_openssl!
|
|
|
64
64
|
|
|
65
65
|
abort "OpenSSL 3.0 or later is required" unless try_compile(version_check)
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
sha3_check = <<~SRC
|
|
68
|
+
#include <openssl/evp.h>
|
|
69
|
+
int main(void) {
|
|
70
|
+
const EVP_MD *md = EVP_sha3_256();
|
|
71
|
+
return md == NULL ? 1 : 0;
|
|
72
|
+
}
|
|
73
|
+
SRC
|
|
74
|
+
abort "OpenSSL SHA3-256 is required (X-Wing combiner)" unless try_compile(sha3_check)
|
|
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
|
+
|
|
85
|
+
$CFLAGS << " -DHAVE_OPENSSL_EVP_H -DHAVE_OPENSSL_RAND_H"
|
|
68
86
|
end
|
|
69
87
|
|
|
70
88
|
def configure_pqclean(vendor_dir)
|
|
@@ -82,7 +100,7 @@ def configure_pqclean(vendor_dir)
|
|
|
82
100
|
|
|
83
101
|
mlkem_sources = Dir.glob(File.join(mlkem_dir, "*.c")).sort
|
|
84
102
|
mldsa_sources = Dir.glob(File.join(mldsa_dir, "*.c")).sort
|
|
85
|
-
common_sources = %w[fips202.c sha2.c sp800-185.c
|
|
103
|
+
common_sources = %w[fips202.c sha2.c sp800-185.c].map { |name| File.join(common_dir, name) }
|
|
86
104
|
|
|
87
105
|
source_groups = [
|
|
88
106
|
["pqclean_mlkem", mlkem_sources],
|
|
@@ -92,7 +110,7 @@ def configure_pqclean(vendor_dir)
|
|
|
92
110
|
|
|
93
111
|
return nil unless source_groups.all? { |_, sources| sources.all? { |path| File.exist?(path) } }
|
|
94
112
|
|
|
95
|
-
$CFLAGS << " -DHAVE_PQCLEAN
|
|
113
|
+
$CFLAGS << " -DHAVE_PQCLEAN"
|
|
96
114
|
include_dirs.each { |dir| $CPPFLAGS << " -I#{dir}" }
|
|
97
115
|
|
|
98
116
|
{
|
|
@@ -117,7 +135,7 @@ def inject_pqclean_sources!(pqclean_config)
|
|
|
117
135
|
build_rules << <<~RULE
|
|
118
136
|
#{object}: #{source}
|
|
119
137
|
$(ECHO) compiling #{source}
|
|
120
|
-
$(Q) $(CC) $(INCFLAGS) $(CPPFLAGS) $(CFLAGS) $(COUTFLAG)$@ -c $(CSRCFLAG)$<
|
|
138
|
+
$(Q) $(CC) $(INCFLAGS) $(CPPFLAGS) $(CFLAGS) #{VENDOR_ONLY_CFLAGS} $(COUTFLAG)$@ -c $(CSRCFLAG)$<
|
|
121
139
|
RULE
|
|
122
140
|
end
|
|
123
141
|
end
|
|
@@ -138,9 +156,6 @@ def inject_pqclean_sources!(pqclean_config)
|
|
|
138
156
|
File.write("Makefile", makefile)
|
|
139
157
|
end
|
|
140
158
|
|
|
141
|
-
have_func("getrandom", "sys/random.h")
|
|
142
|
-
have_func("arc4random_buf", "stdlib.h")
|
|
143
|
-
|
|
144
159
|
vendor_dir = USE_SYSTEM ? nil : find_vendor_dir
|
|
145
160
|
|
|
146
161
|
puts
|
|
@@ -149,7 +164,7 @@ configure_openssl!
|
|
|
149
164
|
pqclean_config = configure_pqclean(vendor_dir)
|
|
150
165
|
puts "OpenSSL: system"
|
|
151
166
|
abort "PQClean vendored sources are required. Run: bundle exec rake vendor" unless pqclean_config
|
|
152
|
-
puts "PQClean: vendored"
|
|
167
|
+
puts "PQClean: vendored (randombytes overridden by pq_randombytes.c)"
|
|
153
168
|
puts "Output: pqcrypto/pqcrypto_secure"
|
|
154
169
|
puts "===================================="
|
|
155
170
|
|
|
@@ -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
|
+
}
|