Class: JWT::PQ::Key

Inherits:
Object
  • Object
show all
Defined in:
lib/jwt/pq/key.rb

Overview

An ML-DSA keypair (public key + optional private key) used for JWT signing and verification.

Prefer the class-level constructors over new:

Examples:

Generate and sign

key = JWT::PQ::Key.generate(:ml_dsa_65)
token = JWT.encode({ sub: "u-1" }, key, "ML-DSA-65")

Verification-only key

verifier = JWT::PQ::Key.from_public_key(:ml_dsa_65, pub_bytes)
JWT.decode(token, verifier, true, algorithms: ["ML-DSA-65"])

Constant Summary collapse

ALGORITHM_ALIASES =

Symbol → canonical algorithm name.

{
  ml_dsa_44: "ML-DSA-44",
  ml_dsa_65: "ML-DSA-65",
  ml_dsa_87: "ML-DSA-87"
}.freeze
ALGORITHM_OIDS =

Algorithm name → ASN.1 OID.

{
  "ML-DSA-44" => PqcAsn1::OID::ML_DSA_44,
  "ML-DSA-65" => PqcAsn1::OID::ML_DSA_65,
  "ML-DSA-87" => PqcAsn1::OID::ML_DSA_87
}.freeze
OID_TO_ALGORITHM =

ASN.1 OID → algorithm name.

ALGORITHM_OIDS.invert.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(algorithm:, public_key:, private_key: nil) ⇒ Key

Low-level constructor. Prefer generate, from_pem, from_pem_pair, or from_public_key in application code.

Parameters:

  • algorithm (Symbol, String)

    one of :ml_dsa_44, :ml_dsa_65, :ml_dsa_87 (or the canonical string form).

  • public_key (String)

    raw public key bytes of the correct size for the algorithm.

  • private_key (String, nil) (defaults to: nil)

    raw private key bytes, or nil for a verification-only key.

Raises:



63
64
65
66
67
68
69
70
71
72
# File 'lib/jwt/pq/key.rb', line 63

def initialize(algorithm:, public_key:, private_key: nil)
  @algorithm = resolve_algorithm(algorithm)
  @ml_dsa = MlDsa.new(@algorithm)
  @public_key = public_key
  @private_key = private_key
  @op_mutex = Mutex.new

  validate!
  init_ffi_buffers!
end

Instance Attribute Details

#algorithmString (readonly)

Returns canonical algorithm name ("ML-DSA-44", "ML-DSA-65", or "ML-DSA-87").

Returns:

  • (String)

    canonical algorithm name ("ML-DSA-44", "ML-DSA-65", or "ML-DSA-87").



43
44
45
# File 'lib/jwt/pq/key.rb', line 43

def algorithm
  @algorithm
end

#private_keyString? (readonly)

Returns raw private (secret) key bytes, or nil for verification-only keys.

Returns:

  • (String, nil)

    raw private (secret) key bytes, or nil for verification-only keys.



50
51
52
# File 'lib/jwt/pq/key.rb', line 50

def private_key
  @private_key
end

#public_keyString (readonly)

Returns raw public key bytes.

Returns:

  • (String)

    raw public key bytes.



46
47
48
# File 'lib/jwt/pq/key.rb', line 46

def public_key
  @public_key
end

Class Method Details

.from_pem(pem_string) ⇒ Key

Import a Key from a PEM string.

Accepts both SPKI (public-only) and PKCS#8 (private + embedded public) PEM documents. For a PKCS#8 PEM that does not carry the public key, use from_pem_pair with a separate public PEM instead.

Parameters:

  • pem_string (String)

    a PEM-encoded key document.

Returns:

  • (Key)

    a public-only or full keypair, depending on the PEM format.

Raises:

  • (KeyError)

    for unknown OIDs or PKCS#8 PEMs missing the public key.



191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/jwt/pq/key.rb', line 191

