Class: StandardSingpass::Myinfo::EcdhJwe

Inherits:
Object
  • Object
show all
Extended by:
T::Sig
Defined in:
lib/standard_singpass/myinfo/ecdh_jwe.rb

Overview

Native ECDH-ES+A256KW JWE implementation for decryption.

The ‘jwt` gem does not support ECDH-ES key agreement algorithms; this service implements the subset needed for MyInfo (FAPI 2.0):

alg: ECDH-ES+A256KW
enc: A128CBC-HS256, A256CBC-HS512, A128GCM, A256GCM

References:

- RFC 7516 (JWE)
- RFC 7518 Section 4.6 (ECDH-ES key agreement)
- NIST SP 800-56A Concat KDF

Defined Under Namespace

Classes: DecryptionFailed, InvalidAlgorithm

Constant Summary collapse

SUPPORTED_ALGS =
T.let(%w[ECDH-ES+A128KW ECDH-ES+A256KW].freeze, T::Array[String])
SUPPORTED_ENCS =
T.let(%w[A128CBC-HS256 A256CBC-HS512 A128GCM A256GCM].freeze, T::Array[String])
KEK_SIZES =

Key wrap key sizes (in bytes) for each alg

T.let({
  "ECDH-ES+A128KW" => 16,
  "ECDH-ES+A256KW" => 32
}.freeze, T::Hash[String, Integer])
CEK_SIZES =

CEK sizes (in bytes) for each enc

T.let({
  "A128CBC-HS256" => 32,
  "A256CBC-HS512" => 64,
  "A128GCM" => 16,
  "A256GCM" => 32
}.freeze, T::Hash[String, Integer])

Class Method Summary collapse

Class Method Details

.decrypt(jwe_string, private_key:) ⇒ Object



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/standard_singpass/myinfo/ecdh_jwe.rb', line 95

def self.decrypt(jwe_string, private_key:)
  parts = jwe_string.split(".")
  raise DecryptionFailed, "Invalid JWE format" unless parts.length == 5

  header_b64, encrypted_key_b64, iv_b64, ciphertext_b64, tag_b64 = parts

  header = JSON.parse(Base64.urlsafe_decode64(T.must(header_b64)))
  alg = header["alg"]
  enc = header["enc"]

  validate_algorithms!(alg, enc)

  epk = header["epk"]
  raise DecryptionFailed, "Missing ephemeral public key (epk)" unless epk

  # Decode apu/apv from header if present
  apu = header["apu"] ? Base64.urlsafe_decode64(header["apu"]) : nil
  apv = header["apv"] ? Base64.urlsafe_decode64(header["apv"]) : nil

  # Reconstruct ephemeral public key
  ephemeral_public_key = jwk_to_ec_public_key(epk)

  # ECDH key agreement
  shared_secret = derive_shared_secret(private_key, ephemeral_public_key)

  # Derive KEK via Concat KDF
  kek_size = KEK_SIZES.fetch(alg)
  kek = concat_kdf(shared_secret, alg, kek_size, apu:, apv:)

  # Unwrap CEK
  encrypted_key = Base64.urlsafe_decode64(T.must(encrypted_key_b64))
  cek = AESKeyWrap.unwrap(encrypted_key, kek)
  raise DecryptionFailed, "Key unwrap failed" unless cek

  # Decrypt content
  iv = Base64.urlsafe_decode64(T.must(iv_b64))
  ciphertext = Base64.urlsafe_decode64(T.must(ciphertext_b64))
  auth_tag = Base64.urlsafe_decode64(T.must(tag_b64))

  decrypt_content(cek, enc, ciphertext, iv, auth_tag, T.must(header_b64))
rescue JSON::ParserError, ArgumentError => e
  raise DecryptionFailed, "Malformed JWE: #{e.message}"
rescue OpenSSL::OpenSSLError => e
  raise DecryptionFailed, "Decryption failed: #{e.message}"
end

.encrypt(payload, public_key:, alg:, enc:, kid: nil, apu: nil, apv: nil) ⇒ Object



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/standard_singpass/myinfo/ecdh_jwe.rb', line 51

def self.encrypt(payload, public_key:, alg:, enc:, kid: nil, apu: nil, apv: nil)
  validate_algorithms!(alg, enc)

  # Generate ephemeral key pair on same curve
  group = public_key.group
  ephemeral_key = OpenSSL::PKey::EC.generate(group.curve_name)

  # ECDH key agreement
  shared_secret = derive_shared_secret(ephemeral_key, public_key)

  # Derive KEK via Concat KDF
  kek_size = KEK_SIZES.fetch(alg)
  kek = concat_kdf(shared_secret, alg, kek_size, apu:, apv:)

  # Generate random CEK
  cek_size = CEK_SIZES.fetch(enc)
  cek = SecureRandom.random_bytes(cek_size)

  # Wrap CEK with KEK
  encrypted_key = AESKeyWrap.wrap(cek, kek)

  # Build header
  epk_jwk = ec_public_key_to_jwk(ephemeral_key)
  header = { "alg" => alg, "enc" => enc, "epk" => epk_jwk }
  header["kid"] = kid if kid
  header["apu"] = Base64.urlsafe_encode64(apu, padding: false) if apu
  header["apv"] = Base64.urlsafe_encode64(apv, padding: false) if apv

  # Encrypt content
  header_b64 = Base64.urlsafe_encode64(header.to_json, padding: false)
  iv, ciphertext, auth_tag = encrypt_content(cek, enc, payload, header_b64)

  # Assemble compact serialization
  [
    header_b64,
    Base64.urlsafe_encode64(encrypted_key, padding: false),
    Base64.urlsafe_encode64(T.must(iv), padding: false),
    Base64.urlsafe_encode64(T.must(ciphertext), padding: false),
    Base64.urlsafe_encode64(T.must(auth_tag), padding: false)
  ].join(".")
end