Class: MlDsa::SecretKey
- Inherits:
-
Object
- Object
- MlDsa::SecretKey
- Defined in:
- lib/ml_dsa/secret_key.rb,
ext/ml_dsa/ml_dsa_ext.c
Overview
SecretKey — reopen the C TypedData class to add Ruby-level methods.
C methods: param_set, public_key, seed, bytesize, with_bytes, wipe!, inspect, to_s, ==, eql?, hash, initialize_copy, _dump_data.
sign is defined HERE in Ruby — it delegates to sign_many (batch C API) with a single-element array. This eliminates a separate single-op sign C codepath that duplicated the batch logic.
DER/PEM serialization is defined HERE in Ruby via the pqc_asn1 gem. SecureBuffer from pqc_asn1 handles secure zeroing of DER intermediates.
Key lives in C-managed memory that is secure_zero'd on GC. SecretKey is intentionally NOT frozen so wipe! is semantically consistent with Ruby's mutability contract. dup/clone raise TypeError (C: initialize_copy). Marshal.dump raises TypeError (C: _dump_data).
Instance Attribute Summary collapse
-
#created_at ⇒ Time
readonly
When this key was created (set by keygen/from_bytes/from_der/from_pem).
-
#key_usage ⇒ Symbol?
Application-defined usage label.
Class Method Summary collapse
- ._from_bytes_raw(rb_raw, rb_ps_code) ⇒ Object
-
.from_bytes(bytes, param_set = nil) ⇒ SecretKey
Deserialize a secret key from raw binary bytes.
-
.from_der(der) ⇒ SecretKey
Deserialize a secret key from PKCS#8 / OneAsymmetricKey DER.
-
.from_hex(hex, param_set = nil) ⇒ SecretKey
Deserialize a secret key from a lowercase or uppercase hex string.
-
.from_pem(pem) ⇒ SecretKey
Deserialize a secret key from PEM-encoded PKCS#8.
-
.from_seed(seed, param_set) ⇒ SecretKey
Reconstruct a secret key deterministically from a 32-byte seed.
Instance Method Summary collapse
-
#==(other) ⇒ Object
uses ct_memeq — secret key material must not leak timing.
-
#_dump_data ⇒ Object
Marshal prevention — key material must not be silently serialised.
- #bytesize ⇒ Object
- #eql?(other) ⇒ Boolean
- #hash ⇒ Object
-
#initialize_copy(orig) ⇒ Object
dup/clone prevention — would create a NULL-bytes object.
- #inspect ⇒ Object
-
#param_set ⇒ Object
==================================================================.
-
#public_key ⇒ Object
Returns the associated PublicKey, or nil if the key was deserialized without one (e.g. from_bytes, from_der).
-
#seed ⇒ Object
Returns the 32-byte keygen seed as a frozen binary String, or nil if the key was not created from a seed (random keygen, from_bytes, etc.).
-
#sign(message, deterministic: false, context: "") ⇒ String
Sign a message.
-
#to_der ⇒ String
Build PKCS#8 / OneAsymmetricKey DER using the pqc_asn1 gem.
-
#to_pem ⇒ String
Build PEM-encoded PKCS#8 / OneAsymmetricKey using the pqc_asn1 gem.
- #to_s ⇒ Object
-
#wipe! ⇒ Object
wipe! -> nil.
- #with_bytes ⇒ Object
Instance Attribute Details
#created_at ⇒ Time (readonly)
Returns when this key was created (set by keygen/from_bytes/from_der/from_pem).
191 192 193 |
# File 'lib/ml_dsa/secret_key.rb', line 191 def created_at @created_at end |
#key_usage ⇒ Symbol?
Returns application-defined usage label.
194 195 196 |
# File 'lib/ml_dsa/secret_key.rb', line 194 def key_usage @key_usage end |
Class Method Details
._from_bytes_raw(rb_raw, rb_ps_code) ⇒ Object
690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 |
# File 'ext/ml_dsa/ml_dsa_ext.c', line 690
static VALUE sk_from_bytes_raw(VALUE klass, VALUE rb_raw, VALUE rb_ps_code)
{
Check_Type(rb_raw, T_STRING);
int ps_code = NUM2INT(rb_ps_code);
const ml_dsa_impl_t *impl = find_impl(ps_code);
if ((size_t)RSTRING_LEN(rb_raw) != impl->sk_len)
rb_raise(rb_eArgError,
"expected %lu bytes for ML-DSA-%d, got %ld",
(unsigned long)impl->sk_len, ps_code, RSTRING_LEN(rb_raw));
return sk_new_from_buf(klass,
(const uint8_t *)RSTRING_PTR(rb_raw),
(size_t)RSTRING_LEN(rb_raw),
ps_code);
}
|
.from_bytes(bytes, param_set = nil) ⇒ SecretKey
Deserialize a secret key from raw binary bytes.
When param_set is omitted, the parameter set is auto-detected from the byte length (each ML-DSA parameter set has a unique SK size).
The bytes are copied into C-managed memory; the caller's String is independent. The returned SecretKey will zero its copy on GC. Prefer with_bytes { |b| ... } for automatic wipe-on-exit.
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
# File 'lib/ml_dsa/secret_key.rb', line 85 def self.from_bytes(bytes, param_set = nil) raise TypeError, "bytes must be a String" unless bytes.is_a?(String) if param_set ps = Internal.resolve_ps(param_set) unless bytes.bytesize == ps.secret_key_bytes raise ArgumentError, "expected #{ps.secret_key_bytes} bytes for #{ps.name}, " \ "got #{bytes.bytesize}" end else ps = PARAM_SET_BY_SK_SIZE[bytes.bytesize] unless ps raise ArgumentError, "cannot auto-detect parameter set from #{bytes.bytesize}-byte secret key " \ "(expected #{PARAM_SET_BY_SK_SIZE.keys.sort.join(", ")})" end end sk = _from_bytes_raw(bytes.b, ps.code) sk.instance_variable_set(:@created_at, Time.now.freeze) sk end |
.from_der(der) ⇒ SecretKey
Deserialize a secret key from PKCS#8 / OneAsymmetricKey DER. Uses pqc_asn1 gem for parsing; secret key bytes are held in a SecureBuffer and only temporarily unlocked to create the key.
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 |
# File 'lib/ml_dsa/secret_key.rb', line 122 def self.from_der(der) raise TypeError, "der must be a String, got #{der.class}" unless der.is_a?(String) begin info = PqcAsn1::DER.parse_pkcs8(der) rescue PqcAsn1::ParseError, PqcAsn1::Error => e Internal.raise_deser("DER", e.respond_to?(:offset) ? e.offset : nil, e.respond_to?(:code) ? e.code.to_s : "parse_error", e.) end oid_code = ML_DSA_OID_TO_CODE[info.oid.dotted] unless oid_code Internal.raise_deser("DER", nil, "unknown_oid", "unknown ML-DSA OID: #{info.oid.dotted}") end ps = Internal.param_set_for_code(oid_code) # info.key is a PqcAsn1::SecureBuffer — unlock temporarily sk = info.key.use do |raw_bytes| unless raw_bytes.bytesize == ps.secret_key_bytes Internal.raise_deser("DER", nil, "wrong_key_size", "invalid DER: secret key is #{raw_bytes.bytesize} bytes, " \ "expected #{ps.secret_key_bytes} for #{ps.name}") end _from_bytes_raw(raw_bytes.b, ps.code) end info.key.wipe! sk.instance_variable_set(:@created_at, Time.now.freeze) sk end |
.from_hex(hex, param_set = nil) ⇒ SecretKey
Deserialize a secret key from a lowercase or uppercase hex string.
112 113 114 |
# File 'lib/ml_dsa/secret_key.rb', line 112 def self.from_hex(hex, param_set = nil) from_bytes(Internal.decode_hex(hex), param_set) end |
.from_pem(pem) ⇒ SecretKey
Deserialize a secret key from PEM-encoded PKCS#8.
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 |
# File 'lib/ml_dsa/secret_key.rb', line 154 def self.from_pem(pem) raise TypeError, "pem must be a String, got #{pem.class}" unless pem.is_a?(String) begin result = PqcAsn1::PEM.decode_auto(pem) rescue PqcAsn1::ParseError, PqcAsn1::Error => e Internal.raise_deser("PEM", nil, "missing_armor", e.) end unless result.label == "PRIVATE KEY" Internal.raise_deser("PEM", nil, "wrong_label", "invalid PEM: expected PRIVATE KEY, found #{result.label}") end begin info = PqcAsn1::DER.parse_pkcs8(result.data) rescue PqcAsn1::ParseError, PqcAsn1::Error => e Internal.raise_deser("PEM", e.respond_to?(:offset) ? e.offset : nil, e.respond_to?(:code) ? e.code.to_s : "parse_error", e.) end oid_code = ML_DSA_OID_TO_CODE[info.oid.dotted] unless oid_code Internal.raise_deser("PEM", nil, "unknown_oid", "unknown ML-DSA OID: #{info.oid.dotted}") end ps = Internal.param_set_for_code(oid_code) sk = info.key.use do |raw_bytes| unless raw_bytes.bytesize == ps.secret_key_bytes Internal.raise_deser("PEM", nil, "wrong_key_size", "invalid PEM: secret key is #{raw_bytes.bytesize} bytes, " \ "expected #{ps.secret_key_bytes} for #{ps.name}") end _from_bytes_raw(raw_bytes.b, ps.code) end info.key.wipe! sk.instance_variable_set(:@created_at, Time.now.freeze) sk end |
.from_seed(seed, param_set) ⇒ SecretKey
Reconstruct a secret key deterministically from a 32-byte seed.
This is the compact "seed-only" approach: store just 32 bytes and
expand to the full secret key (+ public key) on demand. The
returned SecretKey has public_key set automatically.
216 217 218 219 |
# File 'lib/ml_dsa/secret_key.rb', line 216 def self.from_seed(seed, param_set) pair = MlDsa.keygen(param_set, seed: seed) pair.secret_key end |
Instance Method Details
#==(other) ⇒ Object
uses ct_memeq — secret key material must not leak timing
665 666 667 668 669 670 671 672 673 674 675 |
# File 'ext/ml_dsa/ml_dsa_ext.c', line 665
static VALUE sk_equal(VALUE self, VALUE other)
{
if (!rb_obj_is_kind_of(other, rb_cSecretKey)) return Qfalse;
ml_dsa_sk_t *d1, *d2;
TypedData_Get_Struct(self, ml_dsa_sk_t, &ml_dsa_sk_type, d1);
TypedData_Get_Struct(other, ml_dsa_sk_t, &ml_dsa_sk_type, d2);
SK_CHECK_WIPED(d1);
SK_CHECK_WIPED(d2);
if (d1->len != d2->len || d1->ps_code != d2->ps_code) return Qfalse;
return ct_memeq(d1->bytes, d2->bytes, d1->len) ? Qtrue : Qfalse;
}
|
#_dump_data ⇒ Object
Marshal prevention — key material must not be silently serialised
718 719 720 721 722 723 724 725 |
# File 'ext/ml_dsa/ml_dsa_ext.c', line 718
static VALUE sk_dump_data(VALUE self)
{
(void)self;
rb_raise(rb_eTypeError,
"MlDsa::SecretKey cannot be marshalled; "
"use to_der/from_der or with_bytes for serialization");
return Qnil;
}
|
#bytesize ⇒ Object
567 568 569 570 571 572 573 |
# File 'ext/ml_dsa/ml_dsa_ext.c', line 567
static VALUE sk_bytesize(VALUE self)
{
ml_dsa_sk_t *d;
TypedData_Get_Struct(self, ml_dsa_sk_t, &ml_dsa_sk_type, d);
SK_CHECK_WIPED(d);
return SIZET2NUM(d->len);
}
|
#eql?(other) ⇒ Boolean
677 678 679 680 |
# File 'ext/ml_dsa/ml_dsa_ext.c', line 677
static VALUE sk_eql(VALUE self, VALUE other)
{
return sk_equal(self, other);
}
|
#hash ⇒ Object
682 683 684 685 686 687 688 |
# File 'ext/ml_dsa/ml_dsa_ext.c', line 682
static VALUE sk_hash(VALUE self)
{
ml_dsa_sk_t *d;
TypedData_Get_Struct(self, ml_dsa_sk_t, &ml_dsa_sk_type, d);
SK_CHECK_WIPED(d);
return hash_key_bytes(d->ps_code, d->bytes, d->len);
}
|
#initialize_copy(orig) ⇒ Object
dup/clone prevention — would create a NULL-bytes object
708 709 710 711 712 713 714 715 |
# File 'ext/ml_dsa/ml_dsa_ext.c', line 708
static VALUE sk_initialize_copy(VALUE self, VALUE orig)
{
(void)self; (void)orig;
rb_raise(rb_eTypeError,
"MlDsa::SecretKey cannot be duplicated; "
"use from_bytes or from_der to create a copy");
return Qnil;
}
|
#inspect ⇒ Object
648 649 650 651 652 653 654 655 656 657 |
# File 'ext/ml_dsa/ml_dsa_ext.c', line 648
static VALUE sk_inspect(VALUE self)
{
ml_dsa_sk_t *d;
TypedData_Get_Struct(self, ml_dsa_sk_t, &ml_dsa_sk_type, d);
VALUE ps = lookup_param_set(d->ps_code);
VALUE ps_name = rb_funcall(ps, id_name, 0);
if (ML_DSA_ATOMIC_LOAD(&d->wiped))
return rb_sprintf("#<MlDsa::SecretKey %"PRIsVALUE" [wiped]>", ps_name);
return rb_sprintf("#<MlDsa::SecretKey %"PRIsVALUE">", ps_name);
}
|
#param_set ⇒ Object
==================================================================
521 522 523 524 525 526 |
# File 'ext/ml_dsa/ml_dsa_ext.c', line 521
static VALUE sk_param_set(VALUE self)
{
ml_dsa_sk_t *d;
TypedData_Get_Struct(self, ml_dsa_sk_t, &ml_dsa_sk_type, d);
return lookup_param_set(d->ps_code);
}
|
#public_key ⇒ Object
Returns the associated PublicKey, or nil if the key was deserialized without one (e.g. from_bytes, from_der).
530 531 532 533 |
# File 'ext/ml_dsa/ml_dsa_ext.c', line 530
static VALUE sk_public_key(VALUE self)
{
return rb_ivar_get(self, id_public_key);
}
|
#seed ⇒ Object
Returns the 32-byte keygen seed as a frozen binary String, or nil if the key was not created from a seed (random keygen, from_bytes, etc.).
555 556 557 558 559 560 561 562 563 564 565 |
# File 'ext/ml_dsa/ml_dsa_ext.c', line 555
static VALUE sk_seed(VALUE self)
{
ml_dsa_sk_t *d;
TypedData_Get_Struct(self, ml_dsa_sk_t, &ml_dsa_sk_type, d);
SK_CHECK_WIPED(d);
if (!d->has_seed) return Qnil;
VALUE s = rb_str_new((const char *)d->seed, ML_DSA_SEED_BYTES);
rb_enc_associate(s, rb_ascii8bit_encoding());
OBJ_FREEZE(s);
return s;
}
|
#sign(message, deterministic: false, context: "") ⇒ String
Sign a message.
Delegates to the batch C API (sign_many) with a single element. This avoids maintaining a separate single-op C sign path.
32 33 34 35 36 37 38 39 40 41 42 43 |
# File 'lib/ml_dsa/secret_key.rb', line 32 def sign(, deterministic: false, context: "") raise TypeError, "message must be a String, got #{.class}" unless .is_a?(String) unless context.is_a?(String) raise TypeError, "context must be a String, got #{context.class}" end if context.bytesize > 255 raise ArgumentError, "context must not exceed 255 bytes" end req = SignRequest.new(sk: self, message: , context: context, deterministic: deterministic) MlDsa.sign_many([req]).first end |
#to_der ⇒ String
Build PKCS#8 / OneAsymmetricKey DER using the pqc_asn1 gem. The raw key bytes are accessed via with_bytes and the intermediate DER is held in a pqc_asn1 SecureBuffer (mmap-protected, securely zeroed).
49 50 51 52 53 54 55 56 57 58 59 |
# File 'lib/ml_dsa/secret_key.rb', line 49 def to_der oid = PqcAsn1::OID[ML_DSA_OIDS[param_set.code]] with_bytes do |raw| secure_buf = PqcAsn1::DER.build_pkcs8(oid, raw, validate: false) # Force a real memory copy (not CoW) since SecureBuffer will # re-lock the mmap'd page after the use block returns. result = secure_buf.use { |der_bytes| "".b << der_bytes } secure_buf.wipe! result.freeze end end |
#to_pem ⇒ String
Build PEM-encoded PKCS#8 / OneAsymmetricKey using the pqc_asn1 gem.
63 64 65 66 67 68 69 70 71 |
# File 'lib/ml_dsa/secret_key.rb', line 63 def to_pem oid = PqcAsn1::OID[ML_DSA_OIDS[param_set.code]] with_bytes do |raw| secure_buf = PqcAsn1::DER.build_pkcs8(oid, raw, validate: false) result = PqcAsn1::PEM.encode(secure_buf, "PRIVATE KEY") secure_buf.wipe! result end end |
#to_s ⇒ Object
659 660 661 662 |
# File 'ext/ml_dsa/ml_dsa_ext.c', line 659
static VALUE sk_to_s(VALUE self)
{
return sk_inspect(self);
}
|
#wipe! ⇒ Object
wipe! -> nil
Explicitly zeroes and frees the key bytes. Callable even on a frozen object because the bytes live in C-managed memory, not Ruby ivars. After wipe! the key cannot be used for signing; inspect and param_set still work and show [wiped] status.
Thread safety: the wiped flag is atomic, so a concurrent wipe! from another thread is visible to SK_CHECK_WIPED immediately. However, if one thread calls wipe! while another is between SK_CHECK_WIPED and the GVL drop in sign, the signing thread has already copied the sk_bytes pointer — secure_zero will zero the bytes under it. This is safe (PQClean reads from the buffer, which is now zeros, and will produce a garbage signature) but callers should still ensure wipe! is called only after all signing threads have finished.
632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 |
# File 'ext/ml_dsa/ml_dsa_ext.c', line 632
static VALUE sk_wipe(VALUE self)
{
ml_dsa_sk_t *d;
TypedData_Get_Struct(self, ml_dsa_sk_t, &ml_dsa_sk_type, d);
if (!ML_DSA_ATOMIC_LOAD(&d->wiped) && d->len > 0) {
secure_zero(d->bytes, d->len);
sk_munlock(d->bytes, d->len);
if (d->has_seed) {
secure_zero(d->seed, ML_DSA_SEED_BYTES);
d->has_seed = 0;
}
ML_DSA_ATOMIC_STORE(&d->wiped, 1);
}
return Qnil;
}
|
#with_bytes ⇒ Object
604 605 606 607 608 609 610 611 612 613 |
# File 'ext/ml_dsa/ml_dsa_ext.c', line 604
static VALUE sk_with_bytes(VALUE self)
{
if (!rb_block_given_p())
rb_raise(rb_eArgError, "with_bytes requires a block");
ml_dsa_sk_t *d;
TypedData_Get_Struct(self, ml_dsa_sk_t, &ml_dsa_sk_type, d);
SK_CHECK_WIPED(d);
VALUE buf = rb_str_new((const char *)d->bytes, (long)d->len);
return ML_DSA_ENSURE(sk_with_bytes_yield, sk_with_bytes_ensure, buf);
}
|