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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -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 +19 -121
- data/lib/standard_singpass/myinfo/error.rb +5 -2
- data/lib/standard_singpass/myinfo/security.rb +29 -20
- data/lib/standard_singpass/myinfo/test_personas.rb +7 -2
- 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,7 +41,11 @@ module StandardSingpass
|
|
|
36
41
|
"A256GCM" => 32
|
|
37
42
|
}.freeze, T::Hash[String, Integer])
|
|
38
43
|
|
|
39
|
-
#
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 <
|
|
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
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# the
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
136
|
+
raise Myinfo::SignatureError, "JWS validation failed: #{e.message}"
|
|
128
137
|
end
|
|
129
138
|
rescue JWT::DecodeError => e
|
|
130
|
-
raise
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
35
|
-
#
|
|
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
|