ml_dsa 0.1.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.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +104 -0
  3. data/LICENSE +14 -0
  4. data/LICENSE-APACHE +185 -0
  5. data/LICENSE-MIT +21 -0
  6. data/README.md +234 -0
  7. data/ext/ml_dsa/extconf.rb +47 -0
  8. data/ext/ml_dsa/fips202.c +933 -0
  9. data/ext/ml_dsa/fips202.h +166 -0
  10. data/ext/ml_dsa/ml-dsa-44/clean/api.h +52 -0
  11. data/ext/ml_dsa/ml-dsa-44/clean/ntt.c +98 -0
  12. data/ext/ml_dsa/ml-dsa-44/clean/ntt.h +10 -0
  13. data/ext/ml_dsa/ml-dsa-44/clean/packing.c +261 -0
  14. data/ext/ml_dsa/ml-dsa-44/clean/packing.h +31 -0
  15. data/ext/ml_dsa/ml-dsa-44/clean/params.h +44 -0
  16. data/ext/ml_dsa/ml-dsa-44/clean/poly.c +848 -0
  17. data/ext/ml_dsa/ml-dsa-44/clean/poly.h +52 -0
  18. data/ext/ml_dsa/ml-dsa-44/clean/polyvec.c +415 -0
  19. data/ext/ml_dsa/ml-dsa-44/clean/polyvec.h +65 -0
  20. data/ext/ml_dsa/ml-dsa-44/clean/reduce.c +69 -0
  21. data/ext/ml_dsa/ml-dsa-44/clean/reduce.h +17 -0
  22. data/ext/ml_dsa/ml-dsa-44/clean/rounding.c +98 -0
  23. data/ext/ml_dsa/ml-dsa-44/clean/rounding.h +14 -0
  24. data/ext/ml_dsa/ml-dsa-44/clean/sign.c +417 -0
  25. data/ext/ml_dsa/ml-dsa-44/clean/sign.h +49 -0
  26. data/ext/ml_dsa/ml-dsa-44/clean/symmetric-shake.c +26 -0
  27. data/ext/ml_dsa/ml-dsa-44/clean/symmetric.h +34 -0
  28. data/ext/ml_dsa/ml-dsa-65/clean/api.h +52 -0
  29. data/ext/ml_dsa/ml-dsa-65/clean/ntt.c +98 -0
  30. data/ext/ml_dsa/ml-dsa-65/clean/ntt.h +10 -0
  31. data/ext/ml_dsa/ml-dsa-65/clean/packing.c +261 -0
  32. data/ext/ml_dsa/ml-dsa-65/clean/packing.h +31 -0
  33. data/ext/ml_dsa/ml-dsa-65/clean/params.h +44 -0
  34. data/ext/ml_dsa/ml-dsa-65/clean/poly.c +799 -0
  35. data/ext/ml_dsa/ml-dsa-65/clean/poly.h +52 -0
  36. data/ext/ml_dsa/ml-dsa-65/clean/polyvec.c +415 -0
  37. data/ext/ml_dsa/ml-dsa-65/clean/polyvec.h +65 -0
  38. data/ext/ml_dsa/ml-dsa-65/clean/reduce.c +69 -0
  39. data/ext/ml_dsa/ml-dsa-65/clean/reduce.h +17 -0
  40. data/ext/ml_dsa/ml-dsa-65/clean/rounding.c +92 -0
  41. data/ext/ml_dsa/ml-dsa-65/clean/rounding.h +14 -0
  42. data/ext/ml_dsa/ml-dsa-65/clean/sign.c +415 -0
  43. data/ext/ml_dsa/ml-dsa-65/clean/sign.h +49 -0
  44. data/ext/ml_dsa/ml-dsa-65/clean/symmetric-shake.c +26 -0
  45. data/ext/ml_dsa/ml-dsa-65/clean/symmetric.h +34 -0
  46. data/ext/ml_dsa/ml-dsa-87/clean/api.h +52 -0
  47. data/ext/ml_dsa/ml-dsa-87/clean/ntt.c +98 -0
  48. data/ext/ml_dsa/ml-dsa-87/clean/ntt.h +10 -0
  49. data/ext/ml_dsa/ml-dsa-87/clean/packing.c +261 -0
  50. data/ext/ml_dsa/ml-dsa-87/clean/packing.h +31 -0
  51. data/ext/ml_dsa/ml-dsa-87/clean/params.h +44 -0
  52. data/ext/ml_dsa/ml-dsa-87/clean/poly.c +823 -0
  53. data/ext/ml_dsa/ml-dsa-87/clean/poly.h +52 -0
  54. data/ext/ml_dsa/ml-dsa-87/clean/polyvec.c +415 -0
  55. data/ext/ml_dsa/ml-dsa-87/clean/polyvec.h +65 -0
  56. data/ext/ml_dsa/ml-dsa-87/clean/reduce.c +69 -0
  57. data/ext/ml_dsa/ml-dsa-87/clean/reduce.h +17 -0
  58. data/ext/ml_dsa/ml-dsa-87/clean/rounding.c +92 -0
  59. data/ext/ml_dsa/ml-dsa-87/clean/rounding.h +14 -0
  60. data/ext/ml_dsa/ml-dsa-87/clean/sign.c +415 -0
  61. data/ext/ml_dsa/ml-dsa-87/clean/sign.h +49 -0
  62. data/ext/ml_dsa/ml-dsa-87/clean/symmetric-shake.c +26 -0
  63. data/ext/ml_dsa/ml-dsa-87/clean/symmetric.h +34 -0
  64. data/ext/ml_dsa/ml_dsa_44_impl.c +10 -0
  65. data/ext/ml_dsa/ml_dsa_65_impl.c +10 -0
  66. data/ext/ml_dsa/ml_dsa_87_impl.c +10 -0
  67. data/ext/ml_dsa/ml_dsa_ext.c +1360 -0
  68. data/ext/ml_dsa/ml_dsa_impl_template.h +35 -0
  69. data/ext/ml_dsa/ml_dsa_internal.h +188 -0
  70. data/ext/ml_dsa/randombytes.c +48 -0
  71. data/ext/ml_dsa/randombytes.h +15 -0
  72. data/lib/ml_dsa/batch_builder.rb +57 -0
  73. data/lib/ml_dsa/config.rb +69 -0
  74. data/lib/ml_dsa/internal.rb +76 -0
  75. data/lib/ml_dsa/key_pair.rb +39 -0
  76. data/lib/ml_dsa/parameter_set.rb +89 -0
  77. data/lib/ml_dsa/public_key.rb +180 -0
  78. data/lib/ml_dsa/requests.rb +96 -0
  79. data/lib/ml_dsa/secret_key.rb +221 -0
  80. data/lib/ml_dsa/version.rb +5 -0
  81. data/lib/ml_dsa.rb +277 -0
  82. data/patches/README.md +55 -0
  83. data/patches/pqclean-explicit-rnd.patch +64 -0
  84. data/sig/ml_dsa.rbs +178 -0
  85. data/test/fixtures/kat_vectors.yaml +16 -0
  86. metadata +194 -0
