lyrebird 1.0.0 → 2.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: 61005075359475ff6b492db62c80f7fea1724bb059c03f19a3f9d04c1f37871f
4
- data.tar.gz: 0e44c46fe69c8701c7b5441f7a23b6bbf752ad957ae044e5f19d398c01a8db91
3
+ metadata.gz: 6b9457c5f260c26c5adf43de4262d67077e1f5729b4506ff0efa92ec61519346
4
+ data.tar.gz: 16a5dbf97973cd9c3673c26212fa0be146955ef9ddaf877768d40120416d6e54
5
5
  SHA512:
6
- metadata.gz: 1ccd8ff5e0c7a4f441dc275a6231924f8969993c7b670b83a60d01f16e4ebf8fff33ccedffaad21a45dc6bca056d1450c5b6566699f9fd733f71fc9342818bec
7
- data.tar.gz: c0ebff2c3cc4134b84fb52327ee41c7d34bd9b2ab3d89ac7a50a3485bc912ca6936ad64388b04253001106a961f7f2caad313b88a2915e86c7bbd4c516eae673
6
+ metadata.gz: fc83af3b91fb52553dc96733c2a8ea0e89c561fe124bddc1b769cb39f681e03c1e18ff78ec2c83ffe886114b369e4a1630e637e56a1cf2dcee2137a5fe88680b
7
+ data.tar.gz: 138bd3e26e223c69dc10e435b21c12e0e1fcf3322e64df65d1775695d8920e6a274caf0d1323c5722244d73a47a09296d4d2a1d2037f2761084f470fcff6202c
data/README.md CHANGED
@@ -7,6 +7,7 @@ A Ruby gem for mimicking SAML Identity Provider (IdP) responses in test
7
7
  environments.
8
8
 
9
9
  ## Installation
10
+ Requires Ruby 3.2 or newer.
10
11
  ```ruby
11
12
  gem "lyrebird", group: :test
12
13
  ```
@@ -24,6 +25,7 @@ class SAMLTest < ActionDispatch::IntegrationTest
24
25
  r.recipient = saml_consume_url
25
26
  r.audience = root_url
26
27
  r.name_id = user.email
28
+ r.sign_with = Lyrebird::Certificate.default
27
29
 
28
30
  r.attributes do |a|
29
31
  a.email = user.email
@@ -88,8 +90,12 @@ end
88
90
  ### Getting the encoded response
89
91
  ```ruby
90
92
  response.mimic # Base64-encoded SAML response (for POST binding)
91
- response.document # Nokogiri::XML::Document for inspection
93
+ response.to_xml # the same response as plain XML (for reading)
94
+ response.document # the response as a Nokogiri document (for searching)
92
95
  ```
96
+ Use `response.to_xml`, not `document.to_xml`. Going through `document`
97
+ adds spaces and line breaks, which breaks the signature, and the SP
98
+ rejects the response.
93
99
 
94
100
  ### Signing
95
101
  Sign both the assertion and response with an IdP certificate:
@@ -112,6 +118,35 @@ response = Lyrebird::Response.build do |r|
112
118
  r.encrypt_with = sp_cert
113
119
  end
114
120
  ```
121
+ The assertion is signed first and then encrypted, and the response is
122
+ signed over the ciphertext.
123
+
124
+ ### Testing rejection paths
125
+ Build deliberately invalid responses the SP should reject:
126
+ ```ruby
127
+ # Expired (NotOnOrAfter in the past)
128
+ response = Lyrebird::Response.build do |r|
129
+ r.sign_with = idp_cert
130
+ r.valid_for = -10
131
+ end
132
+
133
+ # Not yet valid (NotBefore in the future)
134
+ response = Lyrebird::Response.build do |r|
135
+ r.sign_with = idp_cert
136
+ r.not_before = Time.now.utc + 600
137
+ end
138
+
139
+ # Wrong audience
140
+ response = Lyrebird::Response.build do |r|
141
+ r.sign_with = idp_cert
142
+ r.audience = "https://wrong.example.com"
143
+ end
144
+
145
+ # Untrusted signature (a certificate the SP does not know)
146
+ response = Lyrebird::Response.build do |r|
147
+ r.sign_with = Lyrebird::Certificate.build
148
+ end
149
+ ```
115
150
 
116
151
  ### NameID Formats
117
152
  ```ruby
