standard_singpass 0.3.1 → 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.
@@ -10,10 +10,13 @@
10
10
  # c.client_id - App ID from Singpass Developer Portal
11
11
  # c.redirect_url - OAuth callback URL (e.g. https://example.com/singpass/callback)
12
12
  # c.private_jwks_json - Full JWKS JSON string with private signing + encryption keys
13
- # (keys are identified by "use": "sig" and "use": "enc")
13
+ # (keys are identified by "use": "sig" and "use": "enc").
14
+ # Parsed lazily, so it can be assigned in any order.
14
15
  #
15
16
  # Optional attributes:
16
- # c.scope - Space-separated scopes (defaults to DEFAULT_SCOPE)
17
+ # c.scope - Space-separated scopes. Defaults to the minimal
18
+ # DEFAULT_SCOPE ("openid uinfin name"); every
19
+ # real host should set its own approved list.
17
20
  # c.minimum_acr - Minimum Authentication Context Class Reference URN
18
21
  # (e.g. urn:singpass:authentication:loa:3) checked
19
22
  # client-side against the id_token `acr` claim.
@@ -33,79 +36,25 @@
33
36
  module StandardSingpass
34
37
  module Myinfo
35
38
  class Configuration
36
- # Default MyInfo scope — keep aligned with the Singpass developer-portal
37
- # approval list. Entries on separate lines so diffs against the portal's
38
- # ordered dump are reviewable line-by-line. Joined into a single
39
- # space-delimited string before sending to PAR.
40
- DEFAULT_SCOPE = %w[
41
- openid
42
- aliasname
43
- cpfbalances.oa
44
- cpfcontributions
45
- cpfemployers
46
- cpfhousingwithdrawal
47
- dob
48
- email
49
- employment
50
- employmentsector
51
- hanyupinyinaliasname
52
- hanyupinyinname
53
- hdbownership.address
54
- hdbownership.balanceloanrepayment
55
- hdbownership.hdbtype
56
- hdbownership.loangranted
57
- hdbownership.monthlyloaninstalment
58
- hdbownership.noofowners
59
- hdbownership.outstandinginstalment
60
- hdbownership.outstandingloanbalance
61
- hdbtype
62
- housingtype
63
- marital
64
- marriedname
65
- mobileno
66
- name
67
- nationality
68
- noa
69
- noa-basic
70
- noahistory
71
- noahistory-basic
72
- occupation
73
- ownerprivate
74
- passexpirydate
75
- passstatus
76
- passtype
77
- race
78
- regadd
79
- residentialstatus
80
- sex
81
- uinfin
82
- vehicles.effectiveownership
83
- ].join(" ").freeze
84
-
85
- # The categories and their lender-underwriting purpose (PDPA §18,
86
- # Purpose Limitation):
39
+ # Default MyInfo scope: the minimal identity set (`openid` plus the
40
+ # NRIC/FIN and name). Deliberately tiny.
87
41
  #
88
- # Identity — uinfin, name, alias names, dob, sex, race,
89
- # nationality, residentialstatus → KYC, contracts
90
- # Pass (FIN-only) — passtype, passstatus, passexpirydate,
91
- # employmentsector → tenure-vs-pass-expiry, eligibility
92
- # Address — regadd, hdbtype, housingtype → KYC + income proxy
93
- # Contact — mobileno, email → OTP, mailers
94
- # Family — marital → soft underwriting signal
95
- # Income — noa, noa-basic, noahistory, noahistory-basic,
96
- # cpfcontributions → MAS TDSR input
97
- # Employment — employment, occupation, cpfemployers
98
- # → continuity + employer stability
99
- # Assets — cpfbalances.oa (only OA — MA/SA/RA are ring-fenced
100
- # and not lender-relevant), ownerprivate
101
- # Liabilities — cpfhousingwithdrawal, hdbownership.* (8 sub-fields)
102
- # → TDSR housing component
103
- # Vehicle — vehicles.effectiveownership (asset/liability hint;
104
- # full vehicle details deliberately not requested)
42
+ # Which person attributes to request is a business decision (PDPA §18,
43
+ # Purpose Limitation) that must match the host's Singpass
44
+ # developer-portal approval list, so it belongs to the host, not this
45
+ # gem. Until 0.4.0 this constant was one consuming app's 42-entry
46
+ # underwriting scope — including `noa`, `noa-basic` and
47
+ # `noahistory-basic`, which Singpass review later flagged as redundant
48
+ # collection — and any host that relied on the default silently
49
+ # requested all of it.
105
50
  #
106
- # `cpfbalances.oa`, `hdbownership.*`, and `vehicles.effectiveownership`
107
- # use FAPI 2.0 sub-attribute scope notation — sharper data minimisation
108
- # than parent-keyword grants.
51
+ # A minimal default rather than a boot-time "scope is required" error:
52
+ # `openid uinfin name` is a subset of every MyInfo approval, so a host
53
+ # that forgets to set `scope` gets a working flow that collects the
54
+ # least data possible, instead of either over-collecting (the old
55
+ # default) or failing to boot in development and test. Hosts set
56
+ # `c.scope` explicitly; the install generator does.
57
+ DEFAULT_SCOPE = "openid uinfin name"
109
58
 
