standard_singpass 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,9 @@
2
2
 
3
3
  module StandardSingpass
4
4
  module Myinfo
5
- # Native ECDH-ES+A256KW JWE implementation for decryption.
5
+ # Native ECDH-ES+A256KW JWE implementation for decryption. (The matching
6
+ # encryption side, needed only to build test fixtures, lives in
7
+ # `StandardSingpass::Testing::EcdhJwe`.)
6
8
  #
7
9
  # The `jwt` gem does not support ECDH-ES key agreement algorithms; this
8
10
  # service implements the subset needed for MyInfo (FAPI 2.0):
@@ -19,12 +21,15 @@ module StandardSingpass
19
21
  class DecryptionFailed < StandardError; end
20
22
  class InvalidAlgorithm < StandardError; end
21
23
 
22
- SUPPORTED_ALGS = T.let(%w[ECDH-ES+A128KW ECDH-ES+A256KW].freeze, T::Array[String])
24
+ # Only ECDH-ES+A256KW: it is the one alg we publish on our encryption
25
+ # JWKs (see Myinfo.public_jwks) and the one FAPI 2.0 / Singpass uses.
26
+ # ECDH-ES+A128KW was accepted until 0.4.0; nothing legitimate sends it
27
+ # to us, so accepting it was pure attack surface.
28
+ SUPPORTED_ALGS = T.let(%w[ECDH-ES+A256KW].freeze, T::Array[String])
23
29
  SUPPORTED_ENCS = T.let(%w[A128CBC-HS256 A256CBC-HS512 A128GCM A256GCM].freeze, T::Array[String])
24
30
 
25
31
  # Key wrap key sizes (in bytes) for each alg
26
32
  KEK_SIZES = T.let({
27
- "ECDH-ES+A128KW" => 16,
28
33
  "ECDH-ES+A256KW" => 32
29
34
  }.freeze, T::Hash[String, Integer])
30
35
 
@@ -36,7 +41,11 @@ module StandardSingpass
36
41
  "A256GCM" => 32
37
42
  }.freeze, T::Hash[String, Integer])
38
43
 
39
- # Encrypts a payload and returns a compact-serialized JWE string.
44
+ # Deprecated (0.4.0): encryption is only ever needed to build fixtures
45
+ # in tests — Singpass encrypts, we decrypt. It lives in
46
+ # `StandardSingpass::Testing::EcdhJwe` now (`require
47
+ # "standard_singpass/testing"`); this forwarder will be removed in a
48
+ # future minor release.
40
49
  sig do
41
50
  params(
42
51
  payload: String,
@@ -49,45 +58,12 @@ module StandardSingpass
49
58
  ).returns(String)
50
59
  end
51
60
  def self.encrypt(payload, public_key:, alg:, enc:, kid: nil, apu: nil, apv: nil)
