lyrebird 1.0.0.alpha1 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 01f50b61e7c1cf3b9417c22f495bfc6cd37c15cdd5bb6989147a4462c10ce7ca
4
- data.tar.gz: ee3ff1a24a14319f978685696a73df8f6dd0a321a9907ef0e0c47cb5b2344fdf
3
+ metadata.gz: 61005075359475ff6b492db62c80f7fea1724bb059c03f19a3f9d04c1f37871f
4
+ data.tar.gz: 0e44c46fe69c8701c7b5441f7a23b6bbf752ad957ae044e5f19d398c01a8db91
5
5
  SHA512:
6
- metadata.gz: 98f2d39495b3f5db08e9f27e8663e9b9924ff55ee41b1b9a81a707aa91f6bfa452e823fb577702990bb05e9fdcd439e36573700f62ef30cbd4b48202cf7f4b98
7
- data.tar.gz: c526d72406244c4da1ba6cc9abe80abdd11191c7cba023db2a1a2b1bbd647a519aeb1531e49d4e09bd6701e1c6164a5b7969f141d005ff3109fe213bd0de0f1d
6
+ metadata.gz: 1ccd8ff5e0c7a4f441dc275a6231924f8969993c7b670b83a60d01f16e4ebf8fff33ccedffaad21a45dc6bca056d1450c5b6566699f9fd733f71fc9342818bec
7
+ data.tar.gz: c0ebff2c3cc4134b84fb52327ee41c7d34bd9b2ab3d89ac7a50a3485bc912ca6936ad64388b04253001106a961f7f2caad313b88a2915e86c7bbd4c516eae673
data/LICENSE.md ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026-present Simple SAML
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
data/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ [![Gem Version](
2
+ https://badge.fury.io/rb/lyrebird.svg
3
+ )](https://badge.fury.io/rb/lyrebird)
4
+
1
5
  # Lyrebird
2
6
  A Ruby gem for mimicking SAML Identity Provider (IdP) responses in test
3
7
  environments.
@@ -14,18 +18,19 @@ class SAMLTest < ActionDispatch::IntegrationTest
14
18
  test "consume creates a session" do
15
19
  user = users(:alice)
16
20
 
17
- response = Lyrebird::Response.new(
18
- issuer: "https://idp.example.com",
19
- destination: saml_consume_url,
20
- recipient: saml_consume_url,
21
- audience: root_url,
22
- name_id: user.email,
23
- attributes: {
24
- email: user.email,
25
- first_name: user.first_name,
26
- last_name: user.last_name
27
- }
28
- )
21
+ response = Lyrebird::Response.build do |r|
22
+ r.issuer = "https://idp.example.com"
23
+ r.destination = saml_consume_url
24
+ r.recipient = saml_consume_url
25
+ r.audience = root_url
26
+ r.name_id = user.email
27
+
28
+ r.attributes do |a|
29
+ a.email = user.email
30
+ a.first_name = user.first_name
31
+ a.last_name = user.last_name
32
+ end
33
+ end
29
34
 
30
35
  post saml_consume_path, params: { SAMLResponse: response.mimic }
31
36
 
@@ -36,45 +41,76 @@ end
36
41
  ```
37
42
 
38
43
  ## Response
39
- Creates complete SAML responses with embedded assertions.
44
+ Builds complete SAML responses with embedded assertions.
40
45
 
41
- ### Creating a response
46
+ ### Building a response
47
+ Defaults produce an SP-initiated response. See
48
+ [IdP-initiated SSO](#idp-initiated-sso) to omit `InResponseTo` and
49
+ `Destination`.
42
50
  ```ruby
43
- # With defaults
44
- response = Lyrebird::Response.new
51
+ # With defaults (SP-initiated)
52
+ response = Lyrebird::Response.build
45
53
 
46
54
  # With options
47
- response = Lyrebird::Response.new(
48
- issuer: "https://idp.example.com",
49
- destination: "https://sp.example.com/acs",
50
- in_response_to: "_request_id",
51
- name_id: "user@example.com",
52
- name_id_format: Lyrebird::NAMEID_EMAIL,
53
- recipient: "https://sp.example.com/acs",
54
- audience: "https://sp.example.com",
55
- valid_for: 300, # seconds
56
- attributes: {
57
- email: "user@example.com",
58
- groups: ["admin", "users"]
59
- }
60
- )
55
+ response = Lyrebird::Response.build do |r|
56
+ r.issuer = "https://idp.example.com"
57
+ r.destination = "https://sp.example.com/acs"
58
+ r.in_response_to = "_request_id"
59
+ r.name_id = "user@example.com"
60
+ r.name_id_format = Lyrebird::NAMEID_EMAIL
61
+ r.recipient = "https://sp.example.com/acs"
62
+ r.audience = "https://sp.example.com"
63
+ r.authn_context = "urn:oasis:names:tc:SAML:2.0:ac:classes:Password"
64
+ r.not_before = Time.now.utc
65
+ r.valid_for = 300 # seconds
66
+ r.sign_with = idp_cert
67
+ r.encrypt_with = sp_cert
68
+
69
+ r.attributes do |a|
70
+ a.email = "user@example.com"
71
+ a.groups = ["admin", "users"]
72
+ end
73
+ end
74
+ ```
75
+
76
+ ### IdP-initiated SSO
77
+ For unsolicited (IdP-initiated) flows where there is no AuthnRequest,
78
+ set `in_response_to` and `destination` to `nil` to omit them from the
79
+ XML entirely:
80
+ ```ruby
81
+ response = Lyrebird::Response.build do |r|
82
+ r.in_response_to = nil
83
+ r.destination = nil
84
+ r.name_id = "user@example.com"
85
+ end
61
86
  ```
62
87
 
63
88
  ### Getting the encoded response
64
89
  ```ruby
65
90
  response.mimic # Base64-encoded SAML response (for POST binding)
66
- response.document # REXML::Document for inspection
91
+ response.document # Nokogiri::XML::Document for inspection
67
92
  ```
68
93
 
69
94
  ### Signing
95
+ Sign both the assertion and response with an IdP certificate:
70
96
  ```ruby
71
- cert = Lyrebird::Certificate.generate
97
+ idp_cert = Lyrebird::Certificate.build
98
+ response = Lyrebird::Response.build(sign_with: idp_cert)
99
+ ```
72
100
 
73
- response = Lyrebird::Response.new(
74
- certificate: cert,
75
- sign_assertion: true, # Sign the assertion (default: false)
76
- sign_response: true # Sign the response (default: false)
77
- )
101
+ ### Encryption
102
+ Encrypt assertions using the SP's certificate so only the SP can decrypt them:
103
+ ```ruby
104
+ sp_cert = Lyrebird::Certificate.build
105
+ response = Lyrebird::Response.build(encrypt_with: sp_cert)
106
+ ```
107
+
108
+ Signing and encryption can be combined:
109
+ ```ruby
110
+ response = Lyrebird::Response.build do |r|
111
+ r.sign_with = idp_cert
112
+ r.encrypt_with = sp_cert
113
+ end
78
114
  ```
79
115
 
80
116
  ### NameID Formats
@@ -89,46 +125,50 @@ Lyrebird::NAMEID_UNSPECIFIED # unspecified
89
125
  Override defaults globally for all responses/assertions:
90
126
  ```ruby
91
127
  # test/test_helper.rb
92
- Lyrebird::DEFAULTS.issuer = "https://custom.example.com"
93
- Lyrebird::DEFAULTS.recipient = "https://custom.example.com/acs"
94
- Lyrebird::DEFAULTS.audience = "https://custom.example.com"
95
- Lyrebird::DEFAULTS.name_id = "default@example.com"
96
- Lyrebird::DEFAULTS.valid_for = 600 # 10 minutes
97
- Lyrebird::DEFAULTS.attributes = { role: "user" }
128
+ Lyrebird.configure do |d|
129
+ d.issuer = "https://custom.example.com"
130
+ d.recipient = "https://custom.example.com/acs"
131
+ d.audience = "https://custom.example.com"
132
+ d.name_id = "default@example.com"
133
+ d.valid_for = 600 # 10 minutes
134
+ d.attributes = { role: "user" }
135
+ end
98
136
  ```
137
+ Defaults are frozen after configuration for thread safety.
99
138
 
100
139
  ## Certificate
101
140
  Generates and manages X.509 certificates for signing SAML responses.
102
141
 
103
- ### Generating a new certificate
142
+ ### Building a certificate
104
143
  ```ruby
105
144
  # With defaults
106
- cert = Lyrebird::Certificate.generate
145
+ cert = Lyrebird::Certificate.build
107
146
 
108
147
  # With options
109
- cert = Lyrebird::Certificate.generate(
110
- bits: 4096, # RSA key size (default: 2048)
111
- cn: "example.com", # Common Name
112
- o: "Acme", # Organization
113
- valid_for: 30, # Validity in days (default: 365)
114
- valid_until: Time.new(2026, 12, 31) # Specific expiration (overrides valid_for)
115
- )
148
+ cert = Lyrebird::Certificate.build do |c|
149
+ c.bits = 4096 # RSA key size (default: 2048)
150
+ c.cn = "example.com" # Common Name
151
+ c.o = "Acme" # Organization
152
+ c.valid_for = 30 # Days (default: 365)
153
+ c.valid_until = Time.new(2999, 12, 31) # Overrides valid_for
154
+ end
116
155
  ```
117
156
 
118
157
  ### Loading an existing certificate
119
158
  ```ruby
120
159
  cert = Lyrebird::Certificate.load(
121
- private_key_pem: File.read("private_key.pem"),
122
- certificate_pem: File.read("certificate.pem")
160
+ key_pem: File.read("private_key.pem"),
161
+ x509_pem: File.read("certificate.pem")
123
162
  )
124
163
  ```
125
164
 
126
- ### Exporting
165
+ ### Using a certificate
127
166
  ```ruby
128
- cert.private_key # OpenSSL::PKey::RSA object
129
- cert.certificate # OpenSSL::X509::Certificate object
130
- cert.private_key_pem # PEM-encoded private key
131
- cert.certificate_pem # PEM-encoded certificate
132
- cert.base64 # Base64-encoded certificate (for SAML metadata)
133
- cert.fingerprint # SHA256 fingerprint
167
+ cert.key # OpenSSL::PKey::RSA private key
168
+ cert.x509 # OpenSSL::X509::Certificate object
169
+ cert.key_pem # PEM-encoded private key
170
+ cert.x509_pem # PEM-encoded certificate
171
+ cert.sign(data) # Sign data with RSA-SHA256
172
+ cert.base64 # Base64-encoded certificate (for SAML metadata)
173
+ cert.fingerprint # SHA256 fingerprint
134
174
  ```
@@ -8,19 +8,19 @@ module Lyrebird
8
8
  name_id_format: DEFAULTS.name_id_format,
9
9
  recipient: DEFAULTS.recipient,
10
10
  in_response_to: DEFAULTS.in_response_to,
11
+ not_before: nil,
11
12
  valid_for: DEFAULTS.valid_for,
12
13
  audience: DEFAULTS.audience,
13
14
  authn_context: DEFAULTS.authn_context,
14
15
  attributes: DEFAULTS.attributes
15
16
  )
16
- @id = ID.generate
17
- @session_index = ID.generate
18
17
  @issue_instant = Time.now.utc
19
18
  @issuer = issuer
20
19
  @name_id = name_id
21
20
  @name_id_format = name_id_format
22
21
  @recipient = recipient
23
22
  @in_response_to = in_response_to
23
+ @not_before = not_before || @issue_instant
24
24
  @not_on_or_after = @issue_instant + valid_for
25
25
  @audience = audience
26
26
  @authn_context = authn_context
@@ -28,74 +28,110 @@ module Lyrebird
28
28
  end
29
29
 
30
30
  def document
31
- REXML::Document.new.tap do |d|
32
- d.add_element(root)
31
+ Nokogiri::XML::Document.new.tap do |doc|
32
+ doc.root = root(doc)
33
33
  end
34
34
  end
35
35
 
36
36
  private
37
37
 
38
- def root
39
- REXML::Element.new("saml:Assertion").tap do |r|
40
- r.add_namespace("saml", SAML_ASSERTION_NS)
41
- r.add_attribute("ID", @id)
42
- r.add_attribute("Version", "2.0")
43
- r.add_attribute("IssueInstant", @issue_instant.iso8601)
44
- r.add_element("saml:Issuer").text = @issuer
45
- r.add_element(subject)
46
- r.add_element(conditions)
47
- r.add_element(authn_statement)
48
- r.add_element(attribute_statement) if @attributes.any?
38
+ def root(doc)
39
+ doc.create_element("Assertion").tap do |a|
40
+ ns = a.add_namespace_definition("saml", SAML_ASSERTION_NS)
41
+
42
+ a.namespace = ns
43
+ a["ID"] = ID.generate
44
+ a["Version"] = "2.0"
45
+ a["IssueInstant"] = @issue_instant.iso8601
46
+
47
+ a.add_child(doc.create_element("Issuer")).tap do |i|
48
+ i.namespace = ns
49
+ i.content = @issuer
50
+ end
51
+
52
+ a.add_child(subject(doc, ns))
53
+ a.add_child(conditions(doc, ns))
54
+ a.add_child(authn_statement(doc, ns))
55
+ a.add_child(attribute_statement(doc, ns)) if @attributes.any?
49
56
  end
50
57
  end
51
58
 
52
- def subject
53
- REXML::Element.new("saml:Subject").tap do |s|
54
- name_id = s.add_element("saml:NameID")
55
- name_id.add_attribute("Format", @name_id_format)
56
- name_id.text = @name_id
57
- s.add_element(subject_confirmation)
59
+ def subject(doc, ns)
60
+ doc.create_element("Subject").tap do |s|
61
+ s.namespace = ns
62
+
63
+ s.add_child(doc.create_element("NameID")).tap do |nid|
64
+ nid.namespace = ns
65
+ nid["Format"] = @name_id_format
66
+ nid.content = @name_id
67
+ end
68
+
69
+ s.add_child(subject_confirmation(doc, ns))
58
70
  end
59
71
  end
60
72
 
61
- def subject_confirmation
62
- REXML::Element.new("saml:SubjectConfirmation").tap do |sc|
63
- sc.add_attribute("Method", CM_BEARER)
64
- data = sc.add_element("saml:SubjectConfirmationData")
65
- data.add_attribute("NotOnOrAfter", @not_on_or_after.iso8601)
66
- data.add_attribute("Recipient", @recipient)
67
- data.add_attribute("InResponseTo", @in_response_to)
73
+ def subject_confirmation(doc, ns)
74
+ doc.create_element("SubjectConfirmation").tap do |sc|
75
+ sc.namespace = ns
76
+ sc["Method"] = CM_BEARER
77
+
78
+ sc.add_child(doc.create_element("SubjectConfirmationData")).tap do |d|
79
+ d.namespace = ns
80
+ d["NotOnOrAfter"] = @not_on_or_after.iso8601
81
+ d["Recipient"] = @recipient
82
+ d["InResponseTo"] = @in_response_to if @in_response_to
83
+ end
68
84
  end
69
85
  end
70
86
 
71
- def conditions
72
- REXML::Element.new("saml:Conditions").tap do |c|
73
- c.add_attribute("NotBefore", @issue_instant.iso8601)
74
- c.add_attribute("NotOnOrAfter", @not_on_or_after.iso8601)
75
- ar = c.add_element("saml:AudienceRestriction")
76
- ar.add_element("saml:Audience").text = @audience
87
+ def conditions(doc, ns)
88
+ doc.create_element("Conditions").tap do |c|
89
+ c.namespace = ns
90
+ c["NotBefore"] = @not_before.iso8601
91
+ c["NotOnOrAfter"] = @not_on_or_after.iso8601
92
+
93
+ c.add_child(doc.create_element("AudienceRestriction")).tap do |ar|
94
+ ar.namespace = ns
95
+ ar.add_child(doc.create_element("Audience")).tap do |a|
96
+ a.namespace = ns
97
+ a.content = @audience
98
+ end
99
+ end
77
100
  end
78
101
  end
79
102
 
80
- def authn_statement
81
- REXML::Element.new("saml:AuthnStatement").tap do |as|
82
- as.add_attribute("AuthnInstant", @issue_instant.iso8601)
83
- as.add_attribute("SessionIndex", @session_index)
84
- ac = as.add_element("saml:AuthnContext")
85
- cr = ac.add_element("saml:AuthnContextClassRef")
86
- cr.text = @authn_context
103
+ def authn_statement(doc, ns)
104
+ doc.create_element("AuthnStatement").tap do |as|
105
+ as.namespace = ns
106
+ as["AuthnInstant"] = @issue_instant.iso8601
107
+ as["SessionIndex"] = ID.generate
108
+
109
+ as.add_child(doc.create_element("AuthnContext")).tap do |ac|
110
+ ac.namespace = ns
111
+ ac.add_child(doc.create_element("AuthnContextClassRef")).tap do |cr|
112
+ cr.namespace = ns
113
+ cr.content = @authn_context
114
+ end
115
+ end
87
116
  end
88
117
  end
89
118
 
90
- def attribute_statement
91
- REXML::Element.new("saml:AttributeStatement").tap do |as|
119
+ def attribute_statement(doc, ns)
120
+ doc.create_element("AttributeStatement").tap do |as|
121
+ as.namespace = ns
122
+
92
123
  @attributes.each do |name, values|
93
- a = as.add_element("saml:Attribute")
94
- a.add_attribute("Name", name)
95
- a.add_attribute("NameFormat", ATTR_NAME_FORMAT)
124
+ as.add_child(doc.create_element("Attribute")).tap do |a|
125
+ a.namespace = ns
126
+ a["Name"] = name
127
+ a["NameFormat"] = ATTR_NAME_FORMAT
96
128
 
97
- Array(values).each do |value|
98
- a.add_element("saml:AttributeValue").text = value
129
+ Array(values).each do |value|
130
+ a.add_child(doc.create_element("AttributeValue")).tap do |av|
131
+ av.namespace = ns
132
+ av.content = value
133
+ end
134
+ end
99
135
  end
100
136
  end
101
137
  end
@@ -2,59 +2,71 @@
2
2
 
3
3
  module Lyrebird
4
4
  class Certificate
5
- attr_reader :private_key, :certificate
5
+ attr_reader :key, :x509
6
6
 
7
- def self.generate(bits: 2048, **options)
8
- new(OpenSSL::PKey::RSA.new(bits), **options)
7
+ def self.build(**kwargs)
8
+ config = OpenStruct.new(kwargs)
9
+ yield config if block_given?
10
+ new(**config.to_h)
9
11
  end
10
12
 
11
- def self.load(private_key_pem:, certificate_pem:)
12
- private_key = OpenSSL::PKey::RSA.new(private_key_pem)
13
- certificate = OpenSSL::X509::Certificate.new(certificate_pem)
14
- new(private_key, certificate: certificate)
13
+ def self.load(key_pem:, x509_pem:)
14
+ key = OpenSSL::PKey::RSA.new(key_pem)
15
+ x509 = OpenSSL::X509::Certificate.new(x509_pem)
16
+ new(key: key, x509: x509)
15
17
  end
16
18
 
17
19
  def initialize(
18
- private_key,
20
+ bits: 2048,
19
21
  cn: nil,
20
22
  o: nil,
21
23
  valid_for: 365,
22
24
  valid_until: nil,
23
- certificate: nil
25
+ key: nil,
26
+ x509: nil
24
27
  )
25
- @private_key = private_key
26
28
  @common_name = cn
27
29
  @organization = o
28
- @not_after = valid_until || Time.now + (valid_for * 24 * 60 * 60)
29
- @certificate = certificate || build_certificate
30
+ @valid_for = valid_for
31
+ @valid_until = valid_until
32
+ @key = key || OpenSSL::PKey::RSA.new(bits)
33
+ @x509 = x509 || build_x509
30
34
  end
31
35
 
32
- def private_key_pem
33
- @private_key.to_pem
36
+ def key_pem
37
+ @key.to_pem
34
38
  end
35
39
 
36
- def certificate_pem
37
- @certificate.to_pem
40
+ def x509_pem
41
+ @x509.to_pem
38
42
  end
39
43
 
40
44
  def fingerprint
41
- OpenSSL::Digest::SHA256.hexdigest(@certificate.to_der)
45
+ OpenSSL::Digest::SHA256.hexdigest(@x509.to_der)
42
46
  end
43
47
 
44
48
  def base64
45
- Base64.strict_encode64(@certificate.to_der)
49
+ Base64.strict_encode64(@x509.to_der)
50
+ end
51
+
52
+ def sign(data)
53
+ @key.sign("SHA256", data)
46
54
  end
47
55
 
48
56
  private
49
57
 
50
- def build_certificate
58
+ def build_x509
59
+ now = Time.now.utc
60
+
51
61
  OpenSSL::X509::Certificate.new.tap do |c|
52
- c.public_key = @private_key.public_key
62
+ c.version = 2
63
+ c.serial = OpenSSL::BN.rand(64)
64
+ c.public_key = @key.public_key
53
65
  c.subject = build_subject
54
66
  c.issuer = c.subject
55
- c.not_before = Time.now
56
- c.not_after = @not_after
57
- c.sign(@private_key, OpenSSL::Digest::SHA256.new)
67
+ c.not_before = now
68
+ c.not_after = @valid_until || now + (@valid_for * 86_400)
69
+ c.sign(@key, OpenSSL::Digest::SHA256.new)
58
70
  end
59
71
  end
60
72
 
@@ -33,7 +33,7 @@ module Lyrebird
33
33
  @attributes = {
34
34
  first_name: "Test",
35
35
  last_name: "User",
36
- }
36
+ }.freeze
37
37
  end
38
38
  end
39
39
 
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lyrebird
4
+ class Encryption
5
+ def initialize(element, certificate)
6
+ @element = element
7
+ @certificate = certificate
8
+ @doc = element.document
9
+ @aes_key = SecureRandom.random_bytes(32)
10
+ end
11
+
12
+ def encrypt
13
+ build_encrypted_assertion
14
+ end
15
+
16
+ private
17
+
18
+ def build_encrypted_assertion
19
+ @doc.create_element("EncryptedAssertion").tap do |ea|
20
+ @saml = ea.add_namespace_definition("saml", SAML_ASSERTION_NS)
21
+ ea.namespace = @saml
22
+ ea.add_child(build_encrypted_data)
23
+ end
24
+ end
25
+
26
+ def build_encrypted_data
27
+ @doc.create_element("EncryptedData").tap do |ed|
28
+ @xenc = ed.add_namespace_definition("xenc", XMLENC_NS)
29
+ ed.namespace = @xenc
30
+ ed["Type"] = "#{XMLENC_NS}Element"
31
+
32
+ ed.add_child(@doc.create_element("EncryptionMethod")).tap do |em|
33
+ em.namespace = @xenc
34
+ em["Algorithm"] = AES256_CBC
35
+ end
36
+
37
+ ed.add_child(build_key_info)
38
+ ed.add_child(build_cipher_data)
39
+ end
40
+ end
41
+
42
+ def build_key_info
43
+ @doc.create_element("KeyInfo").tap do |ki|
44
+ @ds = ki.add_namespace_definition("ds", XMLDSIG_NS)
45
+ ki.namespace = @ds
46
+ ki.add_child(build_encrypted_key)
47
+ end
48
+ end
49
+
50
+ def build_encrypted_key
51
+ @doc.create_element("EncryptedKey").tap do |ek|
52
+ ek.add_namespace_definition("xenc", XMLENC_NS)
53
+ ek.namespace = @xenc
54
+
55
+ ek.add_child(@doc.create_element("EncryptionMethod")).tap do |em|
56
+ em.namespace = @xenc
57
+ em["Algorithm"] = RSA_OAEP
58
+ end
59
+
60
+ ek.add_child(build_encrypted_key_cipher_data)
61
+ end
62
+ end
63
+
64
+ def build_encrypted_key_cipher_data
65
+ public_key = @certificate.x509.public_key
66
+ padding = OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING
67
+ encrypted_aes_key = public_key.public_encrypt(@aes_key, padding)
68
+
69
+ @doc.create_element("CipherData").tap do |cd|
70
+ cd.namespace = @xenc
71
+
72
+ cd.add_child(@doc.create_element("CipherValue")).tap do |cv|
73
+ cv.namespace = @xenc
74
+ cv.content = Base64.strict_encode64(encrypted_aes_key)
75
+ end
76
+ end
77
+ end
78
+
79
+ def build_cipher_data
80
+ cipher = OpenSSL::Cipher.new("AES-256-CBC")
81
+ cipher.encrypt
82
+ cipher.key = @aes_key
83
+ iv = cipher.random_iv
84
+ ciphertext = cipher.update(@element.to_xml) + cipher.final
85
+
86
+ @doc.create_element("CipherData").tap do |cd|
87
+ cd.namespace = @xenc
88
+
89
+ cd.add_child(@doc.create_element("CipherValue")).tap do |cv|
90
+ cv.namespace = @xenc
91
+ cv.content = Base64.strict_encode64(iv + ciphertext)
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -11,4 +11,7 @@ module Lyrebird
11
11
  CM_BEARER = "urn:oasis:names:tc:SAML:2.0:cm:bearer"
12
12
  ATTR_NAME_FORMAT = "urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified"
13
13
  STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success"
14
+ XMLENC_NS = "http://www.w3.org/2001/04/xmlenc#"
15
+ AES256_CBC = "http://www.w3.org/2001/04/xmlenc#aes256-cbc"
16
+ RSA_OAEP = "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"
14
17
  end