@@ -0,0 +1,35 @@
1
+ /*
2
+ * ml_dsa_impl_template.h — Parametric amalgamation template.
3
+ *
4
+ * Single source of truth for which PQClean .c files are included per
5
+ * parameter set. Each ml_dsa_NN_impl.c defines ML_DSA_PS to its
6
+ * parameter set code (44, 65, or 87) and includes this template.
7
+ *
8
+ * Including .c files causes GCC/Clang to resolve their quoted includes
9
+ * (e.g. #include "params.h") relative to the directory of the included
10
+ * file, so each variant picks up its own params.h automatically.
11
+ * fips202.h and randombytes.h are resolved via -I$(srcdir) (ext/ml_dsa/).
12
+ *
13
+ * To add a new PQClean source file, add it ONCE here — all three
14
+ * parameter sets pick it up automatically.
15
+ */
16
+
17
+ #ifndef ML_DSA_PS
18
+ # error "ML_DSA_PS must be defined before including ml_dsa_impl_template.h"
19
+ #endif
20
+
21
+ /* Token-paste helpers to build paths like ml-dsa-44/clean/ntt.c */
22
+ #define ML_DSA_PASTE2(a, b) a ## b
23
+ #define ML_DSA_PASTE(a, b) ML_DSA_PASTE2(a, b)
24
+ #define ML_DSA_PATH(file) ML_DSA_STRINGIFY(ml-dsa-ML_DSA_PS/clean/file)
25
+ #define ML_DSA_STRINGIFY(x) ML_DSA_STRINGIFY2(x)
26
+ #define ML_DSA_STRINGIFY2(x) #x
27
+
28
+ #include ML_DSA_PATH(ntt.c)
29
+ #include ML_DSA_PATH(packing.c)
30
+ #include ML_DSA_PATH(poly.c)
31
+ #include ML_DSA_PATH(polyvec.c)
32
+ #include ML_DSA_PATH(reduce.c)
33
+ #include ML_DSA_PATH(rounding.c)
34
+ #include ML_DSA_PATH(symmetric-shake.c)
35
+ #include ML_DSA_PATH(sign.c)
@@ -0,0 +1,188 @@
1
+ /*
2
+ * ml_dsa_internal.h — Shared declarations for the ML-DSA C extension.
3
+ *
4
+ * This header provides the minimal interface consumed by ml_dsa_ext.c
5
+ * and the parametric impl template files (ml_dsa_NN_impl.c). It contains:
6
+ * - Standard and Ruby includes
7
+ * - C11 atomics compatibility macros
8
+ * - PQClean api.h includes (ifdef-guarded by parameter set)
9
+ * - Constants and named boundary-array positions
10
+ * - Dispatch table typedef
11
+ * - TypedData struct definitions for PublicKey and SecretKey
12
+ *
13
+ * Design decisions:
14
+ *
15
+ * Why no Signer/Verifier streaming classes?
16
+ * True incremental ML-DSA signing would require splitting PQClean's
17
+ * sign/verify at the SHAKE-256 mu-computation boundary — a fragile
18
+ * change that breaks on every PQClean update. The former Ruby
19
+ * Signer/Verifier classes buffered the full message anyway, providing
20
+ * an illusion of streaming without actual streaming. Removed in
21
+ * favor of direct sk.sign / pk.verify calls.
22
+ *
23
+ * Why batch-only C layer (no single-op sign/verify in C)?
24
+ * The batch sign/verify C paths already handle single items. Having
25
+ * separate single-op C functions duplicated ~190 lines of nogvl
26
+ * structs, callbacks, body/ensure pairs. Now sk.sign and pk.verify
27
+ * are Ruby methods that call sign_many/verify_many with a single-
28
+ * element array. The overhead (one Ruby Array + one SignRequest/
29
+ * VerifyRequest) is negligible vs. the ~50us crypto operation.
30
+ *
31
+ * Why ParameterSet in Ruby, not C?
32
+ * ParameterSet is a value object with Comparable, inspect, to_s —
33
+ * features that are trivial in Ruby but would require 100+ lines of
34
+ * boilerplate in C. The C layer passes integer codes (44/65/87)
35
+ * through the boundary. The build_param_data array-of-arrays bridges
36
+ * C constants into Ruby with no per-call overhead (runs once at
37
+ * require time).
38
+ *
39
+ * Why no code generator for the dispatch table?
40
+ * ML-DSA has exactly 3 parameter sets (44, 65, 87), standardized by
41
+ * NIST FIPS 204. This set will not change. A code generator would
42
+ * add build complexity for a table that has 3 entries.
43
+ *
44
+ * Why context is a plain string (not a Context class)?
45
+ * FIPS 204 defines context as 0..255 bytes — that's a string with a
46
+ * length check. A wrapper class adds API surface without adding
47
+ * invariants beyond what the primitive can express.
48
+ *
49
+ * rb_ensure pattern:
50
+ * Every GVL-released crypto operation follows this structure:
51
+ * 1. Allocate buffers in body (covered by ensure on OOM)
52
+ * 2. Pin Ruby strings with rb_str_new_frozen before GVL drop
53
+ * 3. Call rb_thread_call_without_gvl
54
+ * 4. Build result objects
55
+ * 5. Place RB_GC_GUARD at END of body (not in ensure!)
56
+ * 6. Ensure function: secure_zero + free all allocations
57
+ * CRITICAL: RB_GC_GUARD must live on the body's stack frame, not
58
+ * in ensure, because ensure may execute after body's locals are gone.
59
+ */
60
+
61
+ #ifndef ML_DSA_INTERNAL_H
62
+ #define ML_DSA_INTERNAL_H
63
+
64
+ /* Must precede <string.h> so memset_s is declared when available. */
65
+ #define __STDC_WANT_LIB_EXT1__ 1
66
+
67
+ #include <ruby.h>
68
+ #include <ruby/thread.h>
69
+ #include <ruby/st.h>
70
+ #include <ruby/encoding.h>
71
+ #include <string.h>
72
+ #include <stdint.h>
73
+ #include <stdarg.h>
74
+
75
+ /* C11 atomics for thread-safe wipe flag. MSVC < 2022 lacks stdatomic.h,
76
+ * so we fall back to volatile int + compiler barriers on that platform. */
77
+ #if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L && !defined(__STDC_NO_ATOMICS__)
78
+ # include <stdatomic.h>
79
+ # define ML_DSA_ATOMIC _Atomic
80
+ # define ML_DSA_ATOMIC_LOAD(p) atomic_load_explicit((p), memory_order_acquire)
81
+ # define ML_DSA_ATOMIC_STORE(p, v) atomic_store_explicit((p), (v), memory_order_release)
82
+ #else
83
+ # define ML_DSA_ATOMIC volatile
84
+ # define ML_DSA_ATOMIC_LOAD(p) (*(p))
85
+ # define ML_DSA_ATOMIC_STORE(p, v) (*(p) = (v))
86
+ #endif
87
+
88
+ #ifdef ML_DSA_ENABLE_44
89
+ # include "ml-dsa-44/clean/api.h"
90
+ #endif
91
+ #ifdef ML_DSA_ENABLE_65
92
+ # include "ml-dsa-65/clean/api.h"
93
+ #endif
94
+ #ifdef ML_DSA_ENABLE_87
95
+ # include "ml-dsa-87/clean/api.h"
96
+ #endif
97
+
98
+ #include "randombytes.h"
99
+
100
+ /* ------------------------------------------------------------------ */
101
+ /* Constants */
102
+ /* ------------------------------------------------------------------ */
103
+
104
+ #define ML_DSA_RNDBYTES 32
105
+ #define ML_DSA_SEED_BYTES 32
106
+
107
+ /* Named positions for the flat Ruby->C boundary arrays.
108
+ * sign_many: [sk, message, ctx_or_nil, deterministic_or_nil, rnd_or_nil]
109
+ * verify_many: [pk, message, signature, ctx_or_nil] */
110
+ #define SIGN_OP_SK 0
111
+ #define SIGN_OP_MSG 1
112
+ #define SIGN_OP_CTX 2
113
+ #define SIGN_OP_DET 3
114
+ #define SIGN_OP_RND 4 /* optional pre-generated rnd bytes from pluggable RNG */
115
+
116
+ #define VERIFY_OP_PK 0
117
+ #define VERIFY_OP_MSG 1
118
+ #define VERIFY_OP_SIG 2
119
+ #define VERIFY_OP_CTX 3
120
+
121
+ #ifdef ML_DSA_ENABLE_87
122
+ # define ML_DSA_MAX_PK_BYTES PQCLEAN_MLDSA87_CLEAN_CRYPTO_PUBLICKEYBYTES
123
+ # define ML_DSA_MAX_SK_BYTES PQCLEAN_MLDSA87_CLEAN_CRYPTO_SECRETKEYBYTES
124
+ # define ML_DSA_MAX_SIG_BYTES PQCLEAN_MLDSA87_CLEAN_CRYPTO_BYTES
125
+ #elif defined(ML_DSA_ENABLE_65)
126
+ # define ML_DSA_MAX_PK_BYTES PQCLEAN_MLDSA65_CLEAN_CRYPTO_PUBLICKEYBYTES
127
+ # define ML_DSA_MAX_SK_BYTES PQCLEAN_MLDSA65_CLEAN_CRYPTO_SECRETKEYBYTES
128
+ # define ML_DSA_MAX_SIG_BYTES PQCLEAN_MLDSA65_CLEAN_CRYPTO_BYTES
129
+ #else
130
+ # define ML_DSA_MAX_PK_BYTES PQCLEAN_MLDSA44_CLEAN_CRYPTO_PUBLICKEYBYTES
131
+ # define ML_DSA_MAX_SK_BYTES PQCLEAN_MLDSA44_CLEAN_CRYPTO_SECRETKEYBYTES
132
+ # define ML_DSA_MAX_SIG_BYTES PQCLEAN_MLDSA44_CLEAN_CRYPTO_BYTES
133
+ #endif
134
+
135
+ /* Convenience macro — wraps rb_ensure with the cast boilerplate. */
136
+ #define ML_DSA_ENSURE(body, ensure, state) \
137
+ rb_ensure((body), (VALUE)(state), (ensure), (VALUE)(state))
138
+
139
+ /* ------------------------------------------------------------------ */
140
+ /* Dispatch table */
141
+ /* ------------------------------------------------------------------ */
142
+
143
+ typedef int (*keygen_fn_t)(uint8_t *, uint8_t *, const uint8_t *);
144
+ typedef int (*sign_fn_t)(uint8_t *, size_t *,
145
+ const uint8_t *, size_t,
146
+ const uint8_t *, size_t,
147
+ const uint8_t *,
148
+ const uint8_t *); /* rnd_in */
149
+ typedef int (*verify_fn_t)(const uint8_t *, size_t,
150
+ const uint8_t *, size_t,
151
+ const uint8_t *, size_t,
152
+ const uint8_t *);
153
+
154
+ typedef struct {
155
+ int ps;
156
+ int security_level;
157
+ keygen_fn_t keygen_fn;
158
+ sign_fn_t sign_fn;
159
+ verify_fn_t verify_fn;
160
+ size_t pk_len;
161
+ size_t sk_len;
162
+ size_t sig_len;
163
+ } ml_dsa_impl_t;
164
+
165
+ extern const ml_dsa_impl_t ML_DSA_IMPLS[];
166
+ extern const size_t ML_DSA_IMPL_COUNT;
167
+
168
+ /* ------------------------------------------------------------------ */
169
+ /* TypedData structures */
170
+ /* ------------------------------------------------------------------ */
171
+
172
+ typedef struct {
173
+ size_t len;
174
+ int ps_code;
175
+ VALUE fingerprint; /* cached SHA-256 hex prefix, Qnil until computed */
176
+ uint8_t bytes[]; /* flexible array member — allocated inline */
177
+ } ml_dsa_pk_t;
178
+
179
+ typedef struct {
180
+ size_t len;
181
+ int ps_code;
182
+ ML_DSA_ATOMIC int wiped; /* non-zero after wipe! — atomic for thread safety */
183
+ int has_seed; /* non-zero if seed[] contains the keygen seed */
184
+ uint8_t seed[ML_DSA_SEED_BYTES]; /* keygen seed (zeroed if !has_seed) */
185
+ uint8_t bytes[]; /* flexible array member — allocated inline */
186
+ } ml_dsa_sk_t;
187
+
188
+ #endif /* ML_DSA_INTERNAL_H */
@@ -0,0 +1,48 @@
1
+ /*
2
+ * randombytes.c — OS-backed CSPRNG.
3
+ *
4
+ * Replaces PQClean's common/randombytes.c. All randomness for keygen
5
+ * and signing is generated before the GVL drop and passed explicitly
6
+ * to the PQClean functions (seed_in for keygen, rnd_in for signing).
7
+ * This file is only used for OS CSPRNG calls made while holding the GVL.
8
+ */
9
+
10
+ #include "randombytes.h"
11
+
12
+ #if defined(_WIN32)
13
+ # include <windows.h>
14
+ # include <bcrypt.h>
15
+ # pragma comment(lib, "bcrypt.lib")
16
+ #elif defined(__APPLE__) || defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__)
17
+ # include <stdlib.h> /* arc4random_buf */
18
+ #else
19
+ # include <errno.h>
20
+ # include <sys/random.h> /* getrandom(2), Linux >= 3.17 */
21
+ #endif
22
+
23
+ void randombytes(uint8_t *x, size_t xlen)
24
+ {
25
+ #if defined(_WIN32)
26
+ /* BCryptGenRandom takes ULONG; guard against truncation on 64-bit. */
27
+ if (xlen > (size_t)ULONG_MAX) abort();
28
+ NTSTATUS status = BCryptGenRandom(NULL, x, (ULONG)xlen,
29
+ BCRYPT_USE_SYSTEM_PREFERRED_RNG);
30
+ if (!BCRYPT_SUCCESS(status)) abort();
31
+
32
+ #elif defined(__APPLE__) || defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__)
33
+ arc4random_buf(x, xlen);
34
+
35
+ #else
36
+ /* Linux: loop on EINTR; getrandom never returns partial reads for <= 256 bytes */
37
+ while (xlen > 0) {
38
+ ssize_t ret = getrandom(x, xlen, 0);
39
+ if (ret < 0) {
40
+ if (errno == EINTR) continue;
41
+ /* getrandom failure is fatal — caller cannot continue safely */
42
+ __builtin_trap();
43
+ }
44
+ x += (size_t)ret;
45
+ xlen -= (size_t)ret;
46
+ }
47
+ #endif
48
+ }
@@ -0,0 +1,15 @@
1
+ #ifndef ML_DSA_RANDOMBYTES_H
2
+ #define ML_DSA_RANDOMBYTES_H
3
+
4
+ #include <stddef.h>
5
+ #include <stdint.h>
6
+
7
+ /*
8
+ * Generate xlen random bytes into x using the OS CSPRNG:
9
+ * - Linux: getrandom(2)
10
+ * - macOS/BSD: arc4random_buf(3)
11
+ * - Windows: BCryptGenRandom
12
+ */
13
+ void randombytes(uint8_t *x, size_t xlen);
14
+
15
+ #endif /* ML_DSA_RANDOMBYTES_H */
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MlDsa
4
+ # Collects sign or verify operations for batch execution.
5
+ # Obtained via {MlDsa.batch}. Do not mix sign and verify in one batch.
6
+ class BatchBuilder
7
+ def initialize
8
+ @ops = []
9
+ @mode = nil
10
+ end
11
+
12
+ # Add a sign operation to the batch.
13
+ # @param sk [SecretKey]
14
+ # @param message [String]
15
+ # @param context [String, nil] FIPS 204 context (0..255 bytes)
16
+ # @param deterministic [Boolean] use deterministic signing
17
+ # @return [self] for chaining
18
+ def sign(sk:, message:, context: nil, deterministic: false)
19
+ check_mode!(:sign)
20
+ @ops << SignRequest.new(sk: sk, message: message,
21
+ context: context, deterministic: deterministic)
22
+ self
23
+ end
24
+
25
+ # Add a verify operation to the batch.
26
+ # @param pk [PublicKey]
27
+ # @param message [String]
28
+ # @param signature [String]
29
+ # @param context [String, nil] FIPS 204 context (0..255 bytes)
30
+ # @return [self] for chaining
31
+ def verify(pk:, message:, signature:, context: nil)
32
+ check_mode!(:verify)
33
+ @ops << VerifyRequest.new(pk: pk, message: message,
34
+ signature: signature, context: context)
35
+ self
36
+ end
37
+
38
+ # @api private
39
+ def execute(config: MlDsa.config, yield_every: Internal::DEFAULT_YIELD_EVERY)
40
+ return [] if @ops.empty?
41
+ if @mode == :sign
42
+ MlDsa.sign_many(@ops, config: config, yield_every: yield_every)
43
+ else
44
+ MlDsa.verify_many(@ops, config: config, yield_every: yield_every)
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def check_mode!(mode)
51
+ if @mode && @mode != mode
52
+ raise ArgumentError, "cannot mix sign and verify in the same batch"
53
+ end
54
+ @mode = mode
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MlDsa
4
+ # Thread-safe configuration holder for instrumentation subscribers
5
+ # and pluggable RNG. Each Ractor or test context can have its own
6
+ # Config instance.
7
+ #
8
+ # @example Custom config for testing
9
+ # cfg = MlDsa::Config.new
10
+ # cfg.random_source = proc { |n| "\x42" * n }
11
+ # pk, sk = MlDsa.keygen(MlDsa::ML_DSA_65, config: cfg)
12
+ class Config
13
+ def initialize
14
+ @subscribers = []
15
+ @mutex = Mutex.new
16
+ @random_source = nil
17
+ end
18
+
19
+ # Subscribe to instrumentation events. The block receives a frozen Hash
20
+ # with keys +:operation+, +:param_set+, +:count+, and +:duration_ns+.
21
+ #
22
+ # @yield [Hash] event payload
23
+ # @return [Proc] the subscriber (pass to {.unsubscribe} to remove)
24
+ def subscribe(&block)
25
+ raise ArgumentError, "subscribe requires a block" unless block
26
+ @mutex.synchronize { @subscribers << block }
27
+ block
28
+ end
29
+
30
+ # Remove a previously registered subscriber.
31
+ # @param subscriber [Proc] the block returned by {#subscribe}
32
+ # @return [Proc, nil] the removed subscriber, or nil if not found
33
+ def unsubscribe(subscriber)
34
+ @mutex.synchronize { @subscribers.delete(subscriber) }
35
+ end
36
+
37
+ # @return [Proc, nil] the current random source, or nil for OS CSPRNG
38
+ attr_reader :random_source
39
+
40
+ # Set a custom random source. The proc must accept a single Integer
41
+ # argument (byte count) and return a binary String of that length.
42
+ #
43
+ # @param source [Proc, nil]
44
+ def random_source=(source)
45
+ if source && !source.respond_to?(:call)
46
+ raise TypeError, "random_source must respond to :call or be nil"
47
+ end
48
+ @random_source = source
49
+ end
50
+
51
+ # @api private — fire event to all subscribers
52
+ def notify(operation, param_set, count, duration_ns)
53
+ # In non-main Ractors, skip instrumentation to avoid isolation errors
54
+ if defined?(Ractor) && Ractor.respond_to?(:main?) && !Ractor.main?
55
+ return nil
56
+ end
57
+ subs = @mutex.synchronize { @subscribers.dup }
58
+ return if subs.empty?
59
+ event = {
60
+ operation: operation,
61
+ param_set: param_set,
62
+ count: count,
63
+ duration_ns: duration_ns
64
+ }.freeze
65
+ subs.each { |s| s.call(event) }
66
+ nil
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MlDsa
4
+ # Nested module with private_constant so that MlDsa::Internal is
5
+ # accessible inside this gem but invisible to external callers.
6
+ module Internal
7
+ # Named positions for the flat Ruby→C boundary arrays.
8
+ # Must match SIGN_OP_* / VERIFY_OP_* macros in ml_dsa_internal.h.
9
+ SIGN_OP_SK = 0
10
+ SIGN_OP_MSG = 1
11
+ SIGN_OP_CTX = 2
12
+ SIGN_OP_DET = 3
13
+ SIGN_OP_RND = 4 # optional pre-generated rnd bytes (pluggable RNG)
14
+
15
+ VERIFY_OP_PK = 0
16
+ VERIFY_OP_MSG = 1
17
+ VERIFY_OP_SIG = 2
18
+ VERIFY_OP_CTX = 3
19
+
20
+ RND_BYTES = 32
21
+
22
+ # Default number of items between cooperative yields to the fiber
23
+ # scheduler during batch normalization loops. Zero means no yielding.
24
+ DEFAULT_YIELD_EVERY = 0
25
+
26
+ # Yield to the fiber scheduler if one is active and yield_every > 0.
27
+ # Called inside normalization loops to allow other fibers to run
28
+ # during large batch preparations.
29
+ def self.maybe_yield(i, yield_every)
30
+ return unless yield_every > 0 && ((i + 1) % yield_every).zero?
31
+ scheduler = Fiber.scheduler
32
+ return unless scheduler
33
+ scheduler.yield
34
+ rescue NoMethodError
35
+ # Fiber.scheduler or scheduler.yield not available (Ruby < 3.1)
36
+ nil
37
+ end
38
+
39
+ # Raise MlDsa::Error::Deserialization with structured metadata.
40
+ def self.raise_deser(format_str, position, reason, message)
41
+ err = MlDsa::Error::Deserialization.new(message)
42
+ err.instance_variable_set(:@format, format_str)
43
+ err.instance_variable_set(:@position, position)
44
+ err.instance_variable_set(:@reason, reason.to_sym)
45
+ raise err
46
+ end
47
+
48
+ # Look up the ParameterSet for a given code (44, 65, or 87).
49
+ def self.param_set_for_code(code)
50
+ MlDsa.const_get("ML_DSA_#{code}")
51
+ end
52
+
53
+ # Validate and return a ParameterSet.
54
+ def self.resolve_ps(ps)
55
+ case ps
56
+ when ParameterSet then ps
57
+ else
58
+ raise TypeError,
59
+ "param_set must be a MlDsa::ParameterSet " \
60
+ "(ML_DSA_44, ML_DSA_65, or ML_DSA_87), got #{ps.class}"
61
+ end
62
+ end
63
+
64
+ # Validate a hex string and decode to binary bytes.
65
+ def self.decode_hex(hex)
66
+ raise TypeError, "hex must be a String, got #{hex.class}" unless hex.is_a?(String)
67
+ raise ArgumentError, "hex string must not be empty" if hex.empty?
68
+ unless hex.match?(/\A[0-9a-fA-F]+\z/) && hex.length.even?
69
+ raise ArgumentError,
70
+ "hex must contain only hexadecimal characters and have even length"
71
+ end
72
+ [hex].pack("H*")
73
+ end
74
+ end
75
+ private_constant :Internal
76
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MlDsa
4
+ # Holds a matched public/secret key pair returned by {MlDsa.keygen}.
5
+ # Supports destructuring: +pk, sk = MlDsa.keygen(...)+.
6
+ class KeyPair
7
+ # @return [PublicKey]
8
+ attr_reader :public_key
9
+ # @return [SecretKey]
10
+ attr_reader :secret_key
11
+
12
+ def initialize(public_key, secret_key)
13
+ @public_key = public_key
14
+ @secret_key = secret_key
15
+ freeze
16
+ end
17
+
18
+ # Support destructuring: +pk, sk = keygen(...)+.
19
+ # @return [Array(PublicKey, SecretKey)]
20
+ def to_ary
21
+ [public_key, secret_key]
22
+ end
23
+
24
+ alias_method :to_a, :to_ary
25
+ alias_method :deconstruct, :to_ary
26
+
27
+ # @return [ParameterSet]
28
+ def param_set
29
+ public_key.param_set
30
+ end
31
+
32
+ # @return [String]
33
+ def inspect
34
+ "#<MlDsa::KeyPair #{param_set.name}>"
35
+ end
36
+
37
+ alias_method :to_s, :inspect
38
+ end
39
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MlDsa
4
+ # Encapsulates a concrete ML-DSA parameter set (44, 65, or 87).
5
+ #
6
+ # Obtain instances via the +ML_DSA_44+, +ML_DSA_65+, +ML_DSA_87+ constants;
7
+ # do not call +new+ directly. Supports +Comparable+ ordering by security
8
+ # level: +ML_DSA_44 < ML_DSA_65 < ML_DSA_87+.
9
+ class ParameterSet
10
+ include Comparable
11
+
12
+ # @return [String] human-readable name, e.g. +"ML-DSA-44"+
13
+ attr_reader :name
14
+ # @return [Integer] numeric code (44, 65, or 87)
15
+ attr_reader :code
16
+ # @return [Integer] NIST security level (2, 3, or 5)
17
+ attr_reader :security_level
18
+ # @return [Integer] public key size in bytes
19
+ attr_reader :public_key_bytes
20
+ # @return [Integer] secret key size in bytes
21
+ attr_reader :secret_key_bytes
22
+ # @return [Integer] signature size in bytes
23
+ attr_reader :signature_bytes
24
+
25
+ def initialize(name, code, security_level, pk, sk, sig)
26
+ @name = name.freeze
27
+ @code = code
28
+ @security_level = security_level
29
+ @public_key_bytes = pk
30
+ @secret_key_bytes = sk
31
+ @signature_bytes = sig
32
+ freeze
33
+ end
34
+
35
+ def <=>(other)
36
+ return nil unless other.is_a?(ParameterSet)
37
+ @security_level <=> other.security_level
38
+ end
39
+
40
+ def to_s
41
+ @name
42
+ end
43
+
44
+ def inspect
45
+ "#<MlDsa::ParameterSet #{@name}>"
46
+ end
47
+
48
+ private_class_method :new
49
+ end
50
+
51
+ # Build ParameterSet constants from C extension data — no raw integer
52
+ # constants are exposed in the public API.
53
+ # Each row is [code, security_level, pk_bytes, sk_bytes, sig_bytes].
54
+ _param_data.each do |code, security_level, pk_bytes, sk_bytes, sig_bytes|
55
+ name = "ML-DSA-#{code}"
56
+ const_name = "ML_DSA_#{code}"
57
+ ps = ParameterSet.send(:new,
58
+ name, code, security_level, pk_bytes, sk_bytes, sig_bytes)
59
+ const_set(const_name, ps)
60
+ end
61
+
62
+ # OIDs assigned by NIST for ML-DSA parameter sets (FIPS 204).
63
+ ML_DSA_OIDS = {
64
+ 44 => "2.16.840.1.101.3.4.3.17",
65
+ 65 => "2.16.840.1.101.3.4.3.18",
66
+ 87 => "2.16.840.1.101.3.4.3.19"
67
+ }.freeze
68
+
69
+ ML_DSA_OID_TO_CODE = ML_DSA_OIDS.invert.freeze
70
+
71
+ # O(1) lookup tables for param_set_for_signature_size / param_set_for_pk_size
72
+ PARAM_SET_BY_SIG_SIZE = constants.filter_map { |c|
73
+ ps = const_get(c)
74
+ [ps.signature_bytes, ps] if ps.is_a?(ParameterSet)
75
+ }.to_h.freeze
76
+ private_constant :PARAM_SET_BY_SIG_SIZE
77
+
78
+ PARAM_SET_BY_PK_SIZE = constants.filter_map { |c|
79
+ ps = const_get(c)
80
+ [ps.public_key_bytes, ps] if ps.is_a?(ParameterSet)
81
+ }.to_h.freeze
82
+ private_constant :PARAM_SET_BY_PK_SIZE
83
+
84
+ PARAM_SET_BY_SK_SIZE = constants.filter_map { |c|
85
+ ps = const_get(c)
86
+ [ps.secret_key_bytes, ps] if ps.is_a?(ParameterSet)
87
+ }.to_h.freeze
88
+ private_constant :PARAM_SET_BY_SK_SIZE
89
+ end