110
59
  PRODUCTION_ENDPOINTS = {
111
60
  authorize_url: "https://id.singpass.gov.sg/fapi/auth",
@@ -130,7 +79,6 @@ module StandardSingpass
130
79
  attr_accessor :authorize_url, :par_url, :token_url, :userinfo_url,
131
80
  :jwks_url, :userinfo_jwks_url, :issuer,
132
81
  :client_id, :redirect_url, :scope,
133
- :signing_key, :signing_kid, :encryption_keys,
134
82
  :minimum_acr, :network_wrapper, :mock_mode, :personas_path
135
83
 
136
84
  # Optional callable deciding whether the current deploy is real
@@ -148,6 +96,10 @@ module StandardSingpass
148
96
  self.environment = :staging
149
97
  @scope = DEFAULT_SCOPE
150
98
  @encryption_keys = []
99
+ @signing_key = nil
100
+ @signing_kid = nil
101
+ @private_jwks_json = nil
102
+ @private_jwks_pending = false
151
103
  @network_wrapper = ->(&block) { block.call }
152
104
  @mock_mode = false
153
105
  @production_env_detector = nil
@@ -167,18 +119,77 @@ module StandardSingpass
167
119
 
168
120
  attr_reader :environment
169
121
 
170
- # Accepts the raw JWKS JSON string and populates signing_key, signing_kid,
171
- # and encryption_keys. Logs and reports issues via Rails.logger / Rails.error
172
- # rather than raising — a malformed JWKS silently degrades the Singpass
173
- # widget at runtime, but the host should boot regardless.
122
+ # Stores the raw private JWKS JSON string. Parsing into signing_key,
123
+ # signing_kid, and encryption_keys is deferred until one of those is
124
+ # first read (or `resolve_private_jwks!` is called — the engine does so
125
+ # from `after_initialize`, alongside MockModeGuard, so problems surface
126
+ # at boot). Until 0.4.0 this setter parsed eagerly, so its warnings
127
+ # depended on `mock_mode` having been assigned *before* it ("set
128
+ # last"); deferring makes the configure block order-independent.
129
+ #
130
+ # Issues are logged and reported via Rails.logger / Rails.error rather
131
+ # than raised — a malformed JWKS silently degrades the Singpass widget
132
+ # at runtime, but the host should boot regardless.
174
133
  def private_jwks_json=(jwks_json)
134
+ @private_jwks_json = jwks_json
135
+ @private_jwks_pending = true
136
+ end
137
+
138
+ attr_reader :private_jwks_json
139
+
140
+ def signing_key
141
+ resolve_private_jwks!
142
+ @signing_key
143
+ end
144
+
145
+ def signing_kid
146
+ resolve_private_jwks!
147
+ @signing_kid
148
+ end
149
+
150
+ def encryption_keys
151
+ resolve_private_jwks!
152
+ @encryption_keys
153
+ end
154
+
155
+ # Direct assignment still works (and wins over an earlier
156
+ # `private_jwks_json=`): any pending JWKS is resolved first so it can't
157
+ # later overwrite the explicit value.
158
+ def signing_key=(value)
159
+ resolve_private_jwks!
160
+ @signing_key = value
161
+ end
162
+
163
+ def signing_kid=(value)
164
+ resolve_private_jwks!
165
+ @signing_kid = value
166
+ end
167
+
168
+ def encryption_keys=(value)
169
+ resolve_private_jwks!
170
+ @encryption_keys = value
171
+ end
172
+
173
+ # Parses the pending private JWKS, if any. Idempotent: a no-op until
174
+ # `private_jwks_json=` is assigned again. Returns self.
175
+ def resolve_private_jwks!
176
+ return self unless @private_jwks_pending
177
+
178
+ @private_jwks_pending = false
179
+ parse_private_jwks(@private_jwks_json)
180
+ self
181
+ end
182
+
183
+ private
184
+
185
+ def parse_private_jwks(jwks_json)
175
186
  @encryption_keys = []
176
187
  @signing_key = nil
177
188
  @signing_kid = nil
178
189
 
179
190
  if jwks_json.nil? || jwks_json.to_s.strip.empty?
180
- return if mock_mode || (defined?(Rails) && Rails.env.test?)
181
- Rails.logger.warn("StandardSingpass::Myinfo: private_jwks_json is not set — Singpass flow will fail at first request")
191
+ return if quiet?
192
+ log(:warn, "private_jwks_json is not set — Singpass flow will fail at first request")
182
193
  return
183
194
  end
184
195
 
@@ -187,7 +198,7 @@ module StandardSingpass
187
198
  keys = jwks["keys"] || []
188
199
 
189
200
  sig_jwks = keys.select { |k| k.is_a?(Hash) && k["use"] == "sig" }
190
- Rails.logger.warn("StandardSingpass::Myinfo: multiple sig keys in private_jwks_json — using first") if sig_jwks.size > 1
201
+ log(:warn, "multiple sig keys in private_jwks_json — using first") if sig_jwks.size > 1
191
202
  sig_jwk = sig_jwks.first
192
203
  if sig_jwk
193
204
  @signing_kid = sig_jwk["kid"]
@@ -196,8 +207,8 @@ module StandardSingpass
196
207
  # confusing in console triage (which is the scenario this method is
197
208
  # trying to help with).
198
209
  @signing_kid = nil unless @signing_key
199
- elsif !mock_mode && !(defined?(Rails) && Rails.env.test?)
200
- Rails.logger.error("StandardSingpass::Myinfo: private_jwks_json contains no key with \"use\":\"sig\"")
210
+ elsif !quiet?
211
+ log(:error, "private_jwks_json contains no key with \"use\":\"sig\"")
201
212
  end
202
213
 
203
214
  enc_jwks = keys.select { |k| k.is_a?(Hash) && k["use"] == "enc" }
@@ -210,11 +221,11 @@ module StandardSingpass
210
221
  # forgot to include enc keys) vs all-rejected (every enc key was
211
222
  # public-only or otherwise unloadable). The latter is the trap the
212
223
  # rest of this method is built to catch.
213
- if @encryption_keys.empty? && !mock_mode && !(defined?(Rails) && Rails.env.test?)
224
+ if @encryption_keys.empty? && !quiet?
214
225
  if enc_jwks.empty?
215
- Rails.logger.error("StandardSingpass::Myinfo: private_jwks_json contains no key with \"use\":\"enc\"")
226
+ log(:error, "private_jwks_json contains no key with \"use\":\"enc\"")
216
227
  else
217
- Rails.logger.error("StandardSingpass::Myinfo: private_jwks_json has \"use\":\"enc\" keys but none are usable (all public-only or invalid)")
228
+ log(:error, "private_jwks_json has \"use\":\"enc\" keys but none are usable (all public-only or invalid)")
218
229
  end
219
230
  end
220
231
  rescue JSON::ParserError, TypeError => e
@@ -224,12 +235,31 @@ module StandardSingpass
224
235
  # Reported because a malformed private JWKS silently degrades the
225
236
  # Singpass widget — the request that finally fails will report, but by
226
237
  # then customers have hit the broken page.
227
- Rails.logger.error("StandardSingpass::Myinfo: failed to parse private_jwks_json: #{e.class}: #{e.message}")
228
- Rails.error.report(e, handled: true, context: { component: "StandardSingpass::Myinfo::Configuration", reason: "parse_private_jwks" }) if defined?(Rails.error)
238
+ log(:error, "failed to parse private_jwks_json: #{e.class}: #{e.message}")
239
+ report(e, reason: "parse_private_jwks")
229
240
  @encryption_keys = []
230
241
  end
231
242
 
232
- private
243
+ # Missing-key warnings are noise in mock mode and in the test env.
244
+ # Read at parse time — i.e. after the whole configure block has run —
245
+ # so the order of assignments inside the block no longer matters.
246
+ def quiet?
247
+ mock_mode || (defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.test?)
248
+ end
249
+
250
+ # Rails.logger / Rails.error are optional: the gem can be loaded (and a
251
+ # Configuration built) outside a booted Rails app — tooling, plain Ruby
252
+ # scripts, or before the logger is assigned.
253
+ def log(level, message)
254
+ return unless defined?(::Rails) && ::Rails.respond_to?(:logger)
255
+ ::Rails.logger&.public_send(level, "StandardSingpass::Myinfo: #{message}")
256
+ end
257
+
258
+ def report(error, reason:, **context)
259
+ return unless defined?(::Rails) && ::Rails.respond_to?(:error)
260
+ ::Rails.error.report(error, handled: true, context: { component: "StandardSingpass::Myinfo::Configuration", reason:, **context })
261
+ end
262
+
233
263
 
234
264
  # Converts a JWK to a *private* PEM. Refuses public-only JWKs — the
235
265
  # private scalar (`d` for EC) must be present, otherwise signing /
@@ -239,13 +269,13 @@ module StandardSingpass
239
269
  def jwk_to_private_pem(jwk_hash, role:)
240
270
  kid = jwk_hash["kid"]
241
271
  if jwk_hash["d"].blank?
242
- Rails.logger.error("StandardSingpass::Myinfo: #{role} JWK #{kid.inspect} is public-only (missing \"d\") — re-export with include_private: true")
272
+ log(:error, "#{role} JWK #{kid.inspect} is public-only (missing \"d\") — re-export with include_private: true")
243
273
  return nil
244
274
  end
245
275
  JWT::JWK.new(jwk_hash).keypair.to_pem
246
276
  rescue => e
247
- Rails.logger.error("StandardSingpass::Myinfo: failed to convert #{role} JWK #{kid.inspect}: #{e.class} — #{e.message}")
248
- Rails.error.report(e, handled: true, context: { component: "StandardSingpass::Myinfo::Configuration", reason: "jwk_to_private_pem", role:, kid: }) if defined?(Rails.error)
277
+ log(:error, "failed to convert #{role} JWK #{kid.inspect}: #{e.class} — #{e.message}")
278
+ report(e, reason: "jwk_to_private_pem", role:, kid:)
249
279
  nil
250
280
  end
251
281
  end
@@ -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