@@ -134,13 +169,19 @@ Lyrebird.configure do |d|
134
169
  d.attributes = { role: "user" }
135
170
  end
136
171
  ```
137
- Defaults are frozen after configuration for thread safety.
172
+ Defaults are frozen after configuration for thread safety, so
173
+ `configure` can only be called once.
138
174
 
139
175
  ## Certificate
140
176
  Generates and manages X.509 certificates for signing SAML responses.
141
177
 
142
178
  ### Building a certificate
179
+ Key generation is slow, so `Lyrebird::Certificate.default` generates one
180
+ certificate and reuses it.
143
181
  ```ruby
182
+ # Generated once and reused
183
+ cert = Lyrebird::Certificate.default
184
+
144
185
  # With defaults
145
186
  cert = Lyrebird::Certificate.build
146
187
 
@@ -164,11 +205,11 @@ cert = Lyrebird::Certificate.load(
164
205
 
165
206
  ### Using a certificate
166
207
  ```ruby
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
208
+ cert.key # OpenSSL::PKey::RSA private key
209
+ cert.x509 # OpenSSL::X509::Certificate object
210
+ cert.key_pem # PEM-encoded private key
211
+ cert.x509_pem # PEM-encoded certificate
212
+ cert.base64 # Base64-encoded certificate (for SAML metadata)
213
+ cert.fingerprint # SHA256 fingerprint, colon-separated
214
+ cert.fingerprint(:sha1) # SHA1 fingerprint
174
215
  ```
@@ -20,15 +20,15 @@ module Lyrebird
20
20
  @name_id_format = name_id_format
21
21
  @recipient = recipient
22
22
  @in_response_to = in_response_to
23
- @not_before = not_before || @issue_instant
24
- @not_on_or_after = @issue_instant + valid_for
23
+ @not_before = (not_before || @issue_instant).to_time.getutc
24
+ @not_on_or_after = [@issue_instant, @not_before].max + valid_for
25
25
  @audience = audience
26
26
  @authn_context = authn_context
27
27
  @attributes = attributes
28
28
  end
29
29
 
30
30
  def document
31
- Nokogiri::XML::Document.new.tap do |doc|
31
+ @document ||= Nokogiri::XML::Document.new.tap do |doc|
32
32
  doc.root = root(doc)
33
33
  end
34
34
  end
@@ -121,12 +121,14 @@ module Lyrebird
121
121
  as.namespace = ns
122
122
 
123
123
  @attributes.each do |name, values|
124
+ list = values.is_a?(Enumerable) ? values.to_a : [values].compact
125
+
124
126
  as.add_child(doc.create_element("Attribute")).tap do |a|
125
127
  a.namespace = ns
126
128
  a["Name"] = name
127
129
  a["NameFormat"] = ATTR_NAME_FORMAT
128
130
 
129
- Array(values).each do |value|
131
+ list.each do |value|
130
132
  a.add_child(doc.create_element("AttributeValue")).tap do |av|
131
133
  av.namespace = ns
132
134
  av.content = value
@@ -2,8 +2,19 @@
2
2
 
3
3
  module Lyrebird
4
4
  class Certificate
5
+ LOCK = Mutex.new
6
+
7
+ EXTENSIONS = {
8
+ "basicConstraints" => "CA:FALSE",
9
+ "keyUsage" => "digitalSignature,keyEncipherment"
10
+ }.freeze
11
+
5
12
  attr_reader :key, :x509
6
13
 
14
+ def self.default
15
+ LOCK.synchronize { @default ||= build }
16
+ end
17
+
7
18
  def self.build(**kwargs)
8
19
  config = OpenStruct.new(kwargs)
9
20
  yield config if block_given?
@@ -18,7 +29,7 @@ module Lyrebird
18
29
 
19
30
  def initialize(
20
31
  bits: 2048,
21
- cn: nil,
32
+ cn: "lyrebird",
22
33
  o: nil,
23
34
  valid_for: 365,
24
35
  valid_until: nil,
@@ -41,18 +52,15 @@ module Lyrebird
41
52
  @x509.to_pem
42
53
  end
43
54
 
44
- def fingerprint
45
- OpenSSL::Digest::SHA256.hexdigest(@x509.to_der)
55
+ def fingerprint(algorithm = :sha256)
56
+ digest = OpenSSL::Digest.new(algorithm.to_s)
57
+ digest.hexdigest(@x509.to_der).upcase.scan(/../).join(":")
46
58
  end
47
59
 
48
60
  def base64
49
61
  Base64.strict_encode64(@x509.to_der)
50
62
  end
51
63
 
52
- def sign(data)
53
- @key.sign("SHA256", data)
54
- end
55
-
56
64
  private
57
65
 
58
66
  def build_x509
@@ -66,6 +74,11 @@ module Lyrebird
66
74
  c.issuer = c.subject
67
75
  c.not_before = now
68
76
  c.not_after = @valid_until || now + (@valid_for * 86_400)
77
+
78
+ EXTENSIONS.each do |oid, value|
79
+ c.add_extension(OpenSSL::X509::Extension.new(oid, value, true))
80
+ end
81
+
69
82
  c.sign(@key, OpenSSL::Digest::SHA256.new)
70
83
  end
71
84
  end
@@ -49,7 +49,6 @@ module Lyrebird
49
49
 
50
50
  def build_encrypted_key
51
51
  @doc.create_element("EncryptedKey").tap do |ek|
52
- ek.add_namespace_definition("xenc", XMLENC_NS)
53
52
  ek.namespace = @xenc
54
53
 
55
54
  ek.add_child(@doc.create_element("EncryptionMethod")).tap do |em|
@@ -81,7 +80,8 @@ module Lyrebird
81
80
  cipher.encrypt
82
81
  cipher.key = @aes_key
83
82
  iv = cipher.random_iv
84
- ciphertext = cipher.update(@element.to_xml) + cipher.final
83
+ plaintext = @element.to_xml(save_with: 0)
84
+ ciphertext = cipher.update(plaintext) + cipher.final
85
85
 
86
86
  @doc.create_element("CipherData").tap do |cd|
87
87
  cd.namespace = @xenc
@@ -6,7 +6,11 @@ module Lyrebird
6
6
  config = OpenStruct.new(kwargs)
7
7
 
8
8
  config.define_singleton_method(:attributes) do |&block|
9
- self.attributes = OpenStruct.new.tap(&block).to_h
9
+ current = self[:attributes] || {}
10
+ return current unless block
11
+
12
+ fresh = OpenStruct.new.tap(&block).to_h
13
+ self[:attributes] = current.transform_keys(&:to_sym).merge(fresh)
10
14
  end
11
15
 
12
16
  yield config if block_given?
@@ -35,11 +39,15 @@ module Lyrebird
35
39
  end
36
40
 
37
41
  def mimic
38
- Base64.strict_encode64(document.to_xml(save_with: 0))
42
+ Base64.strict_encode64(to_xml)
43
+ end
44
+
45
+ def to_xml
46
+ document.to_xml(save_with: 0)
39
47
  end
40
48
 
41
49
  def document
42
- Nokogiri::XML::Document.new.tap do |doc|
50
+ @document ||= Nokogiri::XML::Document.new.tap do |doc|
43
51
  doc.root = build_response(doc)
44
52
  sign_assertion(doc) if @sign_with && !@encrypt_with
45
53
  sign_response(doc) if @sign_with
@@ -66,13 +74,12 @@ module Lyrebird
66
74
  end
67
75
 
68
76
  r.add_child(build_status(doc))
69
- r.add_child(build_assertion_element(doc))
77
+ r.add_child(build_assertion_element)
70
78
  end
71
79
  end
72
80
 
73
- def build_assertion_element(doc)
74
- assertion_doc = @assertion.document
75
- element = assertion_doc.root
81
+ def build_assertion_element
82
+ element = @assertion.document.root
76
83
 
77
84
  if @encrypt_with
78
85
  Signature.new(element, @sign_with).sign! if @sign_with
@@ -11,8 +11,11 @@ module Lyrebird
11
11
  end
12
12
 
13
13
  def sign!
14
- issuer = @element.at_xpath("saml:Issuer", "saml" => SAML_ASSERTION_NS)
14
+ ns = { "saml" => SAML_ASSERTION_NS, "ds" => XMLDSIG_NS }
15
+ @element.at_xpath("ds:Signature", ns)&.remove
16
+ issuer = @element.at_xpath("saml:Issuer", ns)
15
17
  issuer.add_next_sibling(build_signature)
18
+ populate_signature_value
16
19
  self
17
20
  end
18
21
 
@@ -32,7 +35,6 @@ module Lyrebird
32
35
  def build_signed_info
33
36
  @doc.create_element("SignedInfo").tap do |si|
34
37
  @signed_info = si
35
- si.add_namespace_definition("ds", XMLDSIG_NS)
36
38
  si.namespace = @ds
37
39
 
38
40
  si.add_child(@doc.create_element("CanonicalizationMethod")).tap do |cm|
@@ -52,16 +54,14 @@ module Lyrebird
52
54
  def build_signature_value
53
55
  @doc.create_element("SignatureValue").tap do |sv|
54
56
  sv.namespace = @ds
55
- canonical = canonicalize_signed_info
56
- sig = @certificate.sign(canonical)
57
- sv.content = Base64.strict_encode64(sig)
57
+ @signature_value = sv
58
58
  end
59
59
  end
60
60
 
61
- def canonicalize_signed_info
62
- xml = @signed_info.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML)
63
- xml = xml.sub("<ds:SignedInfo>", "<ds:SignedInfo xmlns:ds=\"#{XMLDSIG_NS}\">")
64
- Nokogiri::XML(xml).root.canonicalize(C14N_EXCLUSIVE)
61
+ def populate_signature_value
62
+ canonical = @signed_info.canonicalize(C14N_EXCLUSIVE)
63
+ sig = @certificate.key.sign("SHA256", canonical)
64
+ @signature_value.content = Base64.strict_encode64(sig)
65
65
  end
66
66
 
67
67
  def build_key_info
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lyrebird
4
- VERSION = "1.0.0"
4
+ VERSION = "2.0.0"
5
5
  end
data/lib/lyrebird.rb CHANGED
@@ -18,8 +18,6 @@ require_relative "lyrebird/signature"
18
18
  require_relative "lyrebird/version"
19
19
 
20
20
  module Lyrebird
21
- class Error < StandardError; end
22
-
23
21
  rails_env = ENV["RAILS_ENV"]&.downcase
24
22
  rack_env = ENV["RACK_ENV"]&.downcase
25
23
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lyrebird
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-02 00:00:00.000000000 Z
11
+ date: 2026-08-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -114,11 +114,8 @@ executables: []
114
114
  extensions: []
115
115
  extra_rdoc_files: []
116
116
  files:
117
- - ".github/workflows/ci.yml"
118
- - ".github/workflows/publish.yml"
119
117
  - LICENSE.md
120
118
  - README.md
121
- - Rakefile
122
119
  - lib/lyrebird.rb
123
120
  - lib/lyrebird/assertion.rb
124
121
  - lib/lyrebird/certificate.rb
@@ -129,22 +126,13 @@ files:
129
126
  - lib/lyrebird/response.rb
130
127
  - lib/lyrebird/signature.rb
131
128
  - lib/lyrebird/version.rb
132
- - lyrebird.gemspec
133
- - sig/lyrebird.rbs
134
- - test/lyrebird/assertion_test.rb
135
- - test/lyrebird/certificate_test.rb
136
- - test/lyrebird/defaults_test.rb
137
- - test/lyrebird/encryption_test.rb
138
- - test/lyrebird/id_test.rb
139
- - test/lyrebird/integration_test.rb
140
- - test/lyrebird/namespaces_test.rb
141
- - test/lyrebird/response_test.rb
142
- - test/lyrebird/signature_test.rb
143
- - test/test_helper.rb
144
129
  homepage: https://github.com/simplesaml/lyrebird
145
130
  licenses:
146
131
  - MIT
147
- metadata: {}
132
+ metadata:
133
+ source_code_uri: https://github.com/simplesaml/lyrebird
134
+ changelog_uri: https://github.com/simplesaml/lyrebird/releases
135
+ bug_tracker_uri: https://github.com/simplesaml/lyrebird/issues
148
136
  post_install_message:
149
137
  rdoc_options: []
150
138
  require_paths:
@@ -1,25 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
- branches: [main]
8
-
9
- jobs:
10
- test:
11
- runs-on: ubuntu-latest
12
- strategy:
13
- fail-fast: false
14
- matrix:
15
- ruby-version: ["3.2", "3.3", "3.4", "4.0"]
16
-
17
- steps:
18
- - uses: actions/checkout@v4
19
- - name: Set up Ruby ${{ matrix.ruby-version }}
20
- uses: ruby/setup-ruby@v1
21
- with:
22
- ruby-version: ${{ matrix.ruby-version }}
23
- bundler-cache: true
24
- - name: Run tests
25
- run: bundle exec rake test
@@ -1,20 +0,0 @@
1
- name: Publish
2
-
3
- on:
4
- release:
5
- types: [published]
6
-
7
- jobs:
8
- publish:
9
- runs-on: ubuntu-latest
10
- steps:
11
- - uses: actions/checkout@v4
12
- - uses: ruby/setup-ruby@v1
13
- with:
14
- ruby-version: "3.2"
15
- - name: Build gem
16
- run: gem build lyrebird.gemspec
17
- - name: Publish to RubyGems
18
- run: gem push lyrebird-*.gem
19
- env:
20
- GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
data/Rakefile DELETED
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/gem_tasks"
4
- require "rake/testtask"
5
-
6
- Rake::TestTask.new(:test) do |t|
7
- t.libs.push("test")
8
- t.test_files = FileList["test/**/*_test.rb"]
9
- end
10
-
11
- task default: :test
data/lyrebird.gemspec DELETED
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/lyrebird/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "lyrebird"
7
- spec.version = Lyrebird::VERSION
8
- spec.authors = ["Josh"]
9
-
10
- spec.summary = "Mimics SAML Identity Provider (IdP) responses for testing"
11
- spec.homepage = "https://github.com/simplesaml/lyrebird"
12
- spec.license = "MIT"
13
- spec.required_ruby_version = ">= 3.2.0"
14
-
15
- spec.files = `git ls-files -z`.split("\x0")
16
- spec.files.delete("Gemfile")
17
- spec.files.delete(".gitignore")
18
- spec.files.reject! { |f| f.start_with?("bin/") }
19
-
20
- spec.require_paths = ["lib"]
21
-
22
- spec.add_dependency "base64"
23
- spec.add_dependency "nokogiri"
24
- spec.add_dependency "ostruct"
25
-
26
- # ruby-saml's gemspec has a conditional dependency on logger for Ruby >= 3.4,
27
- # but it was evaluated at gem build time (on Ruby < 3.4), so the published gem
28
- # doesn't include it.
29
- spec.add_development_dependency "logger"
30
- spec.add_development_dependency "minitest"
31
- spec.add_development_dependency "rake"
32
- spec.add_development_dependency "ruby-saml"
33
- end
data/sig/lyrebird.rbs DELETED
@@ -1,4 +0,0 @@
1
- module Lyrebird
2
- VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
- end