52
- validate_algorithms!(alg, enc)
53
-
54
- # Generate ephemeral key pair on same curve
55
- group = public_key.group
56
- ephemeral_key = OpenSSL::PKey::EC.generate(group.curve_name)
57
-
58
- # ECDH key agreement
59
- shared_secret = derive_shared_secret(ephemeral_key, public_key)
60
-
61
- # Derive KEK via Concat KDF
62
- kek_size = KEK_SIZES.fetch(alg)
63
- kek = concat_kdf(shared_secret, alg, kek_size, apu:, apv:)
64
-
65
- # Generate random CEK
66
- cek_size = CEK_SIZES.fetch(enc)
67
- cek = SecureRandom.random_bytes(cek_size)
68
-
69
- # Wrap CEK with KEK
70
- encrypted_key = AESKeyWrap.wrap(cek, kek)
71
-
72
- # Build header
73
- epk_jwk = ec_public_key_to_jwk(ephemeral_key)
74
- header = { "alg" => alg, "enc" => enc, "epk" => epk_jwk }
75
- header["kid"] = kid if kid
76
- header["apu"] = Base64.urlsafe_encode64(apu, padding: false) if apu
77
- header["apv"] = Base64.urlsafe_encode64(apv, padding: false) if apv
78
-
79
- # Encrypt content
80
- header_b64 = Base64.urlsafe_encode64(header.to_json, padding: false)
81
- iv, ciphertext, auth_tag = encrypt_content(cek, enc, payload, header_b64)
82
-
83
- # Assemble compact serialization
84
- [
85
- header_b64,
86
- Base64.urlsafe_encode64(encrypted_key, padding: false),
87
- Base64.urlsafe_encode64(T.must(iv), padding: false),
88
- Base64.urlsafe_encode64(T.must(ciphertext), padding: false),
89
- Base64.urlsafe_encode64(T.must(auth_tag), padding: false)
90
- ].join(".")
61
+ StandardSingpass.deprecator.warn(
62
+ "StandardSingpass::Myinfo::EcdhJwe.encrypt is deprecated and will be removed; " \
63
+ "require \"standard_singpass/testing\" and use StandardSingpass::Testing::EcdhJwe.encrypt instead."
64
+ )
65
+ require "standard_singpass/testing"
66
+ StandardSingpass::Testing::EcdhJwe.encrypt(payload, public_key:, alg:, enc:, kid:, apu:, apv:)
91
67
  end
92
68
 
93
69
  # Decrypts a compact-serialized JWE string.
@@ -171,38 +147,6 @@ module StandardSingpass
171
147
  digest[0, key_length]
172
148
  end
173
149
 
174
- # Converts an OpenSSL EC key to a JWK hash (public components only).
175
- sig { params(ec_key: OpenSSL::PKey::EC).returns(T::Hash[String, String]) }
176
- def ec_public_key_to_jwk(ec_key)
177
- # Get the public key point
178
- point = ec_key.public_key
179
- group = ec_key.group
180
-
181
- # Determine curve name for JWK
182
- crv = case group.curve_name
183
- when "prime256v1" then "P-256"
184
- when "secp384r1" then "P-384"
185
- when "secp521r1" then "P-521"
186
- else raise InvalidAlgorithm, "Unsupported curve: #{group.curve_name}"
187
- end
188
-
189
- # Get uncompressed point bytes (0x04 || x || y)
190
- bn = point.to_bn(:uncompressed)
191
- uncompressed = bn.to_s(2)
192
-
193
- # Skip the 0x04 prefix byte
194
- coord_length = (uncompressed.bytesize - 1) / 2
195
- x = uncompressed[1, coord_length]
196
- y = uncompressed[1 + coord_length, coord_length]
197
-
198
- {
199
- "kty" => "EC",
200
- "crv" => crv,
201
- "x" => Base64.urlsafe_encode64(x, padding: false),
202
- "y" => Base64.urlsafe_encode64(y, padding: false)
203
- }
204
- end
205
-
206
150
  # Reconstructs an EC public key from a JWK hash.
207
151
  sig { params(jwk: T::Hash[String, T.untyped]).returns(OpenSSL::PKey::EC) }
208
152
  def jwk_to_ec_public_key(jwk)
@@ -231,18 +175,6 @@ module StandardSingpass
231
175
  OpenSSL::PKey::EC.new(asn1.to_der)
232
176
  end
233
177
 
234
- sig { params(cek: String, enc: String, plaintext: String, aad: String).returns(T::Array[String]) }
235
- def encrypt_content(cek, enc, plaintext, aad)
236
- case enc
237
- when "A128GCM", "A256GCM"
238
- encrypt_gcm(cek, plaintext, aad, enc)
239
- when "A128CBC-HS256", "A256CBC-HS512"
240
- encrypt_cbc(cek, plaintext, aad, enc)
241
- else
242
- raise InvalidAlgorithm, "Unsupported enc: #{enc}"
243
- end
244
- end
245
-
246
178
  sig { params(cek: String, enc: String, ciphertext: String, iv: String, auth_tag: String, aad: String).returns(String) }