def self.from_pem(pem_string)
  info = PqcAsn1::DER.parse_pem(pem_string)
  alg_name = resolve_oid!(info.oid)

  case info.format
  when :spki  then new(algorithm: alg_name, public_key: info.key)
  when :pkcs8 then build_from_pkcs8(info, alg_name)
  # :nocov: — defensive guard; PqcAsn1::DER.parse_pem only returns :spki or :pkcs8
  else raise KeyError, "Unsupported PEM format: #{info.format}"
    # :nocov:
  end
ensure
  info&.key&.wipe! if info&.format == :pkcs8
end

.from_pem_pair(public_pem:, private_pem:) ⇒ Key

Import a Key from separate public and private PEM strings.

Use this when your private PEM is PKCS#8 without an embedded public key, or when public and private material come from different sources.

Parameters:

  • public_pem (String)

    SPKI-encoded public key PEM.

  • private_pem (String)

    PKCS#8-encoded private key PEM.

Returns:

  • (Key)

    a full keypair.

Raises:

  • (KeyError)

    if the OIDs are unknown or the public and private PEMs specify different algorithms.



216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/jwt/pq/key.rb', line 216

def self.from_pem_pair(public_pem:, private_pem:)
  pub_info = PqcAsn1::DER.parse_pem(public_pem)
  priv_info = PqcAsn1::DER.parse_pem(private_pem)

  pub_alg = OID_TO_ALGORITHM[pub_info.oid]
  priv_alg = OID_TO_ALGORITHM[priv_info.oid]

  raise KeyError, "Unknown OID in public PEM: #{pub_info.oid.dotted}" unless pub_alg
  raise KeyError, "Unknown OID in private PEM: #{priv_info.oid.dotted}" unless priv_alg
  raise KeyError, "Algorithm mismatch: public=#{pub_alg}, private=#{priv_alg}" unless pub_alg == priv_alg

  sk_bytes = extract_secure_bytes(priv_info.key)
  new(algorithm: pub_alg, public_key: pub_info.key, private_key: sk_bytes)
ensure
  priv_info&.key&.wipe!
end

.from_public_key(algorithm, public_key_bytes) ⇒ Key

Wrap raw public key bytes for verification-only use.

Parameters:

  • algorithm (Symbol, String)

    the algorithm the public key belongs to.

  • public_key_bytes (String)

    raw public key bytes.

