standard_singpass 0.3.1 → 0.5.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,60 +41,6 @@ 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.
40
- sig do
41
- params(
42
- payload: String,
43
- public_key: OpenSSL::PKey::EC,
44
- alg: String,
45
- enc: String,
46
- kid: T.nilable(String),
47
- apu: T.nilable(String),
48
- apv: T.nilable(String)
49
- ).returns(String)
50
- end
51
- 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(".")
91
- end
92
-
93
44
  # Decrypts a compact-serialized JWE string.
94
45
  sig { params(jwe_string: String, private_key: OpenSSL::PKey::EC).returns(String) }
95
46
  def self.decrypt(jwe_string, private_key:)
@@ -171,38 +122,6 @@ module StandardSingpass
171
122
  digest[0, key_length]
172
123
  end
173
124
 
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
125
  # Reconstructs an EC public key from a JWK hash.
207
126
  sig { params(jwk: T::Hash[String, T.untyped]).returns(OpenSSL::PKey::EC) }
208
127
  def jwk_to_ec_public_key(jwk)
@@ -231,18 +150,6 @@ module StandardSingpass
231
150
  OpenSSL::PKey::EC.new(asn1.to_der)
232
151
  end
233
152
 
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
153
  sig { params(cek: String, enc: String, ciphertext: String, iv: String, auth_tag: String, aad: String).returns(String) }
247
154
  def decrypt_content(cek, enc, ciphertext, iv, auth_tag, aad)
248
155
  case enc
@@ -270,18 +177,6 @@ module StandardSingpass
270
177
  enc == "A128CBC-HS256" ? "SHA256" : "SHA512"
271
178
  end
272
179
 
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
180
  sig { params(cek: String, ciphertext: String, iv: String, auth_tag: String, aad: String, enc: String).returns(String) }
286
181
  def decrypt_gcm(cek, ciphertext, iv, auth_tag, aad, enc)
287
182
  raise DecryptionFailed, "Invalid authentication tag" if auth_tag.bytesize < 16
@@ -297,28 +192,6 @@ module StandardSingpass
297
192
  raise DecryptionFailed, "Content decryption failed"
298
193
  end
299
194
 
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
195
  sig { params(cek: String, ciphertext: String, iv: String, auth_tag: String, aad: String, enc: String).returns(String) }
323
196
  def decrypt_cbc(cek, ciphertext, iv, auth_tag, aad, enc)
324
197
  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,16 +5,6 @@ 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
17
-
18
8
  JWKS_CACHE_TTL = T.let(1.hour, ActiveSupport::Duration)
19
9
 
20
10
  # Generates a PKCE code verifier and code challenge pair (S256).
@@ -94,21 +84,25 @@ module StandardSingpass
94
84
  sig { params(jwe_string: String, private_keys: T::Array[T::Hash[Symbol, T.untyped]]).returns(String) }
95
85
  def self.decrypt_jwe(jwe_string, private_keys:)
96
86
  header_kid = extract_jwe_kid(jwe_string)
97
- raise DecryptionError, "JWE header missing kid field" unless header_kid
87
+ raise Myinfo::DecryptionError, "JWE header missing kid field" unless header_kid
98
88
 
99
89
  matching_key = private_keys.find { |k| k[:kid] == header_kid }
100
- raise DecryptionError, "No matching decryption key found" unless matching_key
90
+ raise Myinfo::DecryptionError, "No matching decryption key found" unless matching_key
101
91
 
102
92
  alg = extract_jwe_alg(jwe_string)
103
93
  unless EcdhJwe::SUPPORTED_ALGS.include?(alg)
104
- raise DecryptionError, "Unsupported JWE alg #{alg.inspect}; FAPI 2.0 requires #{EcdhJwe::SUPPORTED_ALGS.join('/')}"
94
+ raise Myinfo::DecryptionError, "Unsupported JWE alg #{alg.inspect}; FAPI 2.0 requires #{EcdhJwe::SUPPORTED_ALGS.join('/')}"
105
95
  end