247
179
  def decrypt_content(cek, enc, ciphertext, iv, auth_tag, aad)
248
180
  case enc
@@ -270,18 +202,6 @@ module StandardSingpass
270
202
  enc == "A128CBC-HS256" ? "SHA256" : "SHA512"
271
203
  end
272
204
 
273
- sig { params(cek: String, plaintext: String, aad: String, enc: String).returns(T::Array[String]) }
274
- def encrypt_gcm(cek, plaintext, aad, enc)
275
- cipher = OpenSSL::Cipher.new(gcm_cipher_name(enc))
276
- cipher.encrypt
277
- cipher.key = cek
278
- iv = cipher.random_iv
279
- cipher.auth_data = aad
280
- ciphertext = cipher.update(plaintext) + cipher.final
281
- auth_tag = cipher.auth_tag
282
- [iv, ciphertext, auth_tag]
283
- end
284
-
285
205
  sig { params(cek: String, ciphertext: String, iv: String, auth_tag: String, aad: String, enc: String).returns(String) }
286
206
  def decrypt_gcm(cek, ciphertext, iv, auth_tag, aad, enc)
287
207
  raise DecryptionFailed, "Invalid authentication tag" if auth_tag.bytesize < 16
@@ -297,28 +217,6 @@ module StandardSingpass
297
217
  raise DecryptionFailed, "Content decryption failed"
298
218
  end
299
219
 
300
- sig { params(cek: String, plaintext: String, aad: String, enc: String).returns(T::Array[String]) }
301
- def encrypt_cbc(cek, plaintext, aad, enc)
302
- mac_key_len = cek.bytesize / 2
303
- mac_key = cek[0, mac_key_len]
304
- enc_key = cek[mac_key_len, mac_key_len]
305
-
306
- cipher = OpenSSL::Cipher.new(cbc_cipher_name(enc))
307
- cipher.encrypt
308
- cipher.key = T.must(enc_key)
309
- iv = cipher.random_iv
310
- ciphertext = cipher.update(plaintext) + cipher.final
311
-
312
- # Compute authentication tag (HMAC over AAD || IV || ciphertext || AL)
313
- al = [aad.bytesize * 8].pack("Q>")
314
- hmac_input = aad + iv + ciphertext + al
315
- hmac = OpenSSL::HMAC.digest(cbc_hmac_digest(enc), T.must(mac_key), hmac_input)
316
- tag_len = mac_key_len # half of HMAC output
317
- auth_tag = hmac[0, tag_len]
318
-
319
- [iv, ciphertext, auth_tag]
320
- end
321
-
322
220
  sig { params(cek: String, ciphertext: String, iv: String, auth_tag: String, aad: String, enc: String).returns(String) }
323
221
  def decrypt_cbc(cek, ciphertext, iv, auth_tag, aad, enc)
324
222
  mac_key_len = cek.bytesize / 2
@@ -1,8 +1,11 @@
1
1
  # typed: strict
2
2
 
3
+ require "standard_singpass/error"
4
+
3
5
  module StandardSingpass
4
6
  module Myinfo
5
- # Base class for every error the gem raises.
7
+ # Base class for every MyInfo error the gem raises. Descends from the
8
+ # gem-wide `StandardSingpass::Error`.
6
9
  #
7
10
  # Every error carries the two facts a host needs to decide what to tell
8
11
  # the user, so neither has to be recovered by reading the message:
@@ -26,7 +29,7 @@ module StandardSingpass
26
29
  # The alternative — matching "unreachable" or an HTTP code in `message` —
27
30
  # is a trap: the message is not a stable interface. `FailureClassifier`
28
31
  # consumes these so hosts never have to.
29
- class Error < StandardError
32
+ class Error < StandardSingpass::Error
30
33
  extend T::Sig
31
34
 
32
35
  sig { returns(T.nilable(Integer)) }
