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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +104 -0
- data/LICENSE +14 -0
- data/LICENSE-APACHE +185 -0
- data/LICENSE-MIT +21 -0
- data/README.md +234 -0
- data/ext/ml_dsa/extconf.rb +47 -0
- data/ext/ml_dsa/fips202.c +933 -0
- data/ext/ml_dsa/fips202.h +166 -0
- data/ext/ml_dsa/ml-dsa-44/clean/api.h +52 -0
- data/ext/ml_dsa/ml-dsa-44/clean/ntt.c +98 -0
- data/ext/ml_dsa/ml-dsa-44/clean/ntt.h +10 -0
- data/ext/ml_dsa/ml-dsa-44/clean/packing.c +261 -0
- data/ext/ml_dsa/ml-dsa-44/clean/packing.h +31 -0
- data/ext/ml_dsa/ml-dsa-44/clean/params.h +44 -0
- data/ext/ml_dsa/ml-dsa-44/clean/poly.c +848 -0
- data/ext/ml_dsa/ml-dsa-44/clean/poly.h +52 -0
- data/ext/ml_dsa/ml-dsa-44/clean/polyvec.c +415 -0
- data/ext/ml_dsa/ml-dsa-44/clean/polyvec.h +65 -0
- data/ext/ml_dsa/ml-dsa-44/clean/reduce.c +69 -0
- data/ext/ml_dsa/ml-dsa-44/clean/reduce.h +17 -0
- data/ext/ml_dsa/ml-dsa-44/clean/rounding.c +98 -0
- data/ext/ml_dsa/ml-dsa-44/clean/rounding.h +14 -0
- data/ext/ml_dsa/ml-dsa-44/clean/sign.c +417 -0
- data/ext/ml_dsa/ml-dsa-44/clean/sign.h +49 -0
- data/ext/ml_dsa/ml-dsa-44/clean/symmetric-shake.c +26 -0
- data/ext/ml_dsa/ml-dsa-44/clean/symmetric.h +34 -0
- data/ext/ml_dsa/ml-dsa-65/clean/api.h +52 -0
- data/ext/ml_dsa/ml-dsa-65/clean/ntt.c +98 -0
- data/ext/ml_dsa/ml-dsa-65/clean/ntt.h +10 -0
- data/ext/ml_dsa/ml-dsa-65/clean/packing.c +261 -0
- data/ext/ml_dsa/ml-dsa-65/clean/packing.h +31 -0
- data/ext/ml_dsa/ml-dsa-65/clean/params.h +44 -0
- data/ext/ml_dsa/ml-dsa-65/clean/poly.c +799 -0
- data/ext/ml_dsa/ml-dsa-65/clean/poly.h +52 -0
- data/ext/ml_dsa/ml-dsa-65/clean/polyvec.c +415 -0
- data/ext/ml_dsa/ml-dsa-65/clean/polyvec.h +65 -0
- data/ext/ml_dsa/ml-dsa-65/clean/reduce.c +69 -0
- data/ext/ml_dsa/ml-dsa-65/clean/reduce.h +17 -0
- data/ext/ml_dsa/ml-dsa-65/clean/rounding.c +92 -0
- data/ext/ml_dsa/ml-dsa-65/clean/rounding.h +14 -0
- data/ext/ml_dsa/ml-dsa-65/clean/sign.c +415 -0
- data/ext/ml_dsa/ml-dsa-65/clean/sign.h +49 -0
- data/ext/ml_dsa/ml-dsa-65/clean/symmetric-shake.c +26 -0
- data/ext/ml_dsa/ml-dsa-65/clean/symmetric.h +34 -0
- data/ext/ml_dsa/ml-dsa-87/clean/api.h +52 -0
- data/ext/ml_dsa/ml-dsa-87/clean/ntt.c +98 -0
- data/ext/ml_dsa/ml-dsa-87/clean/ntt.h +10 -0
- data/ext/ml_dsa/ml-dsa-87/clean/packing.c +261 -0
- data/ext/ml_dsa/ml-dsa-87/clean/packing.h +31 -0
- data/ext/ml_dsa/ml-dsa-87/clean/params.h +44 -0
- data/ext/ml_dsa/ml-dsa-87/clean/poly.c +823 -0
- data/ext/ml_dsa/ml-dsa-87/clean/poly.h +52 -0
- data/ext/ml_dsa/ml-dsa-87/clean/polyvec.c +415 -0
- data/ext/ml_dsa/ml-dsa-87/clean/polyvec.h +65 -0
- data/ext/ml_dsa/ml-dsa-87/clean/reduce.c +69 -0
- data/ext/ml_dsa/ml-dsa-87/clean/reduce.h +17 -0
- data/ext/ml_dsa/ml-dsa-87/clean/rounding.c +92 -0
- data/ext/ml_dsa/ml-dsa-87/clean/rounding.h +14 -0
- data/ext/ml_dsa/ml-dsa-87/clean/sign.c +415 -0
- data/ext/ml_dsa/ml-dsa-87/clean/sign.h +49 -0
- data/ext/ml_dsa/ml-dsa-87/clean/symmetric-shake.c +26 -0
- data/ext/ml_dsa/ml-dsa-87/clean/symmetric.h +34 -0
- data/ext/ml_dsa/ml_dsa_44_impl.c +10 -0
- data/ext/ml_dsa/ml_dsa_65_impl.c +10 -0
- data/ext/ml_dsa/ml_dsa_87_impl.c +10 -0
- data/ext/ml_dsa/ml_dsa_ext.c +1360 -0
- data/ext/ml_dsa/ml_dsa_impl_template.h +35 -0
- data/ext/ml_dsa/ml_dsa_internal.h +188 -0
- data/ext/ml_dsa/randombytes.c +48 -0
- data/ext/ml_dsa/randombytes.h +15 -0
- data/lib/ml_dsa/batch_builder.rb +57 -0
- data/lib/ml_dsa/config.rb +69 -0
- data/lib/ml_dsa/internal.rb +76 -0
- data/lib/ml_dsa/key_pair.rb +39 -0
- data/lib/ml_dsa/parameter_set.rb +89 -0
- data/lib/ml_dsa/public_key.rb +180 -0
- data/lib/ml_dsa/requests.rb +96 -0
- data/lib/ml_dsa/secret_key.rb +221 -0
- data/lib/ml_dsa/version.rb +5 -0
- data/lib/ml_dsa.rb +277 -0
- data/patches/README.md +55 -0
- data/patches/pqclean-explicit-rnd.patch +64 -0
- data/sig/ml_dsa.rbs +178 -0
- data/test/fixtures/kat_vectors.yaml +16 -0
- 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
|