106
96
 
107
97
  EcdhJwe.decrypt(jwe_string, private_key: resolve_key(matching_key[:key]))
108
98
  rescue EcdhJwe::DecryptionFailed => e
109
- raise DecryptionError, "JWE decryption failed: #{e.message}"
99
+ raise Myinfo::DecryptionError, "JWE decryption failed: #{e.message}"
100
+ rescue EcdhJwe::InvalidAlgorithm => e
101
+ # e.g. an unsupported `enc` — previously escaped as a bare
102
+ # StandardError subclass, bypassing every `rescue Myinfo::Error`.
103
+ raise Myinfo::DecryptionError, "Unsupported JWE: #{e.message}"
110
104
  rescue ArgumentError => e
111
- raise DecryptionError, "Malformed JWE: #{e.message}"
105
+ raise Myinfo::DecryptionError, "Malformed JWE: #{e.message}"
112
106
  end
113
107
 
114
108
  # Validates a JWS string against keys from a JWKS endpoint.
@@ -124,10 +118,10 @@ module StandardSingpass
124
118
  begin
125
119
  decode_with_jwks(jws_string, jwks_data)
126
120
  rescue JWT::DecodeError => e
127
- raise ValidationError, "JWS validation failed: #{e.message}"
121
+ raise Myinfo::SignatureError, "JWS validation failed: #{e.message}"
128
122
  end
129
123
  rescue JWT::DecodeError => e
130
- raise ValidationError, "JWS validation failed: #{e.message}"
124
+ raise Myinfo::SignatureError, "JWS validation failed: #{e.message}"
131
125
  end
132
126
  end
133
127
 
@@ -164,7 +158,7 @@ module StandardSingpass
164
158
  case key
165
159
  when OpenSSL::PKey::PKey then key
166
160
  when String then OpenSSL::PKey.read(key)
167
- else raise DecryptionError, "Unsupported key type: #{key.class}"
161
+ else raise Myinfo::DecryptionError, "Unsupported key type: #{key.class}"
168
162
  end
169
163
  end
170
164
  private_class_method :resolve_key
@@ -178,7 +172,7 @@ module StandardSingpass
178
172
  Rails.cache.fetch(cache_key, expires_in: JWKS_CACHE_TTL) do
179
173
  response = Faraday.get(url) { |req| req.options.timeout = 5; req.options.open_timeout = 3 }
180
174
  unless response.success?
181
- raise ValidationError.new("Failed to fetch JWKS: HTTP #{response.status}", status: response.status)
175
+ raise Myinfo::SignatureError.new("Failed to fetch JWKS: HTTP #{response.status}", status: response.status)
182
176
  end
183
177
 
184
178
  JSON.parse(response.body)
@@ -186,10 +180,10 @@ module StandardSingpass
186
180
  rescue Faraday::Error => e
187
181
  # Never reached the JWKS host — the same "their side" case a transport
188
182
  # failure on any other leg represents.
189
- raise ValidationError.new("Failed to fetch JWKS: #{e.message}", transport: true)
183
+ raise Myinfo::SignatureError.new("Failed to fetch JWKS: #{e.message}", transport: true)
190
184
  rescue JSON::ParserError => e
191
185
  # Reached it and got something unusable. Not an availability problem.
192
- raise ValidationError, "Failed to fetch JWKS: #{e.message}"
186
+ raise Myinfo::SignatureError, "Failed to fetch JWKS: #{e.message}"
193
187
  end
194
188
  private_class_method :fetch_jwks
195
189
 
@@ -31,13 +31,6 @@ 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.
36
- def self.reload!
37
- @data = nil
38
- data
39
- end
40
-
41
34
  def self.fixture_path
42
35
  configured = StandardSingpass::Myinfo.configuration.personas_path
43
36
  configured ? Pathname.new(configured) : GEM_FIXTURE_PATH
@@ -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