@@ -5,15 +5,20 @@ module StandardSingpass
5
5
  class Security
6
6
  extend T::Sig
7
7
 
8
- # Both inherit the gem's base Error so they carry `status` and
9
- # `transport?`. That matters for ValidationError in particular: fetching
10
- # the JWKS is a network call to a Singpass host, so "the JWKS endpoint
11
- # is down" and "this signature is genuinely bad" both surface here and
12
- # must stay distinguishable — the first is an outage, the second is a
13
- # key/cert problem. FailureClassifier reads those attributes; without
14
- # them a JWKS-host outage would be reported as our own bug.
15
- class DecryptionError < Error; end
16
- class ValidationError < Error; end
8
+ # Deprecated aliases (0.4.0). Security used to raise its own
9
+ # DecryptionError / ValidationError, which the client then translated
10
+ # into the public Myinfo errors. It now raises the public errors
11
+ # directly: JWE failures raise `Myinfo::DecryptionError`, JWS / JWKS
12
+ # failures raise `Myinfo::SignatureError` (carrying the JWKS response's
13
+ # `status`, or `transport?` when the JWKS host was unreachable, so an
14
+ # outage stays distinguishable from a genuinely bad signature).
15
+ #
16
+ # The old names still resolve — to the same classes, so an existing
17
+ # `rescue Security::ValidationError` keeps catching — and will be
18
+ # removed in a future minor release.
19
+ DecryptionError = Myinfo::DecryptionError
20
+ ValidationError = Myinfo::SignatureError
21
+ deprecate_constant :DecryptionError, :ValidationError
17
22
 
18
23
  JWKS_CACHE_TTL = T.let(1.hour, ActiveSupport::Duration)
19
24
 
@@ -94,21 +99,25 @@ module StandardSingpass
94
99
  sig { params(jwe_string: String, private_keys: T::Array[T::Hash[Symbol, T.untyped]]).returns(String) }
95
100
  def self.decrypt_jwe(jwe_string, private_keys:)
96
101
  header_kid = extract_jwe_kid(jwe_string)
97
- raise DecryptionError, "JWE header missing kid field" unless header_kid
102
+ raise Myinfo::DecryptionError, "JWE header missing kid field" unless header_kid
98
103
 
99
104
  matching_key = private_keys.find { |k| k[:kid] == header_kid }
100
- raise DecryptionError, "No matching decryption key found" unless matching_key
105
+ raise Myinfo::DecryptionError, "No matching decryption key found" unless matching_key
101
106
 
102
107
  alg = extract_jwe_alg(jwe_string)
103
108
  unless EcdhJwe::SUPPORTED_ALGS.include?(alg)
104
- raise DecryptionError, "Unsupported JWE alg #{alg.inspect}; FAPI 2.0 requires #{EcdhJwe::SUPPORTED_ALGS.join('/')}"
109
+ raise Myinfo::DecryptionError, "Unsupported JWE alg #{alg.inspect}; FAPI 2.0 requires #{EcdhJwe::SUPPORTED_ALGS.join('/')}"
105
110
  end
106
111
 
107
112
  EcdhJwe.decrypt(jwe_string, private_key: resolve_key(matching_key[:key]))
108
113
  rescue EcdhJwe::DecryptionFailed => e
109
- raise DecryptionError, "JWE decryption failed: #{e.message}"
114
+ raise Myinfo::DecryptionError, "JWE decryption failed: #{e.message}"
115
+ rescue EcdhJwe::InvalidAlgorithm => e
116
+ # e.g. an unsupported `enc` — previously escaped as a bare
117
+ # StandardError subclass, bypassing every `rescue Myinfo::Error`.
118
+ raise Myinfo::DecryptionError, "Unsupported JWE: #{e.message}"
110
119
  rescue ArgumentError => e
111
- raise DecryptionError, "Malformed JWE: #{e.message}"
120
+ raise Myinfo::DecryptionError, "Malformed JWE: #{e.message}"
112
121
  end
