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.
@@ -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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module StandardSingpass
2
- VERSION = "0.3.1"
2
+ VERSION = "0.4.0"
3
3
  end
@@ -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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_singpass
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -16,6 +16,9 @@ dependencies:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
18
  version: '8.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
@@ -23,6 +26,9 @@ dependencies:
23
26
  - - ">="
24
27
  - !ruby/object:Gem::Version
25
28
  version: '8.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: faraday
28
34
  requirement: !ruby/object:Gem::Requirement
@@ -30,6 +36,9 @@ dependencies:
30
36
  - - ">="
31
37
  - !ruby/object:Gem::Version
32
38
  version: '2.0'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '3'
33
42
  type: :runtime
34
43
  prerelease: false
35
44
  version_requirements: !ruby/object:Gem::Requirement
@@ -37,6 +46,9 @@ dependencies:
37
46
  - - ">="
38
47
  - !ruby/object:Gem::Version
39
48
  version: '2.0'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '3'
40
52
  - !ruby/object:Gem::Dependency
41
53
  name: jwt
42
54
  requirement: !ruby/object:Gem::Requirement
@@ -44,6 +56,9 @@ dependencies:
44
56
  - - ">="
45
57
  - !ruby/object:Gem::Version
46
58
  version: '2.7'
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '4'
47
62
  type: :runtime
48
63
  prerelease: false
49
64
  version_requirements: !ruby/object:Gem::Requirement
@@ -51,6 +66,9 @@ dependencies:
51
66
  - - ">="
52
67
  - !ruby/object:Gem::Version
53
68
  version: '2.7'
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '4'
54
72
  - !ruby/object:Gem::Dependency
55
73
  name: aes_key_wrap
56
74
  requirement: !ruby/object:Gem::Requirement
@@ -140,6 +158,7 @@ files:
140
158
  - lib/generators/standard_singpass/install/templates/initializer.rb.erb
141
159
  - lib/standard_singpass.rb
142
160
  - lib/standard_singpass/engine.rb
161
+ - lib/standard_singpass/error.rb
143
162
  - lib/standard_singpass/myinfo.rb
144
163
  - lib/standard_singpass/myinfo/client.rb
145
164
  - lib/standard_singpass/myinfo/configuration.rb
@@ -152,6 +171,7 @@ files:
152
171
  - lib/standard_singpass/myinfo/person_data_parser.rb
153
172
  - lib/standard_singpass/myinfo/security.rb
154
173
  - lib/standard_singpass/myinfo/test_personas.rb
174
+ - lib/standard_singpass/testing.rb
155
175
  - lib/standard_singpass/version.rb
156
176
  - lib/tasks/standard_singpass.rake
157
177
  homepage: https://github.com/rarebit-one/standard_singpass