Class: JWT::PQ::HybridKey

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

Overview

A composite key that pairs an Ed25519 keypair with an ML-DSA keypair for hybrid EdDSA+ML-DSA-* JWT signatures.

Hybrid mode concatenates the two signatures (ed25519 || ml_dsa) so that a verifier only accepts the token if both signatures are valid. The classical half remains secure against today's attackers while the post-quantum half resists a future cryptographically relevant quantum computer.

Requires the ed25519 gem (or jwt-eddsa, which depends on it). Use hybrid_available? to probe availability.

Examples:

Generate a hybrid key and encode a JWT

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

Verification-only hybrid key

verifier = JWT::PQ::HybridKey.new(
  ed25519: ed25519_verify_key,
  ml_dsa:  JWT::PQ::Key.from_public_key(:ml_dsa_65, pub_bytes)
)

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(ed25519:, ml_dsa:) ⇒ HybridKey

Build a hybrid key from existing Ed25519 and ML-DSA components.

Pass an Ed25519::SigningKey for a full signing key, or an Ed25519::VerifyKey for verification-only.

Parameters:

  • ed25519 (Ed25519::SigningKey, Ed25519::VerifyKey)

    Ed25519 key.

  • ml_dsa (JWT::PQ::Key)

    ML-DSA keypair.

Raises:



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'lib/jwt/pq/hybrid_key.rb', line 46

def initialize(ed25519:, ml_dsa:)
  require_eddsa_dependency!

  @ml_dsa_key = ml_dsa
  @op_mutex = Mutex.new

  case ed25519
  when Ed25519::SigningKey
    @ed25519_signing_key = ed25519
    @ed25519_verify_key = ed25519.verify_key
  when Ed25519::VerifyKey
    @ed25519_signing_key = nil
    @ed25519_verify_key = ed25519
  else
    raise KeyError, "Expected Ed25519::SigningKey or Ed25519::VerifyKey, got #{ed25519.class}"
  end
end

Instance Attribute Details

#ed25519_signing_keyEd25519::SigningKey? (readonly)

Returns Ed25519 signing key, or nil for verification-only.

Returns:

  • (Ed25519::SigningKey, nil)

    Ed25519 signing key, or nil for verification-only.



29
30
31
# File 'lib/jwt/pq/hybrid_key.rb', line 29

def ed25519_signing_key
  @ed25519_signing_key
end

#ed25519_verify_keyEd25519::VerifyKey (readonly)

Returns Ed25519 verification key.

Returns:

  • (Ed25519::VerifyKey)

    Ed25519 verification key.



32
33
34
# File 'lib/jwt/pq/hybrid_key.rb', line 32

def ed25519_verify_key
  @ed25519_verify_key
end

#ml_dsa_keyJWT::PQ::Key (readonly)

Returns ML-DSA keypair (public-only or full).

Returns:



35
36
37
# File 'lib/jwt/pq/hybrid_key.rb', line 35

def ml_dsa_key
  @ml_dsa_key
end

Class Method Details

.generate(ml_dsa_algorithm = :ml_dsa_65) ⇒ HybridKey

Generate a fresh hybrid keypair.

Creates both an Ed25519 SigningKey and an ML-DSA keypair of the requested parameter set.

Parameters:

  • ml_dsa_algorithm (Symbol, String) (defaults to: :ml_dsa_65)

    one of :ml_dsa_44, :ml_dsa_65, :ml_dsa_87. Defaults to :ml_dsa_65.

Returns:

  • (HybridKey)

    a full hybrid keypair (signing + verification).

Raises:



73
74
75
76
77
78
79
80
# File 'lib/jwt/pq/hybrid_key.rb', line 73

def self.generate(ml_dsa_algorithm = :ml_dsa_65)
  require_eddsa_dependency!

  ed_key = Ed25519::SigningKey.generate
  ml_key = Key.generate(ml_dsa_algorithm)

  new(ed25519: ed_key, ml_dsa: ml_key)
end

Instance Method Details

#algorithmString

Returns the ML-DSA algorithm name (e.g. "ML-DSA-65").

Returns:

  • (String)

    the ML-DSA algorithm name (e.g. "ML-DSA-65").



89
90
91
# File 'lib/jwt/pq/hybrid_key.rb', line 89

def algorithm
  @ml_dsa_key.algorithm
end

#destroy!true

Zero and discard private key material from both halves.

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

Thread-safe: serialized on the hybrid key's own mutex (which also guards #sign) and internally delegates to Key#destroy!, which uses its own mutex. A concurrent #sign will block until destroy! completes and then raise KeyError, never observing a half-destroyed state.

Ed25519 wipe: Ed25519::SigningKey stores the private seed in two separate Strings — @seed (32 bytes) returned by #to_bytes, and @keypair (64 bytes = seed || public_key) returned by #keypair. Both are attr_reader-backed and hand out the internal String by reference, so we must zero both in place — wiping only @seed leaves the first 32 bytes of @keypair holding the seed until GC.

Returns:

  • (true)


143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/jwt/pq/hybrid_key.rb', line 143

def destroy!
  @op_mutex.synchronize do
    @ml_dsa_key.destroy!
    if @ed25519_signing_key
      seed = @ed25519_signing_key.to_bytes
      seed.replace("\0" * seed.bytesize)
      keypair = @ed25519_signing_key.keypair
      keypair.replace("\0" * keypair.bytesize)
      @ed25519_signing_key = nil
    end
  end
  true
end

#hybrid_algorithmString

Returns the hybrid JWT algorithm name (e.g. "EdDSA+ML-DSA-65").

Returns:

  • (String)

    the hybrid JWT algorithm name (e.g. "EdDSA+ML-DSA-65").



95
96
97
# File 'lib/jwt/pq/hybrid_key.rb', line 95

def hybrid_algorithm
  "EdDSA+#{@ml_dsa_key.algorithm}"
end

#inspectString Also known as: to_s

Returns short diagnostic string — never contains key material.

Returns:

  • (String)

    short diagnostic string — never contains key material.



158
159
160
# File 'lib/jwt/pq/hybrid_key.rb', line 158

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

#private?Boolean

Returns true when both halves have private components and the key can be used for signing.

Returns:

  • (Boolean)

    true when both halves have private components and the key can be used for signing.



84
85
86
# File 'lib/jwt/pq/hybrid_key.rb', line 84

def private?
  !@ed25519_signing_key.nil? && @ml_dsa_key.private?
end

#sign(data) ⇒ String

Produce a hybrid signature (Ed25519 ‖ ML-DSA) over data.

Thread-safe: both component signatures are taken under the hybrid key's own mutex, and #destroy! contends on the same mutex. That guarantees a concurrent destroy! cannot zero the Ed25519 seed while libsodium is mid-sign, and cannot produce a half-signed output (Ed25519 succeeds, ML-DSA fails because the buffer was just zeroed). Lock order is hybrid mutex → ML-DSA mutex; callers must not invoke any Key method while holding another lock that might be taken by destroy!.

Parameters:

  • data (String)

    message bytes to sign.

Returns:

  • (String)

    concatenated signature — 64 bytes of Ed25519 followed by the ML-DSA signature.

Raises:

  • (KeyError)

    if either half is missing its private component.



114
115
116
117
118
119
120
121
122
# File 'lib/jwt/pq/hybrid_key.rb', line 114

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

    ed_sig = @ed25519_signing_key.sign(data)
    ml_sig = @ml_dsa_key.sign(data)
    ed_sig + ml_sig
  end
end