113
122
 
114
123
  # Validates a JWS string against keys from a JWKS endpoint.
@@ -124,10 +133,10 @@ module StandardSingpass
124
133
  begin
125
134
  decode_with_jwks(jws_string, jwks_data)
126
135
  rescue JWT::DecodeError => e
127
- raise ValidationError, "JWS validation failed: #{e.message}"
136
+ raise Myinfo::SignatureError, "JWS validation failed: #{e.message}"
128
137
  end
129
138
  rescue JWT::DecodeError => e
130
- raise ValidationError, "JWS validation failed: #{e.message}"
139
+ raise Myinfo::SignatureError, "JWS validation failed: #{e.message}"
131
140
  end
132
141
  end
133
142
 
@@ -164,7 +173,7 @@ module StandardSingpass
164
173
  case key
165
174
  when OpenSSL::PKey::PKey then key
166
175
  when String then OpenSSL::PKey.read(key)
167
- else raise DecryptionError, "Unsupported key type: #{key.class}"
176
+ else raise Myinfo::DecryptionError, "Unsupported key type: #{key.class}"
168
177
  end
169
178
  end
170
179
  private_class_method :resolve_key
@@ -178,7 +187,7 @@ module StandardSingpass
178
187
  Rails.cache.fetch(cache_key, expires_in: JWKS_CACHE_TTL) do
179
188
  response = Faraday.get(url) { |req| req.options.timeout = 5; req.options.open_timeout = 3 }
180
189
  unless response.success?
181
- raise ValidationError.new("Failed to fetch JWKS: HTTP #{response.status}", status: response.status)
190
+ raise Myinfo::SignatureError.new("Failed to fetch JWKS: HTTP #{response.status}", status: response.status)
182
191
  end
183
192
 
184
193
  JSON.parse(response.body)
@@ -186,10 +195,10 @@ module StandardSingpass
186
195
  rescue Faraday::Error => e
187
196
  # Never reached the JWKS host — the same "their side" case a transport
188
197
  # failure on any other leg represents.
189
- raise ValidationError.new("Failed to fetch JWKS: #{e.message}", transport: true)
198
+ raise Myinfo::SignatureError.new("Failed to fetch JWKS: #{e.message}", transport: true)
190
199
  rescue JSON::ParserError => e
191
200
  # Reached it and got something unusable. Not an availability problem.
192
- raise ValidationError, "Failed to fetch JWKS: #{e.message}"
201
+ raise Myinfo::SignatureError, "Failed to fetch JWKS: #{e.message}"
193
202
  end
194
203
  private_class_method :fetch_jwks
195
204
 
@@ -31,9 +31,14 @@ module StandardSingpass
31
31
  @data ||= JSON.parse(fixture_path.read).freeze
32
32
  end
33
33
 
34
- # Test-only — call from a spec `before(:suite)` if you want to pick up
35
- # mid-run edits to the fixture file.
34
+ # Deprecated (0.4.0) — unused by any consumer, and will be removed in a
35
+ # future minor release. Personas are read once per process; restart the
36
+ # process (or point `personas_path` at a different file) to pick up
37
+ # edits.
36
38
  def self.reload!
39
+ StandardSingpass.deprecator.warn(
40
+ "StandardSingpass::Myinfo::TestPersonas.reload! is deprecated and will be removed."
41
+ )
37
42
  @data = nil
38
43
  data
39
44
  end
@@ -9,6 +9,7 @@ require "digest"
9
9
  require "securerandom"
10
10
  require "aes_key_wrap"
11
11
 
12
+ require "standard_singpass/error"
12
13
  require "standard_singpass/myinfo/error"
13
14
  require "standard_singpass/myinfo/failure_classifier"
14
15
  require "standard_singpass/myinfo/configuration"
@@ -32,6 +33,7 @@ module StandardSingpass
32
33
  def configuration
33
34
  @configuration ||= Configuration.new
34
35
  end