Returns:

  • (Key)

    a verification-only key (#private? returns false).



93
94
95
# File 'lib/jwt/pq/key.rb', line 93

def self.from_public_key(algorithm, public_key_bytes)
  new(algorithm: algorithm, public_key: public_key_bytes)
end

.generate(algorithm) ⇒ Key

Generate a new keypair for the given algorithm.

Parameters:

  • algorithm (Symbol, String)

    one of :ml_dsa_44, :ml_dsa_65, :ml_dsa_87 (or the canonical string form).

Returns:

  • (Key)

    a new keypair with both public and private components.

Raises:



80
81
82
83
84
85
86
# File 'lib/jwt/pq/key.rb', line 80

def self.generate(algorithm)
  alg_name = resolve_algorithm(algorithm)
  ml_dsa = MlDsa.new(alg_name)
  pk, sk = ml_dsa.keypair

  new(algorithm: alg_name, public_key: pk, private_key: sk)
end

Instance Method Details

#destroy!true

Zero and discard private key material from Ruby memory.

After calling this, #private? becomes false and the key can only be used for verification. Idempotent — safe to call multiple times, and on verification-only keys.

Thread-safe: serialized on a per-instance mutex shared with #sign and #verify, so destroy! waits for any in-flight signing or verification to complete before zeroing the buffer.

Returns:

  • (true)


146
147
148
149
150
151
152
153
154
155
156
# File 'lib/jwt/pq/key.rb', line 146

def destroy!
  @op_mutex.synchronize do
    if @private_key
      @private_key.replace("\0" * @private_key.bytesize)
      @private_key = nil
    end
    @sk_buffer&.clear
    @sk_buffer = nil
  end
  true
end

#inspectString Also known as: to_s

Returns short diagnostic string — never contains key material.

Returns:

  • (String)

    short diagnostic string — never contains key material.



159
160
161
# File 'lib/jwt/pq/key.rb', line 159

def inspect
  "#<#{self.class} algorithm=#{@algorithm} private=#{private?}>"
end

#jwk_thumbprintString

RFC 7638 JWK Thumbprint for this key, memoized.

The thumbprint depends only on the canonical JSON of {alg, kty, pub} — all immutable for the lifetime of a Key — so it is computed lazily on first access and cached. Useful for callers (e.g. JWKSet) that index many keys by kid without wanting to allocate a JWK wrapper each time.

Safe to call concurrently on a shared key: the inputs are immutable post-construction, so a concurrent first access at worst recomputes the same deterministic string; the ||= assignment is a single atomic reference write on MRI.

Returns:

  • (String)

    base64url-encoded SHA-256 thumbprint.



178
179
180
# File 'lib/jwt/pq/key.rb', line 178

def jwk_thumbprint
  @jwk_thumbprint ||= JWT::PQ::JWK.compute_thumbprint(@algorithm, @public_key)
end

#private?Boolean

Returns true when this key has a private component and can sign.

Returns:

  • (Boolean)

    true when this key has a private component and can sign.



131
132
133
# File 'lib/jwt/pq/key.rb', line 131

def private?
  !@private_key.nil?
end

#private_to_pemString

Export the private key as a PKCS#8 PEM string.

The PEM carries both the private key and the public key (so the pair can later be re-imported with from_pem alone).

Thread-safe: the read of @private_key and the DER build are serialized against #destroy! on the per-instance mutex, so a concurrent destroy! cannot zero the bytes mid-encode.

Returns:

  • (String)

    a -----BEGIN PRIVATE KEY----- PEM document.

Raises:

  • (KeyError)

    if this key has no private component.



253
254
255
256
257
258
259
260
261
# File 'lib/jwt/pq/key.rb', line 253

def private_to_pem
  @op_mutex.synchronize do
    raise KeyError, "Private key not available" unless @private_key

    oid = ALGORITHM_OIDS[@algorithm]
    secure_der = PqcAsn1::DER.build_pkcs8(oid, @private_key, public_key: @public_key)
    secure_der.to_pem
  end
end

#sign(data) ⇒ String

Sign data using the private key.

Thread-safe: serialized on a per-instance mutex shared with #verify and #destroy!, so a concurrent destroy! cannot race with an in-flight sign. The mutex cost (~hundreds of ns) is negligible relative to ML-DSA signing (~130–200 µs).

Parameters:

  • data (String)

    message bytes to sign.

Returns:

  • (String)

    raw signature bytes.

Raises:

  • (KeyError)

    if this key has no private component.

  • (SignatureError)

    if liboqs reports a signing failure.



108
109
110
111
112
113
114
# File 'lib/jwt/pq/key.rb', line 108

def sign(data)
  @op_mutex.synchronize do
    raise KeyError, "Private key not available — cannot sign" unless @sk_buffer

    @ml_dsa.sign_with_sk_buffer(data, @sk_buffer)
  end
end

#to_pemString

Export the public key as an SPKI PEM string.

Returns:

  • (String)

    a -----BEGIN PUBLIC KEY----- PEM document.



236
237
238
239
240
# File 'lib/jwt/pq/key.rb', line 236

def to_pem
  oid = ALGORITHM_OIDS[@algorithm]
  der = PqcAsn1::DER.build_spki(oid, @public_key)
  PqcAsn1::PEM.encode(der, "PUBLIC KEY")
end

#verify(data, signature) ⇒ Boolean

Verify a signature against data using the public key.

Thread-safe: serialized on a per-instance mutex shared with #sign and #destroy!.

Parameters:

  • data (String)

    message bytes that were signed.

  • signature (String)

    raw signature bytes produced by #sign.

Returns:

  • (Boolean)

    true if the signature is valid, false otherwise.



124
125
126
127
128
# File 'lib/jwt/pq/key.rb', line 124

def verify(data, signature)
  @op_mutex.synchronize do
    @ml_dsa.verify_with_pk_buffer(data, signature, @pk_buffer)
  end
end