standard_singpass 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -0
- data/README.md +114 -7
- data/fixtures/myinfo-personas.json +59 -0
- data/lib/generators/standard_singpass/install/templates/initializer.rb.erb +27 -8
- data/lib/standard_singpass/engine.rb +13 -3
- data/lib/standard_singpass/error.rb +9 -0
- data/lib/standard_singpass/myinfo/client.rb +122 -63
- data/lib/standard_singpass/myinfo/configuration.rb +127 -93
- 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
|
@@ -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
|
|
@@ -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
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
|
|
3
|
+
require "standard_singpass"
|
|
4
|
+
|
|
5
|
+
module StandardSingpass
|
|
6
|
+
# Test-only helpers. Not loaded by `require "standard_singpass"`; require
|
|
7
|
+
# this file from a spec helper:
|
|
8
|
+
#
|
|
9
|
+
# require "standard_singpass/testing"
|
|
10
|
+
#
|
|
11
|
+
# Nothing here is needed at runtime — Singpass encrypts, the gem decrypts.
|
|
12
|
+
module Testing
|
|
13
|
+
# ECDH-ES JWE *encryption*, the mirror of
|
|
14
|
+
# `StandardSingpass::Myinfo::EcdhJwe.decrypt`. Use it to build the
|
|
15
|
+
# encrypted id_token / userinfo payloads Singpass would return, so specs
|
|
16
|
+
# can exercise the real decrypt + verify path end to end.
|
|
17
|
+
#
|
|
18
|
+
# Accepts exactly the algorithms the decryptor does
|
|
19
|
+
# (`Myinfo::EcdhJwe::SUPPORTED_ALGS` / `SUPPORTED_ENCS`), and shares its
|
|
20
|
+
# key-agreement and KDF code, so a fixture that encrypts here decrypts
|
|
21
|
+
# there by construction.
|
|
22
|
+
class EcdhJwe
|
|
23
|
+
extend T::Sig
|
|
24
|
+
|
|
25
|
+
Impl = StandardSingpass::Myinfo::EcdhJwe
|
|
26
|
+
InvalidAlgorithm = StandardSingpass::Myinfo::EcdhJwe::InvalidAlgorithm
|
|
27
|
+
|
|
28
|
+
# Encrypts a payload and returns a compact-serialized JWE string.
|
|
29
|
+
sig do
|
|
30
|
+
params(
|
|
31
|
+
payload: String,
|
|
32
|
+
public_key: OpenSSL::PKey::EC,
|
|
33
|
+
alg: String,
|
|
34
|
+
enc: String,
|
|
35
|
+
kid: T.nilable(String),
|
|
36
|
+
apu: T.nilable(String),
|
|
37
|
+
apv: T.nilable(String)
|
|
38
|
+
).returns(String)
|
|
39
|
+
end
|
|
40
|
+
def self.encrypt(payload, public_key:, alg:, enc:, kid: nil, apu: nil, apv: nil)
|
|
41
|
+
shared(:validate_algorithms!, alg, enc)
|
|
42
|
+
|
|
43
|
+
# Generate ephemeral key pair on same curve
|
|
44
|
+
group = public_key.group
|
|
45
|
+
ephemeral_key = OpenSSL::PKey::EC.generate(group.curve_name)
|
|
46
|
+
|
|
47
|
+
# ECDH key agreement
|
|
48
|
+
shared_secret = shared(:derive_shared_secret, ephemeral_key, public_key)
|
|
49
|
+
|
|
50
|
+
# Derive KEK via Concat KDF
|
|
51
|
+
kek_size = Impl::KEK_SIZES.fetch(alg)
|
|
52
|
+
kek = shared(:concat_kdf, shared_secret, alg, kek_size, apu:, apv:)
|
|
53
|
+
|
|
54
|
+
# Generate random CEK
|
|
55
|
+
cek_size = Impl::CEK_SIZES.fetch(enc)
|
|
56
|
+
cek = SecureRandom.random_bytes(cek_size)
|
|
57
|
+
|
|
58
|
+
# Wrap CEK with KEK
|
|
59
|
+
encrypted_key = AESKeyWrap.wrap(cek, kek)
|
|
60
|
+
|
|
61
|
+
# Build header
|
|
62
|
+
epk_jwk = ec_public_key_to_jwk(ephemeral_key)
|
|
63
|
+
header = { "alg" => alg, "enc" => enc, "epk" => epk_jwk }
|
|
64
|
+
header["kid"] = kid if kid
|
|
65
|
+
header["apu"] = Base64.urlsafe_encode64(apu, padding: false) if apu
|
|
66
|
+
header["apv"] = Base64.urlsafe_encode64(apv, padding: false) if apv
|
|
67
|
+
|
|
68
|
+
# Encrypt content
|
|
69
|
+
header_b64 = Base64.urlsafe_encode64(header.to_json, padding: false)
|
|
70
|
+
iv, ciphertext, auth_tag = encrypt_content(cek, enc, payload, header_b64)
|
|
71
|
+
|
|
72
|
+
# Assemble compact serialization
|
|
73
|
+
[
|
|
74
|
+
header_b64,
|
|
75
|
+
Base64.urlsafe_encode64(encrypted_key, padding: false),
|
|
76
|
+
Base64.urlsafe_encode64(T.must(iv), padding: false),
|
|
77
|
+
Base64.urlsafe_encode64(T.must(ciphertext), padding: false),
|
|
78
|
+
Base64.urlsafe_encode64(T.must(auth_tag), padding: false)
|
|
79
|
+
].join(".")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class << self
|
|
83
|
+
extend T::Sig
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# The decryptor's key-agreement / KDF / validation helpers are private
|
|
88
|
+
# class methods; reuse them rather than keeping a second copy.
|
|
89
|
+
sig { params(name: Symbol, args: T.untyped, kwargs: T.untyped).returns(T.untyped) }
|
|
90
|
+
def shared(name, *args, **kwargs)
|
|
91
|
+
T.unsafe(Impl).send(name, *args, **kwargs)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Converts an OpenSSL EC key to a JWK hash (public components only).
|
|
95
|
+
sig { params(ec_key: OpenSSL::PKey::EC).returns(T::Hash[String, String]) }
|
|
96
|
+
def ec_public_key_to_jwk(ec_key)
|
|
97
|
+
# Get the public key point
|
|
98
|
+
point = ec_key.public_key
|
|
99
|
+
group = ec_key.group
|
|
100
|
+
|
|
101
|
+
# Determine curve name for JWK
|
|
102
|
+
crv = case group.curve_name
|
|
103
|
+
when "prime256v1" then "P-256"
|
|
104
|
+
when "secp384r1" then "P-384"
|
|
105
|
+
when "secp521r1" then "P-521"
|
|
106
|
+
else raise InvalidAlgorithm, "Unsupported curve: #{group.curve_name}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Get uncompressed point bytes (0x04 || x || y)
|
|
110
|
+
bn = point.to_bn(:uncompressed)
|
|
111
|
+
uncompressed = bn.to_s(2)
|
|
112
|
+
|
|
113
|
+
# Skip the 0x04 prefix byte
|
|
114
|
+
coord_length = (uncompressed.bytesize - 1) / 2
|
|
115
|
+
x = uncompressed[1, coord_length]
|
|
116
|
+
y = uncompressed[1 + coord_length, coord_length]
|
|
117
|
+
|
|
118
|
+
{
|
|
119
|
+
"kty" => "EC",
|
|
120
|
+
"crv" => crv,
|
|
121
|
+
"x" => Base64.urlsafe_encode64(x, padding: false),
|
|
122
|
+
"y" => Base64.urlsafe_encode64(y, padding: false)
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
sig { params(cek: String, enc: String, plaintext: String, aad: String).returns(T::Array[String]) }
|
|
127
|
+
def encrypt_content(cek, enc, plaintext, aad)
|
|
128
|
+
case enc
|
|
129
|
+
when "A128GCM", "A256GCM"
|
|
130
|
+
encrypt_gcm(cek, plaintext, aad, enc)
|
|
131
|
+
when "A128CBC-HS256", "A256CBC-HS512"
|
|
132
|
+
encrypt_cbc(cek, plaintext, aad, enc)
|
|
133
|
+
else
|
|
134
|
+
raise InvalidAlgorithm, "Unsupported enc: #{enc}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
sig { params(cek: String, plaintext: String, aad: String, enc: String).returns(T::Array[String]) }
|
|
139
|
+
def encrypt_gcm(cek, plaintext, aad, enc)
|
|
140
|
+
cipher = OpenSSL::Cipher.new(shared(:gcm_cipher_name, enc))
|
|
141
|
+
cipher.encrypt
|
|
142
|
+
cipher.key = cek
|
|
143
|
+
iv = cipher.random_iv
|
|
144
|
+
cipher.auth_data = aad
|
|
145
|
+
ciphertext = cipher.update(plaintext) + cipher.final
|
|
146
|
+
auth_tag = cipher.auth_tag
|
|
147
|
+
[iv, ciphertext, auth_tag]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
sig { params(cek: String, plaintext: String, aad: String, enc: String).returns(T::Array[String]) }
|
|
151
|
+
def encrypt_cbc(cek, plaintext, aad, enc)
|
|
152
|
+
mac_key_len = cek.bytesize / 2
|
|
153
|
+
mac_key = cek[0, mac_key_len]
|
|
154
|
+
enc_key = cek[mac_key_len, mac_key_len]
|
|
155
|
+
|
|
156
|
+
cipher = OpenSSL::Cipher.new(shared(:cbc_cipher_name, enc))
|
|
157
|
+
cipher.encrypt
|
|
158
|
+
cipher.key = T.must(enc_key)
|
|
159
|
+
iv = cipher.random_iv
|
|
160
|
+
ciphertext = cipher.update(plaintext) + cipher.final
|
|
161
|
+
|
|
162
|
+
# Compute authentication tag (HMAC over AAD || IV || ciphertext || AL)
|
|
163
|
+
al = [aad.bytesize * 8].pack("Q>")
|
|
164
|
+
hmac_input = aad + iv + ciphertext + al
|
|
165
|
+
hmac = OpenSSL::HMAC.digest(shared(:cbc_hmac_digest, enc), T.must(mac_key), hmac_input)
|
|
166
|
+
tag_len = mac_key_len # half of HMAC output
|
|
167
|
+
auth_tag = hmac[0, tag_len]
|
|
168
|
+
|
|
169
|
+
[iv, ciphertext, auth_tag]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
data/lib/standard_singpass.rb
CHANGED
|
@@ -3,4 +3,29 @@ require "standard_singpass/engine"
|
|
|
3
3
|
require "standard_singpass/myinfo"
|
|
4
4
|
|
|
5
5
|
module StandardSingpass
|
|
6
|
+
class << self
|
|
7
|
+
# Top-level entry points matching the sibling `standard_*` gems'
|
|
8
|
+
# `Gem.configure` / `Gem.config` convention. Singpass MyInfo is the only
|
|
9
|
+
# product the gem integrates today, so both delegate to
|
|
10
|
+
# `StandardSingpass::Myinfo` — `configure` goes through
|
|
11
|
+
# `Myinfo.configure` so the derived public-JWKS cache is still reset.
|
|
12
|
+
#
|
|
13
|
+
# StandardSingpass.configure { |c| c.client_id = "..." }
|
|
14
|
+
# StandardSingpass.config.client_id
|
|
15
|
+
def configure(&block)
|
|
16
|
+
Myinfo.configure(&block)
|
|
17
|
+
config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def config
|
|
21
|
+
Myinfo.configuration
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Deprecation warnings for the gem's own deprecated APIs. Registered
|
|
25
|
+
# with the host's `Rails.application.deprecators` by the engine, so the
|
|
26
|
+
# host's deprecation behaviour (log / raise / silence) applies.
|
|
27
|
+
def deprecator
|
|
28
|
+
@deprecator ||= ActiveSupport::Deprecation.new("0.5", "StandardSingpass")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
6
31
|
end
|