36
+ alias_method :config, :configuration
35
37
 
36
38
  def reset_configuration!
37
39
  @configuration = Configuration.new
@@ -57,8 +59,7 @@ module StandardSingpass
57
59
  exported[:alg] = "ES256"
58
60
  keys << exported
59
61
  rescue OpenSSL::PKey::PKeyError => e
60
- Rails.logger.error("StandardSingpass::Myinfo: failed to load signing key: #{e.message}")
61
- Rails.error.report(e, handled: true, context: { component: "StandardSingpass::Myinfo", reason: "build_public_jwks_signing", kid: c.signing_kid })
62
+ log_and_report(e, "failed to load signing key: #{e.message}", reason: "build_public_jwks_signing", kid: c.signing_kid)
62
63
  end
63
64
  end
64
65
 
@@ -70,12 +71,17 @@ module StandardSingpass
70
71
  exported[:alg] = "ECDH-ES+A256KW"
71
72
  keys << exported
72
73
  rescue OpenSSL::PKey::PKeyError => e
73
- Rails.logger.error("StandardSingpass::Myinfo: failed to load encryption key #{enc_key_config[:kid]}: #{e.message}")
74
- Rails.error.report(e, handled: true, context: { component: "StandardSingpass::Myinfo", reason: "build_public_jwks_encryption", kid: enc_key_config[:kid] })
74
+ log_and_report(e, "failed to load encryption key #{enc_key_config[:kid]}: #{e.message}", reason: "build_public_jwks_encryption", kid: enc_key_config[:kid])
75
75
  end
76
76
 
77
77
  { keys: }
78
78
  end
79
+
80
+ def log_and_report(error, message, reason:, kid:)
81
+ return unless defined?(::Rails)
82
+ ::Rails.logger&.error("StandardSingpass::Myinfo: #{message}") if ::Rails.respond_to?(:logger)
83
+ ::Rails.error.report(error, handled: true, context: { component: "StandardSingpass::Myinfo", reason:, kid: }) if ::Rails.respond_to?(:error)
84
+ end
79
85
  end
80
86
  end
81
87
  end
