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,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MlDsa
4
+ # PublicKey — reopen the C TypedData class to add Ruby-level methods.
5
+ #
6
+ # C methods: param_set, bytesize, to_bytes, to_hex, fingerprint,
7
+ # to_s, inspect, ==, eql?, hash, initialize_copy, _dump_data.
8
+ #
9
+ # verify is defined HERE in Ruby — it delegates to verify_many (batch
10
+ # C API) with a single-element array. This eliminates a separate
11
+ # single-op verify C codepath that duplicated the batch logic.
12
+ #
13
+ # Bytes live in C-managed memory with a stable pointer.
14
+ # dup/clone raise TypeError (C: initialize_copy).
15
+ # Marshal.dump raises TypeError (C: _dump_data).
16
+
17
+ class PublicKey
18
+ # Verify a signature.
19
+ #
20
+ # Delegates to the batch C API (verify_many) with a single element.
21
+ # This avoids maintaining a separate single-op C verify path.
22
+ #
23
+ # Returns false (not raises) for: wrong-size signature, context >255 bytes,
24
+ # or cryptographic verification failure.
25
+ # Raises TypeError for non-String message/signature/context.
26
+ #
27
+ # @param message [String]
28
+ # @param signature [String]
29
+ # @param context [String] FIPS 204 context string (0..255 bytes)
30
+ # @return [Boolean]
31
+ def verify(message, signature, context: "")
32
+ raise TypeError, "message must be a String, got #{message.class}" unless message.is_a?(String)
33
+ raise TypeError, "signature must be a String, got #{signature.class}" unless signature.is_a?(String)
34
+ raise TypeError, "context must be a String, got #{context.class}" unless context.is_a?(String)
35
+ # Context >255 bytes can never verify per FIPS 204 — return false
36
+ # rather than raising, since verify is a predicate.
37
+ return false unless context.bytesize <= 255
38
+ # Early-reject wrong-size signatures without entering C
39
+ return false unless signature.bytesize == param_set.signature_bytes
40
+ req = VerifyRequest.new(pk: self, message: message,
41
+ signature: signature, context: context)
42
+ MlDsa.verify_many([req]).first.ok?
43
+ end
44
+
45
+ # Build SubjectPublicKeyInfo DER using the pqc_asn1 gem.
46
+ # @return [String] frozen binary DER (ASCII-8BIT)
47
+ def to_der
48
+ oid = PqcAsn1::OID[ML_DSA_OIDS[param_set.code]]
49
+ PqcAsn1::DER.build_spki(oid, to_bytes, validate: false)
50
+ end
51
+
52
+ # Build PEM-encoded SubjectPublicKeyInfo using the pqc_asn1 gem.
53
+ # @return [String] frozen PEM string
54
+ def to_pem
55
+ PqcAsn1::PEM.encode(to_der, "PUBLIC KEY")
56
+ end
57
+
58
+ # fingerprint is now a C method (lazy-computed, cached in struct).
59
+ # See pk_fingerprint in ml_dsa_ext.c.
60
+
61
+ # Deserialize a public key from raw binary bytes.
62
+ #
63
+ # When param_set is omitted, the parameter set is auto-detected from
64
+ # the byte length (each ML-DSA parameter set has a unique PK size).
65
+ #
66
+ # @param bytes [String]
67
+ # @param param_set [ParameterSet, nil] auto-detected from size if omitted
68
+ # @return [PublicKey]
69
+ def self.from_bytes(bytes, param_set = nil)
70
+ raise TypeError, "bytes must be a String, got #{bytes.class}" unless bytes.is_a?(String)
71
+ if param_set
72
+ ps = Internal.resolve_ps(param_set)
73
+ unless bytes.bytesize == ps.public_key_bytes
74
+ raise ArgumentError,
75
+ "expected #{ps.public_key_bytes} bytes for #{ps.name}, " \
76
+ "got #{bytes.bytesize}"
77
+ end
78
+ else
79
+ ps = PARAM_SET_BY_PK_SIZE[bytes.bytesize]
80
+ unless ps
81
+ raise ArgumentError,
82
+ "cannot auto-detect parameter set from #{bytes.bytesize}-byte public key " \
83
+ "(expected #{PARAM_SET_BY_PK_SIZE.keys.sort.join(", ")})"
84
+ end
85
+ end
86
+ pk = _from_bytes_raw(bytes.b, ps.code)
87
+ pk.instance_variable_set(:@created_at, Time.now.freeze)
88
+ pk
89
+ end
90
+
91
+ # Deserialize a public key from a lowercase or uppercase hex string.
92
+ #
93
+ # @param hex [String]
94
+ # @param param_set [ParameterSet, nil] auto-detected from size if omitted
95
+ # @return [PublicKey]
96
+ def self.from_hex(hex, param_set = nil)
97
+ from_bytes(Internal.decode_hex(hex), param_set)
98
+ end
99
+
100
+ # Deserialize a public key from SubjectPublicKeyInfo DER.
101
+ # @param der [String] DER-encoded SPKI
102
+ # @return [PublicKey]
103
+ # @raise [MlDsa::Error::Deserialization] on malformed or unrecognized input
104
+ def self.from_der(der)
105
+ raise TypeError, "der must be a String, got #{der.class}" unless der.is_a?(String)
106
+ begin
107
+ info = PqcAsn1::DER.parse_spki(der)
108
+ rescue PqcAsn1::ParseError, PqcAsn1::Error => e
109
+ Internal.raise_deser("DER", e.respond_to?(:offset) ? e.offset : nil,
110
+ e.respond_to?(:code) ? e.code.to_s : "parse_error", e.message)
111
+ end
112
+ oid_code = ML_DSA_OID_TO_CODE[info.oid.dotted]
113
+ unless oid_code
114
+ Internal.raise_deser("DER", nil, "unknown_oid",
115
+ "unknown ML-DSA OID: #{info.oid.dotted}")
116
+ end
117
+ ps = Internal.param_set_for_code(oid_code)
118
+ unless info.key.bytesize == ps.public_key_bytes
119
+ Internal.raise_deser("DER", nil, "wrong_key_size",
120
+ "invalid DER: public key is #{info.key.bytesize} bytes, " \
121
+ "expected #{ps.public_key_bytes} for #{ps.name}")
122
+ end
123
+ pk = from_bytes(info.key, ps)
124
+ pk.instance_variable_set(:@created_at, Time.now.freeze)
125
+ pk
126
+ end
127
+
128
+ # Deserialize a public key from PEM-encoded SubjectPublicKeyInfo.
129
+ # @param pem [String] PEM-encoded public key
130
+ # @return [PublicKey]
131
+ # @raise [MlDsa::Error::Deserialization] on malformed or unrecognized input
132
+ def self.from_pem(pem)
133
+ raise TypeError, "pem must be a String, got #{pem.class}" unless pem.is_a?(String)
134
+ begin
135
+ result = PqcAsn1::PEM.decode_auto(pem)
136
+ rescue PqcAsn1::ParseError, PqcAsn1::Error => e
137
+ Internal.raise_deser("PEM", nil, "missing_armor", e.message)
138
+ end
139
+ unless result.label == "PUBLIC KEY"
140
+ Internal.raise_deser("PEM", nil, "wrong_label",
141
+ "invalid PEM: expected PUBLIC KEY, found #{result.label}")
142
+ end
143
+ begin
144
+ info = PqcAsn1::DER.parse_spki(result.data)
145
+ rescue PqcAsn1::ParseError, PqcAsn1::Error => e
146
+ Internal.raise_deser("PEM", e.respond_to?(:offset) ? e.offset : nil,
147
+ e.respond_to?(:code) ? e.code.to_s : "parse_error", e.message)
148
+ end
149
+ oid_code = ML_DSA_OID_TO_CODE[info.oid.dotted]
150
+ unless oid_code
151
+ Internal.raise_deser("PEM", nil, "unknown_oid",
152
+ "unknown ML-DSA OID: #{info.oid.dotted}")
153
+ end
154
+ ps = Internal.param_set_for_code(oid_code)
155
+ unless info.key.bytesize == ps.public_key_bytes
156
+ Internal.raise_deser("PEM", nil, "wrong_key_size",
157
+ "invalid PEM: public key is #{info.key.bytesize} bytes, " \
158
+ "expected #{ps.public_key_bytes} for #{ps.name}")
159
+ end
160
+ pk = from_bytes(info.key, ps)
161
+ pk.instance_variable_set(:@created_at, Time.now.freeze)
162
+ pk
163
+ end
164
+
165
+ # @return [Time] when this key was created (set by keygen/from_bytes/from_der/from_pem)
166
+ attr_reader :created_at
167
+
168
+ # @return [Symbol, nil] application-defined usage label
169
+ attr_reader :key_usage
170
+
171
+ # Set an application-defined usage label.
172
+ # @param value [Symbol, nil]
173
+ def key_usage=(value)
174
+ unless value.nil? || value.is_a?(Symbol)
175
+ raise TypeError, "key_usage must be a Symbol or nil, got #{value.class}"
176
+ end
177
+ @key_usage = value
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MlDsa
4
+ # Describes a single sign operation for {MlDsa.sign_many}.
5
+ #
6
+ # @!attribute [rw] sk
7
+ # @return [SecretKey] the signing key
8
+ # @!attribute [rw] message
9
+ # @return [String] the message to sign
10
+ # @!attribute [rw] context
11
+ # @return [String, nil] optional FIPS 204 context (0..255 bytes)
12
+ # @!attribute [rw] deterministic
13
+ # @return [Boolean, nil] use deterministic signing (zero rnd)
14
+ SignRequest = Struct.new(:sk, :message, :context, :deterministic,
15
+ keyword_init: true) do
16
+ # Validates field types before entering C.
17
+ # @return [self]
18
+ # @raise [ArgumentError] if any field has an invalid type
19
+ def validate!
20
+ unless sk.is_a?(MlDsa::SecretKey)
21
+ raise ArgumentError,
22
+ "SignRequest :sk must be a MlDsa::SecretKey, got #{sk.class}"
23
+ end
24
+ unless message.is_a?(String)
25
+ raise ArgumentError,
26
+ "SignRequest :message must be a String, got #{message.class}"
27
+ end
28
+ if !context.nil? && !context.is_a?(String)
29
+ raise ArgumentError,
30
+ "SignRequest :context must be a String or nil, got #{context.class}"
31
+ end
32
+ self
33
+ end
34
+ end
35
+
36
+ # Describes a single verify operation for {MlDsa.verify_many}.
37
+ #
38
+ # @!attribute [rw] pk
39
+ # @return [PublicKey] the verification key
40
+ # @!attribute [rw] message
41
+ # @return [String] the message that was signed
42
+ # @!attribute [rw] signature
43
+ # @return [String] the signature to verify
44
+ # @!attribute [rw] context
45
+ # @return [String, nil] optional FIPS 204 context (0..255 bytes)
46
+ VerifyRequest = Struct.new(:pk, :message, :signature, :context,
47
+ keyword_init: true) do
48
+ # Validates field types before entering C.
49
+ # @return [self]
50
+ # @raise [ArgumentError] if any field has an invalid type
51
+ def validate!
52
+ unless pk.is_a?(MlDsa::PublicKey)
53
+ raise ArgumentError,
54
+ "VerifyRequest :pk must be a MlDsa::PublicKey, got #{pk.class}"
55
+ end
56
+ unless message.is_a?(String)
57
+ raise ArgumentError,
58
+ "VerifyRequest :message must be a String, got #{message.class}"
59
+ end
60
+ unless signature.is_a?(String)
61
+ raise ArgumentError,
62
+ "VerifyRequest :signature must be a String, got #{signature.class}"
63
+ end
64
+ if !context.nil? && !context.is_a?(String)
65
+ raise ArgumentError,
66
+ "VerifyRequest :context must be a String or nil, got #{context.class}"
67
+ end
68
+ self
69
+ end
70
+ end
71
+
72
+ # Result wrapper for batch operations that need per-item error details.
73
+ #
74
+ # @example
75
+ # results = MlDsa.verify_many(operations)
76
+ # results.each do |r|
77
+ # if r.ok?
78
+ # puts "valid"
79
+ # else
80
+ # puts "failed: #{r.reason}"
81
+ # end
82
+ # end
83
+ Result = Struct.new(:value, :ok, :reason, keyword_init: true) do
84
+ # @return [Boolean] true if the operation succeeded
85
+ def ok?
86
+ ok
87
+ end
88
+
89
+ # @return [String]
90
+ def inspect
91
+ ok? ? "#<MlDsa::Result ok>" : "#<MlDsa::Result error=#{reason}>"
92
+ end
93
+
94
+ alias_method :to_s, :inspect
95
+ end
96
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MlDsa
4
+ # SecretKey — reopen the C TypedData class to add Ruby-level methods.
5
+ #
6
+ # C methods: param_set, public_key, seed, bytesize, with_bytes, wipe!,
7
+ # inspect, to_s, ==, eql?, hash, initialize_copy, _dump_data.
8
+ #
9
+ # sign is defined HERE in Ruby — it delegates to sign_many (batch
10
+ # C API) with a single-element array. This eliminates a separate
11
+ # single-op sign C codepath that duplicated the batch logic.
12
+ #
13
+ # DER/PEM serialization is defined HERE in Ruby via the pqc_asn1 gem.
14
+ # SecureBuffer from pqc_asn1 handles secure zeroing of DER intermediates.
15
+ #
16
+ # Key lives in C-managed memory that is secure_zero'd on GC.
17
+ # SecretKey is intentionally NOT frozen so wipe! is semantically
18
+ # consistent with Ruby's mutability contract.
19
+ # dup/clone raise TypeError (C: initialize_copy).
20
+ # Marshal.dump raises TypeError (C: _dump_data).
21
+
22
+ class SecretKey
23
+ # Sign a message.
24
+ #
25
+ # Delegates to the batch C API (sign_many) with a single element.
26
+ # This avoids maintaining a separate single-op C sign path.
27
+ #
28
+ # @param message [String]
29
+ # @param deterministic [Boolean] use zero rnd (reproducible signatures)
30
+ # @param context [String] FIPS 204 context string (0..255 bytes)
31
+ # @return [String] frozen binary signature
32
+ def sign(message, deterministic: false, context: "")
33
+ raise TypeError, "message must be a String, got #{message.class}" unless message.is_a?(String)
34
+ unless context.is_a?(String)
35
+ raise TypeError, "context must be a String, got #{context.class}"
36
+ end
37
+ if context.bytesize > 255
38
+ raise ArgumentError, "context must not exceed 255 bytes"
39
+ end
40
+ req = SignRequest.new(sk: self, message: message,
41
+ context: context, deterministic: deterministic)
42
+ MlDsa.sign_many([req]).first
43
+ end
44
+
45
+ # Build PKCS#8 / OneAsymmetricKey DER using the pqc_asn1 gem.
46
+ # The raw key bytes are accessed via with_bytes and the intermediate
47
+ # DER is held in a pqc_asn1 SecureBuffer (mmap-protected, securely zeroed).
48
+ # @return [String] frozen binary DER (ASCII-8BIT)
49
+ def to_der
50
+ oid = PqcAsn1::OID[ML_DSA_OIDS[param_set.code]]
51
+ with_bytes do |raw|
52
+ secure_buf = PqcAsn1::DER.build_pkcs8(oid, raw, validate: false)
53
+ # Force a real memory copy (not CoW) since SecureBuffer will
54
+ # re-lock the mmap'd page after the use block returns.
55
+ result = secure_buf.use { |der_bytes| "".b << der_bytes }
56
+ secure_buf.wipe!
57
+ result.freeze
58
+ end
59
+ end
60
+
61
+ # Build PEM-encoded PKCS#8 / OneAsymmetricKey using the pqc_asn1 gem.
62
+ # @return [String] frozen PEM string
63
+ def to_pem
64
+ oid = PqcAsn1::OID[ML_DSA_OIDS[param_set.code]]
65
+ with_bytes do |raw|
66
+ secure_buf = PqcAsn1::DER.build_pkcs8(oid, raw, validate: false)
67
+ result = PqcAsn1::PEM.encode(secure_buf, "PRIVATE KEY")
68
+ secure_buf.wipe!
69
+ result
70
+ end
71
+ end
72
+
73
+ # Deserialize a secret key from raw binary bytes.
74
+ #
75
+ # When param_set is omitted, the parameter set is auto-detected from
76
+ # the byte length (each ML-DSA parameter set has a unique SK size).
77
+ #
78
+ # The bytes are copied into C-managed memory; the caller's String is
79
+ # independent. The returned SecretKey will zero its copy on GC.
80
+ # Prefer with_bytes { |b| ... } for automatic wipe-on-exit.
81
+ #
82
+ # @param bytes [String]
83
+ # @param param_set [ParameterSet, nil] auto-detected from size if omitted
84
+ # @return [SecretKey]
85
+ def self.from_bytes(bytes, param_set = nil)
86
+ raise TypeError, "bytes must be a String" unless bytes.is_a?(String)
87
+ if param_set
88
+ ps = Internal.resolve_ps(param_set)
89
+ unless bytes.bytesize == ps.secret_key_bytes
90
+ raise ArgumentError,
91
+ "expected #{ps.secret_key_bytes} bytes for #{ps.name}, " \
92
+ "got #{bytes.bytesize}"
93
+ end
94
+ else
95
+ ps = PARAM_SET_BY_SK_SIZE[bytes.bytesize]
96
+ unless ps
97
+ raise ArgumentError,
98
+ "cannot auto-detect parameter set from #{bytes.bytesize}-byte secret key " \
99
+ "(expected #{PARAM_SET_BY_SK_SIZE.keys.sort.join(", ")})"
100
+ end
101
+ end
102
+ sk = _from_bytes_raw(bytes.b, ps.code)
103
+ sk.instance_variable_set(:@created_at, Time.now.freeze)
104
+ sk
105
+ end
106
+
107
+ # Deserialize a secret key from a lowercase or uppercase hex string.
108
+ #
109
+ # @param hex [String]
110
+ # @param param_set [ParameterSet, nil] auto-detected from size if omitted
111
+ # @return [SecretKey]
112
+ def self.from_hex(hex, param_set = nil)
113
+ from_bytes(Internal.decode_hex(hex), param_set)
114
+ end
115
+
116
+ # Deserialize a secret key from PKCS#8 / OneAsymmetricKey DER.
117
+ # Uses pqc_asn1 gem for parsing; secret key bytes are held in a
118
+ # SecureBuffer and only temporarily unlocked to create the key.
119
+ # @param der [String] DER-encoded PKCS#8
120
+ # @return [SecretKey]
121
+ # @raise [MlDsa::Error::Deserialization] on malformed or unrecognized input
122
+ def self.from_der(der)
123
+ raise TypeError, "der must be a String, got #{der.class}" unless der.is_a?(String)
124
+ begin
125
+ info = PqcAsn1::DER.parse_pkcs8(der)
126
+ rescue PqcAsn1::ParseError, PqcAsn1::Error => e
127
+ Internal.raise_deser("DER", e.respond_to?(:offset) ? e.offset : nil,
128
+ e.respond_to?(:code) ? e.code.to_s : "parse_error", e.message)
129
+ end
130
+ oid_code = ML_DSA_OID_TO_CODE[info.oid.dotted]
131
+ unless oid_code
132
+ Internal.raise_deser("DER", nil, "unknown_oid",
133
+ "unknown ML-DSA OID: #{info.oid.dotted}")
134
+ end
135
+ ps = Internal.param_set_for_code(oid_code)
136
+ # info.key is a PqcAsn1::SecureBuffer — unlock temporarily
137
+ sk = info.key.use do |raw_bytes|
138
+ unless raw_bytes.bytesize == ps.secret_key_bytes
139
+ Internal.raise_deser("DER", nil, "wrong_key_size",
140
+ "invalid DER: secret key is #{raw_bytes.bytesize} bytes, " \
141
+ "expected #{ps.secret_key_bytes} for #{ps.name}")
142
+ end
143
+ _from_bytes_raw(raw_bytes.b, ps.code)
144
+ end
145
+ info.key.wipe!
146
+ sk.instance_variable_set(:@created_at, Time.now.freeze)
147
+ sk
148
+ end
149
+
150
+ # Deserialize a secret key from PEM-encoded PKCS#8.
151
+ # @param pem [String] PEM-encoded private key
152
+ # @return [SecretKey]
153
+ # @raise [MlDsa::Error::Deserialization] on malformed or unrecognized input
154
+ def self.from_pem(pem)
155
+ raise TypeError, "pem must be a String, got #{pem.class}" unless pem.is_a?(String)
156
+ begin
157
+ result = PqcAsn1::PEM.decode_auto(pem)
158
+ rescue PqcAsn1::ParseError, PqcAsn1::Error => e
159
+ Internal.raise_deser("PEM", nil, "missing_armor", e.message)
160
+ end
161
+ unless result.label == "PRIVATE KEY"
162
+ Internal.raise_deser("PEM", nil, "wrong_label",
163
+ "invalid PEM: expected PRIVATE KEY, found #{result.label}")
164
+ end
165
+ begin
166
+ info = PqcAsn1::DER.parse_pkcs8(result.data)
167
+ rescue PqcAsn1::ParseError, PqcAsn1::Error => e
168
+ Internal.raise_deser("PEM", e.respond_to?(:offset) ? e.offset : nil,
169
+ e.respond_to?(:code) ? e.code.to_s : "parse_error", e.message)
170
+ end
171
+ oid_code = ML_DSA_OID_TO_CODE[info.oid.dotted]
172
+ unless oid_code
173
+ Internal.raise_deser("PEM", nil, "unknown_oid",
174
+ "unknown ML-DSA OID: #{info.oid.dotted}")
175
+ end
176
+ ps = Internal.param_set_for_code(oid_code)
177
+ sk = info.key.use do |raw_bytes|
178
+ unless raw_bytes.bytesize == ps.secret_key_bytes
179
+ Internal.raise_deser("PEM", nil, "wrong_key_size",
180
+ "invalid PEM: secret key is #{raw_bytes.bytesize} bytes, " \
181
+ "expected #{ps.secret_key_bytes} for #{ps.name}")
182
+ end
183
+ _from_bytes_raw(raw_bytes.b, ps.code)
184
+ end
185
+ info.key.wipe!
186
+ sk.instance_variable_set(:@created_at, Time.now.freeze)
187
+ sk
188
+ end
189
+
190
+ # @return [Time] when this key was created (set by keygen/from_bytes/from_der/from_pem)
191
+ attr_reader :created_at
192
+
193
+ # @return [Symbol, nil] application-defined usage label
194
+ attr_reader :key_usage
195
+
196
+ # Set an application-defined usage label.
197
+ # @param value [Symbol, nil]
198
+ def key_usage=(value)
199
+ unless value.nil? || value.is_a?(Symbol)
200
+ raise TypeError, "key_usage must be a Symbol or nil, got #{value.class}"
201
+ end
202
+ @key_usage = value
203
+ end
204
+
205
+ # Reconstruct a secret key deterministically from a 32-byte seed.
206
+ #
207
+ # This is the compact "seed-only" approach: store just 32 bytes and
208
+ # expand to the full secret key (+ public key) on demand. The
209
+ # returned SecretKey has +public_key+ set automatically.
210
+ #
211
+ # @param seed [String] exactly 32 bytes
212
+ # @param param_set [ParameterSet] ML_DSA_44, ML_DSA_65, or ML_DSA_87
213
+ # @return [SecretKey] with +public_key+ attached
214
+ # @raise [TypeError] if seed is not a String or param_set is invalid
215
+ # @raise [ArgumentError] if seed is not exactly 32 bytes
216
+ def self.from_seed(seed, param_set)
217
+ pair = MlDsa.keygen(param_set, seed: seed)
218
+ pair.secret_key
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MlDsa
4
+ VERSION = "0.1.0"
5
+ end