Class: Linzer::MLDSA::OpenSSLKey

Inherits:
Key
  • Object
show all
Defined in:
lib/linzer/ml_dsa/openssl_key.rb

Overview

Note:

Requires OpenSSL 3.5+ with ML-DSA signature algorithms enabled. Some distributions ship OpenSSL 3.5+ with these disabled by crypto policy (see https://github.com/ruby/openssl/issues/1075), so callers should be prepared for OpenSSL::PKey::PKeyError on unsupported builds even when the OpenSSL version alone looks sufficient.

ML-DSA (FIPS 204) signing/verification backed directly by OpenSSL 3.5+, with no additional gem dependency. Supports all three parameter sets (ML-DSA-44/65/87).

Like Ed25519, ML-DSA is a "pure"/digest-less signature scheme: the RFC 9421 signature base is signed directly, with no prehashing.

Instance Attribute Summary collapse

Attributes inherited from Key

#material

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Key

#key_id, #private?, #public?

Constructor Details

#initialize(material, params = {}) ⇒ OpenSSLKey

Returns a new instance of OpenSSLKey.

Parameters:

  • material (OpenSSL::PKey::PKey)

    The underlying OpenSSL key

  • params (Hash) (defaults to: {})

    Additional key parameters

Options Hash (params):

  • :algorithm (String)

    Required. One of "ml-dsa-44"/"ml-dsa-65"/"ml-dsa-87"

  • :id (String)

    The key identifier (keyid)



73
74
75
76
# File 'lib/linzer/ml_dsa/openssl_key.rb', line 73

def initialize(material, params = {})
  @algorithm = String(params.fetch(:algorithm))
  super
end

Instance Attribute Details

#algorithmString (readonly)

Returns The FIPS 204 parameter set this key was constructed for, e.g. "ml-dsa-44".

Returns:

  • (String)

    The FIPS 204 parameter set this key was constructed for, e.g. "ml-dsa-44"



66
67
68
# File 'lib/linzer/ml_dsa/openssl_key.rb', line 66

def algorithm
  @algorithm
end

Class Method Details

.deserialize_raw_or_encoded_key(material, algorithm) ⇒ OpenSSL::PKey::PKey

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Builds an OpenSSL key from ML-DSA material of unknown shape: raw FIPS 204 bytes for algorithm (sniffed by exact byte length, the same approach GemKey uses for the gem backend) or an OpenSSL-encoded PEM/DER key, handled as a fallback. Sniffing is scoped to algorithm's own raw sizes, not all three parameter sets' sizes at once, material sized for a different parameter set than requested falls through to the OpenSSL::PKey.read fallback and fails there (raw bytes aren't valid PEM/DER), rather than silently being accepted and mislabeled.

Parameters:

  • material (String)

    Raw FIPS 204 bytes, or a PEM/DER-encoded key

  • algorithm (String)

    Linzer's lowercase algorithm identifier, e.g. "ml-dsa-44"

Returns:

  • (OpenSSL::PKey::PKey)

Raises:

  • (TypeError)

    If material isn't a String



143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/linzer/ml_dsa/openssl_key.rb', line 143

def deserialize_raw_or_encoded_key(material, algorithm)
  raise TypeError, "ML-DSA key material must be a String" unless material.is_a?(String)

  case material.bytesize
  when RAW_PUBLIC_KEY_BYTES.fetch(algorithm)
    wrap_raw_public_key(material, algorithm)
  when RAW_PRIVATE_KEY_BYTES.fetch(algorithm)
    wrap_raw_private_key(material, algorithm)
  else
    OpenSSL::PKey.read(material)
  end
end

.unwrap_raw_private_key(key) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Extracts the raw (expanded, seed-free) FIPS 204 private key bytes from an OpenSSL key's own PKCS8 encoding -- the inverse of wrap_raw_private_key. Used to hand a key generated via this backend to the ml_dsa gem backend instead.

OpenSSL's own generated keys always use the "both" alternative of the ML-DSA private key CHOICE (a seed alongside the expanded key, confirmed empirically -- see the strategy notes), so this only handles that shape; anything else raises rather than silently returning the wrong bytes.

Parameters:

  • key (OpenSSL::PKey::PKey)

    A private ML-DSA key

Returns:

  • (String)

    Raw FIPS 204 expanded private key bytes

Raises:

  • (Error)

    If the key's private key CHOICE isn't the expected seed+expandedKey SEQUENCE



185
186
187
188
189
190
191
192
193
# File 'lib/linzer/ml_dsa/openssl_key.rb', line 185

def unwrap_raw_private_key(key)
  one_asymmetric_key = OpenSSL::ASN1.decode(key.private_to_der)
  private_key_choice = OpenSSL::ASN1.decode(one_asymmetric_key.value[2].value)
  unless private_key_choice.is_a?(OpenSSL::ASN1::Sequence)
    raise Error, "Unsupported ML-DSA private key encoding (expected seed+expandedKey)"
  end

  private_key_choice.value[1].value
end

.unwrap_raw_public_key(key) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Extracts the raw FIPS 204 public key bytes from an OpenSSL key's own SubjectPublicKeyInfo encoding -- the inverse of wrap_raw_public_key. Used to hand a key generated (or loaded) via this backend to the ml_dsa gem backend instead.

Parameters:

  • key (OpenSSL::PKey::PKey)

    A public or private ML-DSA key

Returns:

  • (String)

    Raw FIPS 204 public key bytes



164
165
166
167
# File 'lib/linzer/ml_dsa/openssl_key.rb', line 164

def unwrap_raw_public_key(key)
  spki = OpenSSL::ASN1.decode(key.public_to_der)
  spki.value[1].value
end

Instance Method Details

#backendSymbol

Returns :openssl -- which backend produced this key.

Returns:

  • (Symbol)

    :openssl -- which backend produced this key



122
123
124
# File 'lib/linzer/ml_dsa/openssl_key.rb', line 122

def backend
  :openssl
end

#sign(data) ⇒ String

Signs data using the ML-DSA private key.

Parameters:

  • data (String)

    The data to sign (typically the signature base)

Returns:

  • (String)

    The FIPS 204 signature

Raises:

  • (SigningError)

    If this key does not contain private key material, or the underlying OpenSSL signing operation fails



97
98
99
100
101
102
# File 'lib/linzer/ml_dsa/openssl_key.rb', line 97

def sign(data)
  validate_signing_key
  material.sign(nil, data)
rescue OpenSSL::PKey::PKeyError => e
  raise SigningError, e.message, cause: e
end

#validate_signature_parameters(parameters) ⇒ true

Validates that the HTTP alg parameter matches this key's algorithm.

Parameters:

  • parameters (Hash)

    HTTP signature parameters

Returns:

  • (true)

    If alg is absent or matches this key

Raises:

  • (VerifyError)

    If alg selects a different algorithm



83
84
85
86
87
88
89
# File 'lib/linzer/ml_dsa/openssl_key.rb', line 83

def validate_signature_parameters(parameters)
  supplied_algorithm = parameters["alg"] || parameters[:alg]
  return true if supplied_algorithm.nil? || supplied_algorithm == algorithm

  raise VerifyError,
    "Signature algorithm #{supplied_algorithm} does not match key algorithm #{algorithm}"
end

#verify(signature, data) ⇒ Boolean

Verifies a signature using the ML-DSA public key.

Parameters:

  • signature (String)

    The signature bytes to verify

  • data (String)

    The data that was signed

Returns:

  • (Boolean)

    true if the signature is valid, false otherwise (including malformed or non-String signature/data input, matching GemKey#verify's contract)

Raises:

  • (VerifyError)

    If this key does not contain public key material



112
113
114
115
116
117
118
119
# File 'lib/linzer/ml_dsa/openssl_key.rb', line 112

def verify(signature, data)
  validate_verify_key
  return false unless signature.is_a?(String)

  material.verify(nil, signature, data)
rescue OpenSSL::PKey::PKeyError, ArgumentError, TypeError
  false
end