@@ -0,0 +1,174 @@
1
+ # typed: strict
2
+
3
+ require "standard_singpass"
4
+
5
+ module StandardSingpass
6
+ # Test-only helpers. Not loaded by `require "standard_singpass"`; require
7
+ # this file from a spec helper:
8
+ #
9
+ # require "standard_singpass/testing"
10
+ #
11
+ # Nothing here is needed at runtime — Singpass encrypts, the gem decrypts.
12
+ module Testing
13
+ # ECDH-ES JWE *encryption*, the mirror of
14
+ # `StandardSingpass::Myinfo::EcdhJwe.decrypt`. Use it to build the
15
+ # encrypted id_token / userinfo payloads Singpass would return, so specs
16
+ # can exercise the real decrypt + verify path end to end.
17
+ #
18
+ # Accepts exactly the algorithms the decryptor does
19
+ # (`Myinfo::EcdhJwe::SUPPORTED_ALGS` / `SUPPORTED_ENCS`), and shares its
20
+ # key-agreement and KDF code, so a fixture that encrypts here decrypts
21
+ # there by construction.
22
+ class EcdhJwe
23
+ extend T::Sig
24
+
25
+ Impl = StandardSingpass::Myinfo::EcdhJwe
26
+ InvalidAlgorithm = StandardSingpass::Myinfo::EcdhJwe::InvalidAlgorithm
27
+
28
+ # Encrypts a payload and returns a compact-serialized JWE string.
29
+ sig do
30
+ params(
31
+ payload: String,
32
+ public_key: OpenSSL::PKey::EC,
33
+ alg: String,
34
+ enc: String,
35
+ kid: T.nilable(String),
36
+ apu: T.nilable(String),
37
+ apv: T.nilable(String)
38
+ ).returns(String)
39
+ end
40
+ def self.encrypt(payload, public_key:, alg:, enc:, kid: nil, apu: nil, apv: nil)
41
+ shared(:validate_algorithms!, alg, enc)
42
+
43
+ # Generate ephemeral key pair on same curve
44
+ group = public_key.group
45
+ ephemeral_key = OpenSSL::PKey::EC.generate(group.curve_name)
46
+
47
+ # ECDH key agreement
48
+ shared_secret = shared(:derive_shared_secret, ephemeral_key, public_key)
49
+
50
+ # Derive KEK via Concat KDF
51
+ kek_size = Impl::KEK_SIZES.fetch(alg)
52
+ kek = shared(:concat_kdf, shared_secret, alg, kek_size, apu:, apv:)
53
+
54
+ # Generate random CEK
55
+ cek_size = Impl::CEK_SIZES.fetch(enc)
56
+ cek = SecureRandom.random_bytes(cek_size)
57
+
58
+ # Wrap CEK with KEK
59
+ encrypted_key = AESKeyWrap.wrap(cek, kek)
60
+
61
+ # Build header
62
+ epk_jwk = ec_public_key_to_jwk(ephemeral_key)
63
+ header = { "alg" => alg, "enc" => enc, "epk" => epk_jwk }
64
+ header["kid"] = kid if kid
65
+ header["apu"] = Base64.urlsafe_encode64(apu, padding: false) if apu
66
+ header["apv"] = Base64.urlsafe_encode64(apv, padding: false) if apv
67
+
68
+ # Encrypt content
69
+ header_b64 = Base64.urlsafe_encode64(header.to_json, padding: false)
70
+ iv, ciphertext, auth_tag = encrypt_content(cek, enc, payload, header_b64)
71
+
72
+ # Assemble compact serialization
73
+ [
74
+ header_b64,
75
+ Base64.urlsafe_encode64(encrypted_key, padding: false),
76
+ Base64.urlsafe_encode64(T.must(iv), padding: false),
77
+ Base64.urlsafe_encode64(T.must(ciphertext), padding: false),
78
+ Base64.urlsafe_encode64(T.must(auth_tag), padding: false)
79
+ ].join(".")
80
+ end
81
+
82
+ class << self
83
+ extend T::Sig
84
+
85
+ private
86
+
87
+ # The decryptor's key-agreement / KDF / validation helpers are private
88
+ # class methods; reuse them rather than keeping a second copy.
89
+ sig { params(name: Symbol, args: T.untyped, kwargs: T.untyped).returns(T.untyped) }
90
+ def shared(name, *args, **kwargs)
91
+ T.unsafe(Impl).send(name, *args, **kwargs)
92
+ end
93
+
94
+ # Converts an OpenSSL EC key to a JWK hash (public components only).
95
+ sig { params(ec_key: OpenSSL::PKey::EC).returns(T::Hash[String, String]) }
96
+ def ec_public_key_to_jwk(ec_key)
97
+ # Get the public key point
98
+ point = ec_key.public_key
99
+ group = ec_key.group
100
+
101
+ # Determine curve name for JWK
102
+ crv = case group.curve_name
103
+ when "prime256v1" then "P-256"
104
+ when "secp384r1" then "P-384"
105
+ when "secp521r1" then "P-521"
106
+ else raise InvalidAlgorithm, "Unsupported curve: #{group.curve_name}"
107
+ end
108
+
109
+ # Get uncompressed point bytes (0x04 || x || y)
110
+ bn = point.to_bn(:uncompressed)
111
+ uncompressed = bn.to_s(2)
112
+
113
+ # Skip the 0x04 prefix byte
114
+ coord_length = (uncompressed.bytesize - 1) / 2
115
+ x = uncompressed[1, coord_length]
116
+ y = uncompressed[1 + coord_length, coord_length]
117
+
118
+ {
119
+ "kty" => "EC",
120
+ "crv" => crv,
121
+ "x" => Base64.urlsafe_encode64(x, padding: false),
122
+ "y" => Base64.urlsafe_encode64(y, padding: false)
123
+ }
124
+ end
125
+
126
+ sig { params(cek: String, enc: String, plaintext: String, aad: String).returns(T::Array[String]) }
127
+ def encrypt_content(cek, enc, plaintext, aad)
128
+ case enc
129
+ when "A128GCM", "A256GCM"
130
+ encrypt_gcm(cek, plaintext, aad, enc)
131
+ when "A128CBC-HS256", "A256CBC-HS512"
132
+ encrypt_cbc(cek, plaintext, aad, enc)
133
+ else
134
+ raise InvalidAlgorithm, "Unsupported enc: #{enc}"
135
+ end
136
+ end
137
+
138
+ sig { params(cek: String, plaintext: String, aad: String, enc: String).returns(T::Array[String]) }
139
+ def encrypt_gcm(cek, plaintext, aad, enc)
140
+ cipher = OpenSSL::Cipher.new(shared(:gcm_cipher_name, enc))
141
+ cipher.encrypt
142
+ cipher.key = cek
143
+ iv = cipher.random_iv
144
+ cipher.auth_data = aad
145
+ ciphertext = cipher.update(plaintext) + cipher.final
146
+ auth_tag = cipher.auth_tag
147
+ [iv, ciphertext, auth_tag]
148
+ end
149
+
150
+ sig { params(cek: String, plaintext: String, aad: String, enc: String).returns(T::Array[String]) }
151
+ def encrypt_cbc(cek, plaintext, aad, enc)
152
+ mac_key_len = cek.bytesize / 2
153
+ mac_key = cek[0, mac_key_len]
154
+ enc_key = cek[mac_key_len, mac_key_len]
155
+
156
+ cipher = OpenSSL::Cipher.new(shared(:cbc_cipher_name, enc))
157
+ cipher.encrypt
158
+ cipher.key = T.must(enc_key)
159
+ iv = cipher.random_iv
160
+ ciphertext = cipher.update(plaintext) + cipher.final
161
+
162
+ # Compute authentication tag (HMAC over AAD || IV || ciphertext || AL)
163
+ al = [aad.bytesize * 8].pack("Q>")
164
+ hmac_input = aad + iv + ciphertext + al
165
+ hmac = OpenSSL::HMAC.digest(shared(:cbc_hmac_digest, enc), T.must(mac_key), hmac_input)
166
+ tag_len = mac_key_len # half of HMAC output
167
+ auth_tag = hmac[0, tag_len]
168
+
169
+ [iv, ciphertext, auth_tag]
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -1,3 +1,3 @@
1
1
  module StandardSingpass
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
3
  end
