rubrik 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 81d2c2fdaaf16157493e2d51d65e7b96695b6c3392456903f312e3809745405c
4
+ data.tar.gz: dc1d3640cca46b468417fc15065c621ccc4187f74687fba7431eabac3088a00a
5
+ SHA512:
6
+ metadata.gz: cfa4044ac36e578939f5759b98a1e3f4ff87794dc66f7613f204097f27b0b7640d41353f728c5e5273ae6994310a6175a5df99c7a3685566342df359ae272d8f
7
+ data.tar.gz: 917ed93c196bb299da3e726fe09b4e534c8c333471a16a8c3ef672cc3dc2ef91971c6649c4770360df99e4f70c8a0037e563b7510a29b67f1f4764fa10d8d7c5
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2022 Tomás Coêlho
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # Rubrik
2
+
3
+ Rubrik is a complete and simple digital signature library that implements the PAdES standard (PDF Advanced Electronic
4
+ Signatures) in pure Ruby. It conforms with PKCS#7 and **will be** compatible with Brazil's AD-RB, AD-RT and EU's B-B
5
+ and B-T profiles.
6
+
7
+ ## Implementation Status
8
+
9
+ This gem is under development and may be subjected to breaking changes.
10
+
11
+ ### PDF Features
12
+ - [x] Modify PDFs with incremental updates (doesn't modify the documents, only append signature appearence)
13
+ - [] Signature appearence (stamp)
14
+ - [] External (offline) signatures
15
+
16
+ ### Signature Profiles
17
+ - [x] CMS (PKCS#7)
18
+ - [] PAdES B-B (conforms with PAdES-E-BES)
19
+ - [] PAdES B-T (conforms with PAdES-E-BES)
20
+ - [] PAdES AD-RB
21
+ - [] PAdES AD-RT
22
+
23
+ ## Installation
24
+
25
+ Install the gem and add to the application's Gemfile by executing:
26
+
27
+ $ bundle add rubrik
28
+
29
+ If bundler is not being used to manage dependencies, install the gem by executing:
30
+
31
+ $ gem install rubrik
32
+
33
+ ## Usage
34
+
35
+ With the gem loaded, run the following to sign an document:
36
+
37
+ ```ruby
38
+ # The input and output can be of types `File`, `Tempfile` or `StringIO`.
39
+ input_pdf = File.open("example.pdf", "rb")
40
+ output_pdf = File.open("signed_example.pdf", "wb")
41
+
42
+ # Load Certificate(s)
43
+ certificate = File.open("example_cert.pem", "rb")
44
+ private_key = OpenSSL::PKey::RSA.new(certificate, "")
45
+ certificate.rewind
46
+ public_key = OpenSSL::X509::Certificate.new(certificate)
47
+ certificate.close
48
+
49
+ # Will write the signed document to `output_pdf`
50
+ Rubrik::Sign.call(input_pdf, output_pdf, private_key:, public_key:, certificate_chain: [])
51
+
52
+ # Don't forget to close the files
53
+ input_pdf.close
54
+ output_pdf.close
55
+ ```
56
+ Multiple signatures on a single document can be achieved by calling `Rubrik::Sign` repeatedly using the last signature
57
+ output as input for the next signature. A better API for this use case may be developed.
58
+
59
+
60
+ ## Development
61
+
62
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
63
+
64
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
65
+
66
+ ## Contributing
67
+
68
+ Bug reports and pull requests are welcome on GitHub at https://github.com/tomascco/rubrik. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/tomascco/rubrik/blob/main/CODE_OF_CONDUCT.md).
69
+
70
+ ## License
71
+
72
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
73
+
74
+ ## Code of Conduct
75
+
76
+ Everyone interacting in the rubrik project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/tomascco/rubrik/blob/main/CODE_OF_CONDUCT.md).
@@ -0,0 +1,106 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rubrik
5
+ class Document
6
+ module Increment
7
+ include Kernel
8
+
9
+ extend T::Sig
10
+ extend self
11
+
12
+ sig {params(document: Rubrik::Document, io: T.any(File, Tempfile, StringIO)).returns(T.any(File, Tempfile, StringIO))}
13
+ def call(document, io:)
14
+ document.io.rewind
15
+ IO.copy_stream(T.unsafe(document.io), T.unsafe(io))
16
+
17
+ io << "\n"
18
+ new_xref = Array.new
19
+
20
+ document.modified_objects.each do |object|
21
+ integer_id = T.let(object[:id].to_i, Integer)
22
+ new_xref << {id: integer_id, offset: io.pos}
23
+
24
+ io << "#{integer_id} 0 obj\n" "#{serialize(object[:value])}\n" "endobj\n\n"
25
+ end
26
+
27
+ updated_trailer = document.objects.trailer.dup
28
+ updated_trailer[:Prev] = last_xref_pos(document)
29
+ updated_trailer[:Size] = document.last_object_id + 1
30
+
31
+ new_xref_pos = io.pos
32
+
33
+ new_xref_subsections = new_xref
34
+ .sort_by { _1[:id] }
35
+ .chunk_while { _1[:id] + 1 == _2[:id] }
36
+
37
+ io << "xref\n"
38
+ io << "0 1\n"
39
+ io << "0000000000 65535 f\n"
40
+
41
+ new_xref_subsections.each do |subsection|
42
+ starting_id = subsection.first[:id]
43
+ length = subsection.length
44
+
45
+ io << "#{starting_id} #{length}\n"
46
+ subsection.each { |entry| io << "#{format("%010d", entry[:offset])} 00000 n\n" }
47
+ end
48
+
49
+ io << "trailer\n"
50
+ io << "#{serialize(updated_trailer)}\n"
51
+ io << "startxref\n"
52
+ io << "#{new_xref_pos.to_s}\n"
53
+ io << "%%EOF\n"
54
+
55
+ io.rewind
56
+ io
57
+ end
58
+
59
+ private
60
+
61
+ sig {params(obj: T.untyped).returns(String)}
62
+ def serialize(obj)
63
+ case obj
64
+ when Hash
65
+ serialized_objs = obj.flatten.map { |e| serialize(e) }
66
+ "<<#{serialized_objs.join(" ")}>>"
67
+ when Symbol
68
+ "/#{obj}"
69
+ when Array
70
+ serialized_objs = obj.map { |e| serialize(e) }
71
+ "[#{serialized_objs.join(" ")}]"
72
+ when PDF::Reader::Reference
73
+ "#{obj.id} #{obj.gen} R"
74
+ when String
75
+ "(#{obj})"
76
+ when TrueClass
77
+ "true"
78
+ when FalseClass
79
+ "false"
80
+ when Document::CONTENTS_PLACEHOLDER
81
+ "<#{"0" * Document::SIGNATURE_SIZE}>"
82
+ when Document::BYTE_RANGE_PLACEHOLDER
83
+ "[0 0000000000 0000000000 0000000000]"
84
+ when Numeric
85
+ obj.to_s
86
+ when NilClass
87
+ "null"
88
+ when PDF::Reader::Stream
89
+ <<~OBJECT.chomp
90
+ #{serialize(obj.hash)}
91
+ stream
92
+ #{obj.data}
93
+ endstream
94
+ OBJECT
95
+ else
96
+ raise NotImplementedError.new("Don't know how to serialize #{obj}")
97
+ end
98
+ end
99
+
100
+ sig {params(document: Rubrik::Document).returns(Integer)}
101
+ def last_xref_pos(document)
102
+ PDF::Reader::Buffer.new(document.io, seek: 0).find_first_xref_offset
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,127 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "securerandom"
5
+
6
+ module Rubrik
7
+ class Document
8
+ extend T::Sig
9
+
10
+ CONTENTS_PLACEHOLDER = Object.new.freeze
11
+ BYTE_RANGE_PLACEHOLDER = Object.new.freeze
12
+ SIGNATURE_SIZE = 8_192
13
+
14
+ sig {returns(T.any(File, Tempfile, StringIO))}
15
+ attr_reader :io
16
+
17
+ sig {returns(PDF::Reader::ObjectHash)}
18
+ attr_reader :objects
19
+
20
+ sig {returns(T::Array[{id: PDF::Reader::Reference, value: T.untyped}])}
21
+ attr_reader :modified_objects
22
+
23
+ sig {returns(PDF::Reader::Reference)}
24
+ attr_reader :interactive_form_id
25
+
26
+ sig {returns(Integer)}
27
+ attr_reader :last_object_id
28
+
29
+ sig {params(input: T.any(File, Tempfile, StringIO)).void}
30
+ def initialize(input)
31
+ self.io = input
32
+ self.objects = PDF::Reader::ObjectHash.new(input)
33
+ self.last_object_id = objects.size
34
+ self.modified_objects = []
35
+
36
+ fetch_or_create_interactive_form!
37
+ end
38
+
39
+ sig {void}
40
+ def add_signature_field
41
+ # create signature value dictionary
42
+ signature_value_id = assign_new_object_id!
43
+ modified_objects << {
44
+ id: signature_value_id,
45
+ value: {
46
+ Type: :Sig,
47
+ Filter: :"Adobe.PPKLite",
48
+ SubFilter: :"adbe.pkcs7.detached",
49
+ Contents: CONTENTS_PLACEHOLDER,
50
+ ByteRange: BYTE_RANGE_PLACEHOLDER
51
+ }
52
+ }
53
+
54
+ first_page_reference = T.must(objects.page_references[0])
55
+
56
+ # create signature field
57
+ signature_field_id = assign_new_object_id!
58
+ modified_objects << {
59
+ id: signature_field_id,
60
+ value: {
61
+ T: "Signature-#{SecureRandom.hex(2)}",
62
+ FT: :Sig,
63
+ V: signature_value_id,
64
+ Type: :Annot,
65
+ Subtype: :Widget,
66
+ Rect: [20, 20, 120, 120],
67
+ F: 4,
68
+ P: first_page_reference
69
+ }
70
+ }
71
+
72
+ modified_page = objects.fetch(first_page_reference).dup
73
+ (modified_page[:Annots] ||= []) << signature_field_id
74
+
75
+ modified_objects << {id: first_page_reference, value: modified_page}
76
+
77
+ (interactive_form[:Fields] ||= []) << signature_field_id
78
+ end
79
+
80
+ private
81
+
82
+ sig {returns(T::Hash[Symbol, T.untyped])}
83
+ def interactive_form
84
+ T.must(modified_objects.first).fetch(:value)
85
+ end
86
+
87
+ sig {void}
88
+ def fetch_or_create_interactive_form!
89
+ root_ref = objects.trailer[:Root]
90
+ root = T.let(objects.fetch(root_ref), Hash)
91
+
92
+ if root.key?(:AcroForm)
93
+ form_id = root[:AcroForm]
94
+
95
+ modified_objects << {id: form_id, value: objects.fetch(form_id).dup}
96
+ else
97
+ interactive_form_id = assign_new_object_id!
98
+ modified_objects << {id: interactive_form_id, value: {Fields: []}}
99
+
100
+ # we also need to create a new version of the document catalog to include the new form ref
101
+ updated_root = root.dup
102
+ updated_root[:AcroForm] = interactive_form_id
103
+
104
+ modified_objects << {id: root_ref, value: updated_root}
105
+ end
106
+
107
+ interactive_form[:SigFlags] = 3 # dont modify, append only
108
+ end
109
+
110
+ sig {returns(PDF::Reader::Reference)}
111
+ def assign_new_object_id!
112
+ PDF::Reader::Reference.new(self.last_object_id += 1, 0)
113
+ end
114
+
115
+ sig {params(io: T.any(File, Tempfile, StringIO)).returns(T.any(File, Tempfile, StringIO))}
116
+ attr_writer :io
117
+
118
+ sig {params(objects: PDF::Reader::ObjectHash).returns(PDF::Reader::ObjectHash)}
119
+ attr_writer :objects
120
+
121
+ sig {params(modified_objects: T::Array[{id: PDF::Reader::Reference, value: T.untyped}]).returns(T::Array[{id: PDF::Reader::Reference, value: T.untyped}])}
122
+ attr_writer :modified_objects
123
+
124
+ sig {params(last_object_id: Integer).returns(Integer)}
125
+ attr_writer :last_object_id
126
+ end
127
+ end
@@ -0,0 +1,72 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "openssl"
5
+
6
+ module Rubrik
7
+ module FillSignature
8
+ extend T::Sig
9
+ extend self
10
+ include Kernel
11
+
12
+ sig {params(
13
+ io: T.any(File, StringIO, Tempfile),
14
+ signature_value_ref: PDF::Reader::Reference,
15
+ private_key: OpenSSL::PKey::RSA,
16
+ public_key: OpenSSL::X509::Certificate,
17
+ certificate_chain: T::Array[OpenSSL::X509::Certificate])
18
+ .returns(T.any(File, StringIO, Tempfile))}
19
+
20
+ FIRST_OFFSET = 0
21
+
22
+ def call(io, signature_value_ref:, private_key:, public_key:, certificate_chain: [])
23
+ io.rewind
24
+
25
+ signature_value_offset = PDF::Reader::XRef.new(io)[signature_value_ref]
26
+
27
+ io.pos = signature_value_offset
28
+ io.gets("/Contents")
29
+ io.gets("<")
30
+
31
+ first_length = io.pos - 1
32
+ # we need to double the SIGNATURE_SIZE because the hex encoding double the data size
33
+ # we also need to sum +2 to account for "<" and ">" of the hex string
34
+ second_offset = first_length + Document::SIGNATURE_SIZE + 2
35
+ second_length = io.size - second_offset
36
+
37
+ byte_range_array = [FIRST_OFFSET, first_length, second_offset, second_length]
38
+
39
+ io.pos = signature_value_offset
40
+
41
+ io.gets("/ByteRange")
42
+ byte_range_start = io.pos - "/ByteRange".size
43
+
44
+ io.gets("]")
45
+ byte_range_end = io.pos
46
+
47
+ byte_range_size = byte_range_end - byte_range_start + 1
48
+ actual_byte_range = " /ByteRange [#{byte_range_array.join(" ")}]".ljust(byte_range_size, " ")
49
+
50
+ io.seek(-byte_range_size, IO::SEEK_CUR)
51
+ io.write(actual_byte_range)
52
+
53
+ io.pos = FIRST_OFFSET
54
+ data_to_sign = T.must(io.read(first_length))
55
+
56
+ io.pos = second_offset
57
+ data_to_sign += T.must(io.read(second_length))
58
+
59
+ signature = OpenSSL::PKCS7.sign(public_key, private_key, data_to_sign, certificate_chain,
60
+ OpenSSL::PKCS7::DETACHED | OpenSSL::PKCS7::BINARY).to_der
61
+ hex_signature = T.let(signature, String).unpack1("H*")
62
+
63
+ padded_contents_field = "<#{hex_signature.ljust(Document::SIGNATURE_SIZE, "0")}>"
64
+
65
+ io.pos = first_length
66
+ io.write(padded_contents_field)
67
+
68
+ io.rewind
69
+ io
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,28 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rubrik
5
+ module Sign
6
+ extend T::Sig
7
+
8
+ sig {params(
9
+ input: T.any(File, Tempfile, StringIO),
10
+ output: T.any(File, Tempfile, StringIO),
11
+ private_key: OpenSSL::PKey::RSA,
12
+ public_key: OpenSSL::X509::Certificate,
13
+ certificate_chain: T::Array[OpenSSL::X509::Certificate])
14
+ .void}
15
+ def self.call(input, output, private_key:, public_key:, certificate_chain: [])
16
+ document = Rubrik::Document.new(input)
17
+
18
+ document.add_signature_field
19
+
20
+ Document::Increment.call(document, io: output)
21
+
22
+ signature_value = T.must(document.modified_objects.find { _1.dig(:value, :Type) == :Sig })
23
+
24
+ signature_value_ref = T.let(signature_value[:id], PDF::Reader::Reference)
25
+ FillSignature.call(output, signature_value_ref:, private_key:, public_key:, certificate_chain:)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubrik
4
+ VERSION = "0.1.0"
5
+ end
data/lib/rubrik.rb ADDED
@@ -0,0 +1,18 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler"
5
+ Bundler.setup(:default)
6
+
7
+ require "sorbet-runtime"
8
+ require "pdf-reader"
9
+ require "irb"
10
+
11
+ module Rubrik
12
+ class Error < StandardError; end
13
+ end
14
+
15
+ require_relative "rubrik/document"
16
+ require_relative "rubrik/document/increment"
17
+ require_relative "rubrik/fill_signature"
18
+ require_relative "rubrik/sign"
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rubrik
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tomás Coêlho
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-10-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: pdf-reader
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.10'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.10'
27
+ - !ruby/object:Gem::Dependency
28
+ name: sorbet-runtime
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.5'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.5'
41
+ description: Sign PDFs digitally in pure Ruby
42
+ email:
43
+ - tomascoelho6@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - LICENSE.txt
49
+ - README.md
50
+ - lib/rubrik.rb
51
+ - lib/rubrik/document.rb
52
+ - lib/rubrik/document/increment.rb
53
+ - lib/rubrik/fill_signature.rb
54
+ - lib/rubrik/sign.rb
55
+ - lib/rubrik/version.rb
56
+ homepage: https://github.com/tomascco/rubrik
57
+ licenses:
58
+ - MIT
59
+ metadata:
60
+ homepage_uri: https://github.com/tomascco/rubrik
61
+ source_code_uri: https://github.com/tomascco/rubrik
62
+ changelog_uri: https://github.com/tomascco/rubrik/blob/main/CHANGELOG.md
63
+ post_install_message:
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: 3.1.0
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ requirements: []
78
+ rubygems_version: 3.4.20
79
+ signing_key:
80
+ specification_version: 4
81
+ summary: Sign PDFs digitally in pure Ruby
82
+ test_files: []