lib/hexapdf/document/signatures.rb in hexapdf-0.26.2 vs lib/hexapdf/document/signatures.rb in hexapdf-0.27.0
- old
+ new
@@ -33,31 +33,73 @@
# If the GNU Affero General Public License doesn't fit your need,
# commercial licenses are available at <https://gettalong.at/hexapdf/>.
#++
require 'openssl'
+require 'net/http'
require 'hexapdf/error'
+require 'stringio'
module HexaPDF
class Document
# This class provides methods for interacting with digital signatures of a PDF file.
class Signatures
- # This is the default signing handler which provides the ability to sign a document with a
- # provided certificate using the adb.pkcs7.detached algorithm.
+ # This is the default signing handler which provides the ability to sign a document with the
+ # adbe.pkcs7.detached or ETSI.CAdES.detached algorithms. It is registered under the :default
+ # name.
#
+ # == Usage
+ #
+ # The signing handler is used by default by all methods that need a signing handler. Therefore
+ # it is usually only necessary to provide the actual attribute values.
+ #
+ # This handler provides two ways to create the PKCS#7 signed-data structure required by
+ # Signatures#add:
+ #
+ # * By providing the signing certificate together with the signing key and the certificate
+ # chain. This way HexaPDF itself does the signing. It is the preferred way if all the needed
+ # information is available.
+ #
+ # Assign the respective data to the #certificate, #key and #certificate_chain attributes.
+ #
+ # * By using an external signing mechanism. Here the actual signing happens "outside" of
+ # HexaPDF, for example, in custom code or even asynchronously. This is needed in case the
+ # signing certificate plus key are not directly available but only an interface to them
+ # (e.g. when dealing with a HSM).
+ #
+ # Assign a callable object to #external_signing. If the signing process needs to be
+ # asynchronous, make sure to set the #signature_size appropriately, return an empty string
+ # during signing and later use Signatures.embed_signature to embed the actual signature.
+ #
# Additional functionality:
#
# * Optionally setting the reason, location and contact information.
# * Making the signature a certification signature by applying the DocMDP transform method.
#
+ # Example:
+ #
+ # # Signing using certificate + key
+ # document.sign("output.pdf", certificate: my_cert, key: my_key,
+ # certificate_chain: my_chain)
+ #
+ # # Signing using an external mechanism:
+ # signing_proc = lambda do |io, byte_range|
+ # io.pos = byte_range[0]
+ # data = io.read(byte_range[1])
+ # io.pos = byte_range[2]
+ # data << io.read(byte_range[3])
+ # signing_service.pkcs7_sign(data)
+ # end
+ # document.sign("output.pdf", signature_size: 10_000, external_signing: signing_proc)
+ #
# == Implementing a Signing Handler
#
# This class also serves as an example on how to create a custom handler: The public methods
- # #filter_name, #sub_filter_name, #signature_size, #finalize_objects and #sign are used by the
- # digital signature algorithm.
+ # #signature_size, #finalize_objects and #sign are used by the digital signature algorithm.
+ # See their descriptions for details.
#
# Once a custom signing handler has been created, it can be registered under the
# 'signature.signing_handler' configuration option for easy use. It has to take keyword
# arguments in its initialize method to be compatible with the Signatures#handler method.
class DefaultHandler
@@ -70,39 +112,52 @@
# The certificate chain that should be embedded in the PDF; normally contains all
# certificates up to the root certificate.
attr_accessor :certificate_chain
+ # A callable object fulfilling the same role as the #sign method that is used instead of the
+ # default mechanism for signing.
+ #
+ # If this attribute is set, the attributes #certificate, #key and #certificate_chain are not
+ # used.
+ attr_accessor :external_signing
+
# The reason for signing. If used, will be set on the signature object.
attr_accessor :reason
# The signing location. If used, will be set on the signature object.
attr_accessor :location
# The contact information. If used, will be set on the signature object.
attr_accessor :contact_info
+ # The size of the serialized signature that should be reserved.
+ #
+ # If this attribute has not been set, an empty string will be signed using #sign to
+ # determine the signature size.
+ #
+ # The size needs to be at least as big as the final signature, otherwise signing results in
+ # an error.
+ attr_writer :signature_size
+
+ # The type of signature to be written (i.e. the value of the /SubFilter key).
+ #
+ # The value can either be :adobe (the default; uses a detached PKCS7 signature) or :etsi
+ # (uses an ETSI CAdES compatible signature).
+ attr_accessor :signature_type
+
# The DocMDP permissions that should be set on the document.
#
# See #doc_mdp_permissions=
attr_reader :doc_mdp_permissions
# Creates a new DefaultHandler with the given attributes.
def initialize(**arguments)
+ @signature_size = nil
arguments.each {|name, value| send("#{name}=", value) }
end
- # Returns the name to be set on the /Filter key when using this signing handler.
- def filter_name
- :'Adobe.PPKLite'
- end
-
- # Returns the name to be set on the /SubFilter key when using this signing handler.
- def sub_filter_name
- :'adbe.pkcs7.detached'
- end
-
# Sets the DocMDP permissions that should be applied to the document.
#
# Valid values for +permissions+ are:
#
# +nil+::
@@ -126,17 +181,21 @@
else
raise ArgumentError, "Invalid permissions value '#{permissions.inspect}'"
end
end
- # Returns the size of the signature that would be created.
+ # Returns the size of the serialized signature that should be reserved.
+ #
+ # If a custom size is set using #signature_size=, it used. Otherwise the size is determined
+ # by using #sign to sign an empty string.
def signature_size
- sign("").size
+ @signature_size || sign(StringIO.new, [0, 0, 0, 0]).size
end
# Finalizes the signature field as well as the signature dictionary before writing.
def finalize_objects(_signature_field, signature)
+ signature[:SubFilter] = :'ETSI.CAdES.detached' if signature_type == :etsi
signature[:Reason] = reason if reason
signature[:Location] = location if location
signature[:ContactInfo] = contact_info if contact_info
if doc_mdp_permissions
@@ -151,34 +210,193 @@
(doc.catalog[:Perms] ||= {})[:DocMDP] = signature
end
end
# Returns the DER serialized OpenSSL::PKCS7 structure containing the signature for the given
- # data.
- def sign(data)
- OpenSSL::PKCS7.sign(@certificate, @key, data, @certificate_chain,
- OpenSSL::PKCS7::DETACHED | OpenSSL::PKCS7::BINARY).to_der
+ # IO byte ranges.
+ #
+ # The +byte_range+ argument is an array containing four numbers [offset1, length1, offset2,
+ # length2]. The offset numbers are byte positions in the +io+ argument and the to-be-signed
+ # data can be determined by reading length bytes at the offsets.
+ def sign(io, byte_range)
+ if external_signing
+ external_signing.call(io, byte_range)
+ else
+ io.pos = byte_range[0]
+ data = io.read(byte_range[1])
+ io.pos = byte_range[2]
+ data << io.read(byte_range[3])
+ OpenSSL::PKCS7.sign(@certificate, @key, data, @certificate_chain,
+ OpenSSL::PKCS7::DETACHED | OpenSSL::PKCS7::BINARY).to_der
+ end
end
end
+ # This is a signing handler for adding a timestamp signature (a PDF2.0 feature) to a PDF
+ # document. It is registered under the :timestamp name.
+ #
+ # The timestamp is provided by a timestamp authority and establishes the document contents at
+ # the time indicated in the timestamp. Timestamping a PDF document is usually done in context
+ # of long term validation but can also be done standalone.
+ #
+ # == Usage
+ #
+ # It is necessary to provide at least the URL of the timestamp authority server (TSA) via
+ # #tsa_url, everything else is optional and uses default values. The TSA server must not use
+ # authentication to be usable.
+ #
+ # Example:
+ #
+ # document.sign("output.pdf", handler: :timestamp, tsa_url: 'https://freetsa.org/tsr')
+ class TimestampHandler
+
+ # The URL of the timestamp authority server.
+ #
+ # This value is required.
+ attr_accessor :tsa_url
+
+ # The hash algorithm to use for timestamping. Defaults to SHA512.
+ attr_accessor :tsa_hash_algorithm
+
+ # The policy OID to use for timestamping. Defaults to +nil+.
+ attr_accessor :tsa_policy_id
+
+ # The size of the serialized signature that should be reserved.
+ #
+ # If this attribute has not been set, an empty string will be signed using #sign to
+ # determine the signature size which will contact the TSA server
+ #
+ # The size needs to be at least as big as the final signature, otherwise signing results in
+ # an error.
+ attr_writer :signature_size
+
+ # The reason for timestamping. If used, will be set on the signature object.
+ attr_accessor :reason
+
+ # The timestamping location. If used, will be set on the signature object.
+ attr_accessor :location
+
+ # The contact information. If used, will be set on the signature object.
+ attr_accessor :contact_info
+
+ # Creates a new TimestampHandler with the given attributes.
+ def initialize(**arguments)
+ @signature_size = nil
+ arguments.each {|name, value| send("#{name}=", value) }
+ end
+
+ # Returns the size of the serialized signature that should be reserved.
+ def signature_size
+ @signature_size || (sign(StringIO.new, [0, 0, 0, 0]).size * 1.5).to_i
+ end
+
+ # Finalizes the signature field as well as the signature dictionary before writing.
+ def finalize_objects(_signature_field, signature)
+ signature.document.version = '2.0'
+ signature[:Type] = :DocTimeStamp
+ signature[:SubFilter] = :'ETSI.RFC3161'
+ signature[:Reason] = reason if reason
+ signature[:Location] = location if location
+ signature[:ContactInfo] = contact_info if contact_info
+ end
+
+ # Returns the DER serialized OpenSSL::PKCS7 structure containing the timestamp token for the
+ # given IO byte ranges.
+ def sign(io, byte_range)
+ hash_algorithm = tsa_hash_algorithm || 'SHA512'
+ digest = OpenSSL::Digest.new(hash_algorithm)
+ io.pos = byte_range[0]
+ digest << io.read(byte_range[1])
+ io.pos = byte_range[2]
+ digest << io.read(byte_range[3])
+
+ req = OpenSSL::Timestamp::Request.new
+ req.algorithm = hash_algorithm
+ req.message_imprint = digest.digest
+ req.policy_id = tsa_policy_id if tsa_policy_id
+
+ http_response = Net::HTTP.post(URI(tsa_url), req.to_der,
+ 'content-type' => 'application/timestamp-query')
+ if http_response.kind_of?(Net::HTTPOK)
+ response = OpenSSL::Timestamp::Response.new(http_response.body)
+ if response.status == 0
+ response.token.to_der
+ else
+ raise HexaPDF::Error, "Timestamp token could not be created: #{response.failure_info}"
+ end
+ else
+ raise HexaPDF::Error, "Invalid TSA server response: #{http_response.body}"
+ end
+ end
+
+ end
+
+ # Embeds the given +signature+ into the /Contents value of the newest signature dictionary of
+ # the PDF document given by the +io+ argument.
+ #
+ # This functionality can be used together with the support for external signing (see
+ # DefaultHandler and DefaultHandler#external_signing) to implement asynchronous signing.
+ #
+ # Note: This will, most probably, only work on documents prepared for external signing by
+ # HexaPDF and not by other libraries.
+ def self.embed_signature(io, signature)
+ doc = HexaPDF::Document.new(io: io)
+ signature_dict = doc.signatures.find {|sig| doc.revisions.current.object(sig) == sig }
+ signature_dict_offset, signature_dict_length = locate_signature_dict(
+ doc.revisions.current.xref_section,
+ doc.revisions.parser.startxref_offset,
+ signature_dict.oid
+ )
+ io.pos = signature_dict_offset
+ signature_data = io.read(signature_dict_length)
+ replace_signature_contents(signature_data, signature)
+ io.pos = signature_dict_offset
+ io.write(signature_data)
+ end
+
+ # Uses the information in the given cross-reference section as well as the byte offset of the
+ # cross-reference section to calculate the offset and length of the signature dictionary with
+ # the given object id.
+ def self.locate_signature_dict(xref_section, start_xref_position, signature_oid)
+ data = xref_section.map {|oid, _gen, entry| [entry.pos, oid] if entry.in_use? }.compact.sort <<
+ [start_xref_position, nil]
+ index = data.index {|_pos, oid| oid == signature_oid }
+ [data[index][0], data[index + 1][0] - data[index][0]]
+ end
+
+ # Replaces the value of the /Contents key in the serialized +signature_data+ with the value of
+ # +contents+.
+ def self.replace_signature_contents(signature_data, contents)
+ signature_data.sub!(/Contents(?:\(.*?\)|<.*?>)/) do |match|
+ length = match.size
+ result = "Contents<#{contents.unpack1('H*')}"
+ if length < result.size
+ raise HexaPDF::Error, "The reserved space for the signature was too small " \
+ "(#{(length - 10) / 2} vs #{(result.size - 10) / 2}) - use the handlers " \
+ "#signature_size method to increase the reserved space"
+ end
+ "#{result.ljust(length - 1, '0')}>"
+ end
+ end
+
include Enumerable
# Creates a new Signatures object for the given PDF document.
def initialize(document)
@document = document
end
- # Creates a signing handler with the given options and returns it.
+ # Creates a signing handler with the given attributes and returns it.
#
# A signing handler name is mapped to a class via the 'signature.signing_handler'
# configuration option. The default signing handler is DefaultHandler.
- def handler(name: :default, **options)
+ def handler(name: :default, **attributes)
handler = @document.config.constantize('signature.signing_handler', name) do
raise HexaPDF::Error, "No signing handler named '#{name}' is available"
end
- handler.new(**options)
+ handler.new(**attributes)
end
# Adds a signature to the document and returns the corresponding signature object.
#
# This method will add a new signature to the document and write the updated document to the
@@ -207,12 +425,19 @@
# The signing handler that provides the necessary methods for signing and adjusting the
# signature and signature field objects to one's liking, see #handler and DefaultHandler.
#
# +write_options+::
# The key-value pairs of this hash will be passed on to the HexaPDF::Document#write
- # command. Note that +incremental+ will be automatically set if signing an already
- # existing file.
+ # method. Note that +incremental+ will be automatically set to ensure proper behaviour.
+ #
+ # The used signature object will have the following default values set:
+ #
+ # /Filter:: /Adobe.PPKLite
+ # /SubFilter:: /adbe.pkcs7.detached
+ # /M:: The current time.
+ #
+ # These values can be overridden in the #finalize_objects method of the signature handler.
def add(file_or_io, handler, signature: nil, write_options: {})
if signature && signature.type != :Sig
signature_field = signature
signature = signature_field.field_value
end
@@ -230,41 +455,38 @@
if signature_field.each_widget.to_a.empty?
signature_field.create_widget(@document.pages[0], Rect: [0, 0, 0, 0])
end
# Prepare signature object
- signature[:Filter] = handler.filter_name
- signature[:SubFilter] = handler.sub_filter_name
+ signature[:Filter] = :'Adobe.PPKLite'
+ signature[:SubFilter] = :'adbe.pkcs7.detached'
+ signature[:M] = Time.now
+ handler.finalize_objects(signature_field, signature)
signature[:ByteRange] = [0, 1_000_000_000_000, 1_000_000_000_000, 1_000_000_000_000]
signature[:Contents] = '00' * handler.signature_size # twice the size due to hex encoding
- signature[:M] = Time.now
io = if file_or_io.kind_of?(String)
File.open(file_or_io, 'wb+')
else
file_or_io
end
# Save the current state so that we can determine the correct /ByteRange value and set the
# values
- handler.finalize_objects(signature_field, signature)
- start_xref_position, section = @document.write(io, incremental: true, **write_options)
- data = section.map {|oid, _gen, entry| [entry.pos, oid] if entry.in_use? }.compact.sort <<
- [start_xref_position, nil]
- index = data.index {|_pos, oid| oid == signature.oid }
- signature_offset = data[index][0]
- signature_length = data[index + 1][0] - data[index][0]
+ start_xref, section = @document.write(io, incremental: true, **write_options)
+ signature_offset, signature_length = self.class.locate_signature_dict(section, start_xref,
+ signature.oid)
io.pos = signature_offset
signature_data = io.read(signature_length)
- io.rewind
- file_data = io.read
+ io.seek(0, IO::SEEK_END)
+ file_size = io.pos
# Calculate the offsets for the /ByteRange
contents_offset = signature_offset + signature_data.index('Contents(') + 8
offset2 = contents_offset + signature[:Contents].size + 2 # +2 because of the needed < and >
- length2 = file_data.size - offset2
+ length2 = file_size - offset2
signature[:ByteRange] = [0, contents_offset, offset2, length2]
# Set the correct /ByteRange value
signature_data.sub!(/ByteRange\[0 1000000000000 1000000000000 1000000000000\]/) do |match|
length = match.size
@@ -272,20 +494,15 @@
result.ljust(length)
end
# Now everything besides the /Contents value is correct, so we can read the contents for
# signing
- file_data[signature_offset, signature_length] = signature_data
- signed_contents = file_data[0, contents_offset] << file_data[offset2, length2]
- signature[:Contents] = handler.sign(signed_contents)
+ io.pos = signature_offset
+ io.write(signature_data)
+ signature[:Contents] = handler.sign(io, signature[:ByteRange].value)
- # Set the correct /Contents value as hexstring
- signature_data.sub!(/Contents\(0+\)/) do |match|
- length = match.size
- result = "Contents<#{signature[:Contents].unpack1('H*')}"
- "#{result.ljust(length - 1, '0')}>"
- end
-
+ # And now replace the /Contents value
+ self.class.replace_signature_contents(signature_data, signature[:Contents])
io.pos = signature_offset
io.write(signature_data)
signature
ensure