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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -0
- data/README.md +75 -5
- data/fixtures/myinfo-personas.json +59 -0
- data/lib/generators/standard_singpass/install/templates/initializer.rb.erb +16 -4
- data/lib/standard_singpass/engine.rb +13 -3
- data/lib/standard_singpass/error.rb +9 -0
- data/lib/standard_singpass/myinfo/client.rb +107 -49
- data/lib/standard_singpass/myinfo/configuration.rb +122 -92
- data/lib/standard_singpass/myinfo/ecdh_jwe.rb +8 -135
- data/lib/standard_singpass/myinfo/error.rb +5 -2
- data/lib/standard_singpass/myinfo/security.rb +15 -21
- data/lib/standard_singpass/myinfo/test_personas.rb +0 -7
- data/lib/standard_singpass/myinfo.rb +10 -4
- data/lib/standard_singpass/testing.rb +174 -0
- data/lib/standard_singpass/version.rb +1 -1
- data/lib/standard_singpass.rb +25 -0
- metadata +21 -1
|
@@ -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
|
|
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
|
|
37
|
-
#
|
|
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
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
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
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
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
|
-
#
|
|
171
|
-
# and encryption_keys
|
|
172
|
-
#
|
|
173
|
-
#
|
|
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
|
|
181
|
-
|
|
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
|
-
|
|
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 !
|
|
200
|
-
|
|
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? && !
|
|
224
|
+
if @encryption_keys.empty? && !quiet?
|
|
214
225
|
if enc_jwks.empty?
|
|
215
|
-
|
|
226
|
+
log(:error, "private_jwks_json contains no key with \"use\":\"enc\"")
|
|
216
227
|
else
|
|
217
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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 <
|
|
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
|
|
121
|
+
raise Myinfo::SignatureError, "JWS validation failed: #{e.message}"
|
|
128
122
|
end
|
|
129
123
|
rescue JWT::DecodeError => e
|
|
130
|
-
raise
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|