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
|
@@ -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.6", "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.
|
|
4
|
+
version: 0.5.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
|