Class: MlDsa::SecretKey

Inherits:
Object
  • Object
show all
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

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#created_at ⇒ Time (readonly)

Returns when this key was created (set by keygen/from_bytes/from_der/from_pem).

Returns:

  • (Time) —

    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.

Returns:

  • (Symbol, nil) —

    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.

Parameters:

  • bytes (String)
  • param_set (ParameterSet, nil) (defaults to: nil) —

    auto-detected from size if omitted

Returns:

Raises:

  • (TypeError)


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.

Parameters:

  • der (String) —

    DER-encoded PKCS#8

Returns:

Raises:



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.message)
  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.

Parameters:

  • hex (String)
  • param_set (ParameterSet, nil) (defaults to: nil) —

    auto-detected from size if omitted

Returns:



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.

Parameters:

  • pem (String) —

    PEM-encoded private key

Returns:

Raises:



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.message)
  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.message)
  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.

Parameters:

  • seed (String) —

    exactly 32 bytes

  • param_set (ParameterSet) —

    ML_DSA_44, ML_DSA_65, or ML_DSA_87

Returns:

Raises:

  • (TypeError) —

    if seed is not a String or param_set is invalid

  • (ArgumentError) —

    if seed is not exactly 32 bytes



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

Returns:

  • (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.

Parameters:

  • message (String)
  • deterministic (Boolean) (defaults to: false) —

    use zero rnd (reproducible signatures)

  • context (String) (defaults to: "") —

    FIPS 204 context string (0..255 bytes)

Returns:

  • (String) —

    frozen binary signature

Raises:

  • (TypeError)


32
33
34
35
36
37
38
39
40
41
42
43
# File 'lib/ml_dsa/secret_key.rb', line 32

def sign(message, deterministic: false, context: "")
  raise TypeError, "message must be a String, got #{message.class}" unless message.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: 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).

Returns:

  • (String) —

    frozen binary DER (ASCII-8BIT)



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.

Returns:

  • (String) —

    frozen PEM string



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);
}