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