@@ -3,4 +3,29 @@ require "standard_singpass/engine"
3
3
  require "standard_singpass/myinfo"
4
4
 
5
5
  module StandardSingpass
6
+ class << self
7
+ # Top-level entry points matching the sibling `standard_*` gems'
8
+ # `Gem.configure` / `Gem.config` convention. Singpass MyInfo is the only
9
+ # product the gem integrates today, so both delegate to
10
+ # `StandardSingpass::Myinfo` — `configure` goes through
11
+ # `Myinfo.configure` so the derived public-JWKS cache is still reset.
12
+ #
13
+ # StandardSingpass.configure { |c| c.client_id = "..." }
14
+ # StandardSingpass.config.client_id
15
+ def configure(&block)
16
+ Myinfo.configure(&block)
17
+ config
18
+ end
19
+
20
+ def config
21
+ Myinfo.configuration
22
+ end
23
+
24
+ # Deprecation warnings for the gem's own deprecated APIs. Registered
25
+ # with the host's `Rails.application.deprecators` by the engine, so the
26
+ # host's deprecation behaviour (log / raise / silence) applies.
27
+ def deprecator
28
+ @deprecator ||= ActiveSupport::Deprecation.new("0.5", "StandardSingpass")
29
+ end
30
+ end
6
31
  end