- <% files.zip(pdfs).each do |file, pdf| %>
+ <% files.zip(signatures).each do |file, file_signatures| %>
- <% if pdf.signatures.to_a.size == 0 %>
+ <% if file_signatures.size == 0 %>
<%= file.original_filename %>
@@ -11,42 +11,29 @@
<% else %>
<%= svg_icon('file_text', class: 'w-5 h-5 inline') %>
- <%= file.original_filename %> - <%= pluralize(pdf.signatures.to_a.size, 'Signature') %>
+ <%= file.original_filename %> - <%= pluralize(file_signatures.size, 'Signature') %>
- <% pdf.signatures.to_a.each do |signature| %>
+ <% file_signatures.each do |signature| %>
- <% signature.verify(trusted_certs:).messages.map(&:content).each do |message| %>
+ <% signature.messages.each do |message| %>
- <% if message == 'Signature verification failed' %>
+ <% if message.status == :error %>
<%= svg_icon('x_circle', class: 'w-6 h-6 text-red-500') %>
- <% elsif message == 'Signature valid' %>
+ <% elsif message.status == :success %>
<%= svg_icon('circle_check', class: 'w-6 h-6 text-green-500') %>
+ <% elsif message.status == :warning %>
+ <%= svg_icon('alert_circle', class: 'w-6 h-6 text-yellow-500') %>
<% end %>
- <%= message %>
+ <%= message.text %>
- <% if message == 'Signature valid' %>
-
- <% if trusted_certs.any? { |e| e.public_key.to_der == signature.signature_handler.signer_certificate.public_key.to_der } %>
- <%= svg_icon('circle_check', class: 'w-6 h-6 text-green-500') %>
-
- <%= t('signed_with_trusted_certificate') %>
-
- <% else %>
- <%= svg_icon('x_circle', class: 'w-6 h-6 text-red-500') %>
-
- <%= t('signed_with_external_certificate') %>
-
- <% end %>
-
- <% end %>
<% end %>
<%= svg_icon('user', class: 'w-5 h-5 inline') %>
- <%= signature.signing_reason %>
+ <%= signature.reason %>
<%= svg_icon('calendar', class: 'w-5 h-5 inline') %>
@@ -54,11 +41,11 @@
<%= svg_icon('certificate', class: 'w-5 h-5 inline') %>
- <%= signature.signer_name.force_encoding('UTF-8') %>
+ <%= signature.common_name %>
<%= svg_icon('lock_access', class: 'w-5 h-5 inline') %>
- <%= signature.signature_type %>
+ <%= signature.type %>
<% end %>
diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml
index a14d53aa..830a4731 100644
--- a/config/locales/i18n.yml
+++ b/config/locales/i18n.yml
@@ -596,6 +596,9 @@ en: &en
there_are_no_signatures: There are no signatures
signed_with_trusted_certificate: Signed with trusted certificate
signed_with_external_certificate: Signed with external certificate
+ signature_valid: Signature valid
+ signature_verification_failed: Signature verification failed
+ contains_unsigned_changes_after_the_last_signature: Contains unsigned changes after the last signature
setup_2fa_to_continue: Setup 2FA to continue.
create_a_new_account: Create a new account.
you_are_already_signed_in: You are already signed in.
@@ -1706,6 +1709,9 @@ es: &es
there_are_no_signatures: No hay firmas
signed_with_trusted_certificate: Firmado con certificado de confianza
signed_with_external_certificate: Firmado con certificado externo
+ signature_valid: Firma válida
+ signature_verification_failed: Verificación de firma fallida
+ contains_unsigned_changes_after_the_last_signature: Contiene cambios no firmados después de la última firma
setup_2fa_to_continue: Configura 2FA para continuar.
create_a_new_account: Crear una nueva cuenta.
you_are_already_signed_in: Ya has iniciado sesión.
@@ -2827,6 +2833,9 @@ it: &it
there_are_no_signatures: Non ci sono firme
signed_with_trusted_certificate: Firmato con certificato affidabile
signed_with_external_certificate: Firmato con certificato esterno
+ signature_valid: Firma valida
+ signature_verification_failed: Verifica della firma non riuscita
+ contains_unsigned_changes_after_the_last_signature: Contiene modifiche non firmate dopo l’ultima firma
setup_2fa_to_continue: Configura 2FA per continuare.
create_a_new_account: Crea un nuovo account.
you_are_already_signed_in: "Hai già effettuato l'accesso."
@@ -3934,6 +3943,9 @@ fr: &fr
there_are_no_signatures: Il n’y a pas de signatures
signed_with_trusted_certificate: Signé avec un certificat de confiance
signed_with_external_certificate: Signé avec un certificat externe
+ signature_valid: Signature valide
+ signature_verification_failed: Échec de la vérification de la signature
+ contains_unsigned_changes_after_the_last_signature: Contient des modifications non signées après la dernière signature
setup_2fa_to_continue: Configurez la 2FA pour continuer.
create_a_new_account: Créer un nouveau compte.
you_are_already_signed_in: Vous êtes déjà connecté.
@@ -5051,6 +5063,9 @@ pt: &pt
there_are_no_signatures: Não há assinaturas
signed_with_trusted_certificate: Assinado com certificado confiável
signed_with_external_certificate: Assinado com certificado externo
+ signature_valid: Assinatura válida
+ signature_verification_failed: Falha na verificação da assinatura
+ contains_unsigned_changes_after_the_last_signature: Contém alterações não assinadas após a última assinatura
setup_2fa_to_continue: Configure 2FA para continuar.
create_a_new_account: Criar uma nova conta.
you_are_already_signed_in: Você já está conectado.
@@ -6171,6 +6186,9 @@ de: &de
there_are_no_signatures: Es gibt keine Unterschriften
signed_with_trusted_certificate: Signiert mit vertrauenswürdigem Zertifikat
signed_with_external_certificate: Signiert mit externem Zertifikat
+ signature_valid: Signatur gültig
+ signature_verification_failed: Signaturprüfung fehlgeschlagen
+ contains_unsigned_changes_after_the_last_signature: Enthält nicht signierte Änderungen nach der letzten Signatur
setup_2fa_to_continue: Richten Sie 2FA ein, um fortzufahren.
create_a_new_account: Neues Konto erstellen.
you_are_already_signed_in: Sie sind bereits angemeldet.
@@ -7807,6 +7825,9 @@ nl: &nl
there_are_no_signatures: Er zijn geen handtekeningen
signed_with_trusted_certificate: Ondertekend met vertrouwd certificaat
signed_with_external_certificate: Ondertekend met extern certificaat
+ signature_valid: Handtekening geldig
+ signature_verification_failed: Verificatie van handtekening mislukt
+ contains_unsigned_changes_after_the_last_signature: Bevat niet-ondertekende wijzigingen na de laatste handtekening
setup_2fa_to_continue: Stel 2FA in om door te gaan.
create_a_new_account: Maak een nieuw account aan.
you_are_already_signed_in: U bent al aangemeld.
diff --git a/lib/pdfium.rb b/lib/pdfium.rb
index e7c5ec40..a2ae95c0 100644
--- a/lib/pdfium.rb
+++ b/lib/pdfium.rb
@@ -28,6 +28,10 @@ class Pdfium
typedef :pointer, :FPDF_PAGEOBJECT
typedef :pointer, :FPDF_PATHSEGMENT
typedef :pointer, :FPDF_FONT
+ typedef :pointer, :FPDF_SIGNATURE
+ typedef :pointer, :FPDF_ANNOTATION
+ typedef :pointer, :FPDF_BOOKMARK
+ typedef :pointer, :FPDF_DEST
MAX_SIZE = 32_767
@@ -102,6 +106,31 @@ class Pdfium
attach_function :FPDF_CloseDocument, [:FPDF_DOCUMENT], :void
attach_function :FPDF_GetPageCount, [:FPDF_DOCUMENT], :int
attach_function :FPDF_GetLastError, [], :ulong
+ attach_function :FPDF_GetTrailerEnds, %i[FPDF_DOCUMENT pointer ulong], :ulong
+ attach_function :FPDF_DocumentHasValidCrossReferenceTable, [:FPDF_DOCUMENT], :int
+
+ attach_function :FPDFPage_GetAnnotCount, [:FPDF_PAGE], :int
+ attach_function :FPDFPage_GetAnnot, %i[FPDF_PAGE int], :FPDF_ANNOTATION
+ attach_function :FPDFPage_CloseAnnot, [:FPDF_ANNOTATION], :void
+ attach_function :FPDFAnnot_GetSubtype, [:FPDF_ANNOTATION], :int
+ attach_function :FPDFAnnot_GetRect, %i[FPDF_ANNOTATION pointer], :int
+
+ attach_function :FPDFBookmark_GetFirstChild, %i[FPDF_DOCUMENT FPDF_BOOKMARK], :FPDF_BOOKMARK
+ attach_function :FPDFBookmark_GetNextSibling, %i[FPDF_DOCUMENT FPDF_BOOKMARK], :FPDF_BOOKMARK
+ attach_function :FPDFBookmark_GetTitle, %i[FPDF_BOOKMARK pointer ulong], :ulong
+ attach_function :FPDFBookmark_GetDest, %i[FPDF_DOCUMENT FPDF_BOOKMARK], :FPDF_DEST
+ attach_function :FPDFDest_GetDestPageIndex, %i[FPDF_DOCUMENT FPDF_DEST], :int
+ attach_function :FPDFDest_GetLocationInPage,
+ %i[FPDF_DEST pointer pointer pointer pointer pointer pointer], :int
+
+ attach_function :FPDF_GetSignatureCount, [:FPDF_DOCUMENT], :int
+ attach_function :FPDF_GetSignatureObject, %i[FPDF_DOCUMENT int], :FPDF_SIGNATURE
+ attach_function :FPDFSignatureObj_GetContents, %i[FPDF_SIGNATURE pointer ulong], :ulong
+ attach_function :FPDFSignatureObj_GetByteRange, %i[FPDF_SIGNATURE pointer ulong], :ulong
+ attach_function :FPDFSignatureObj_GetSubFilter, %i[FPDF_SIGNATURE pointer ulong], :ulong
+ attach_function :FPDFSignatureObj_GetReason, %i[FPDF_SIGNATURE pointer ulong], :ulong
+ attach_function :FPDFSignatureObj_GetTime, %i[FPDF_SIGNATURE pointer ulong], :ulong
+ attach_function :FPDFSignatureObj_GetDocMDPPermission, [:FPDF_SIGNATURE], :uint
attach_function :FPDF_LoadPage, %i[FPDF_DOCUMENT int], :FPDF_PAGE
attach_function :FPDF_ClosePage, [:FPDF_PAGE], :void
@@ -253,6 +282,13 @@ class Pdfium
FLATTEN_NOTHINGTODO = 2
# rubocop:disable Naming/ClassAndModuleCamelCase
+ class FS_RECTF < FFI::Struct
+ layout :left, :float,
+ :top, :float,
+ :right, :float,
+ :bottom, :float
+ end
+
class FS_MATRIX < FFI::Struct
layout :a, :float,
:b, :float,
@@ -519,6 +555,72 @@ class Pdfium
@pages[page_index] ||= Page.new(self, page_index)
end
+ def bookmarks(parent = nil, seen = Set.new)
+ acc = []
+ bookmark = Pdfium.FPDFBookmark_GetFirstChild(@document_ptr, parent)
+
+ until bookmark.null?
+ break unless seen.add?(bookmark.address)
+
+ acc << [bookmark_title(bookmark), *destination(Pdfium.FPDFBookmark_GetDest(@document_ptr, bookmark))]
+ acc.concat(bookmarks(bookmark, seen))
+
+ bookmark = Pdfium.FPDFBookmark_GetNextSibling(@document_ptr, bookmark)
+ end
+
+ acc
+ end
+
+ def bookmark_title(bookmark)
+ length = Pdfium.FPDFBookmark_GetTitle(bookmark, nil, 0)
+
+ return if length.zero?
+
+ buffer = FFI::MemoryPointer.new(:char, length)
+ Pdfium.FPDFBookmark_GetTitle(bookmark, buffer, length)
+
+ buffer.read_bytes(length).force_encoding('UTF-16LE').encode('UTF-8').delete("\u0000")
+ end
+
+ def destination(dest)
+ return [] if dest.nil? || dest.null?
+
+ flags = Array.new(3) { FFI::MemoryPointer.new(:int) }
+ coords = Array.new(3) { FFI::MemoryPointer.new(:float) }
+ Pdfium.FPDFDest_GetLocationInPage(dest, *flags, *coords)
+
+ [Pdfium.FPDFDest_GetDestPageIndex(@document_ptr, dest),
+ *coords.each_with_index.map { |c, i| c.read_float.round(3) if flags[i].read_int == 1 }]
+ end
+
+ def valid_cross_reference_table?
+ Pdfium.FPDF_DocumentHasValidCrossReferenceTable(@document_ptr) == 1
+ end
+
+ def signature_count
+ @signature_count ||= Pdfium.FPDF_GetSignatureCount(@document_ptr)
+ end
+
+ def signatures
+ @signatures ||= (0...signature_count).map { |index| Signature.new(self, index) }
+ end
+
+ def trailer_ends
+ @trailer_ends ||=
+ begin
+ count = Pdfium.FPDF_GetTrailerEnds(@document_ptr, nil, 0)
+
+ if count.zero?
+ []
+ else
+ buffer = FFI::MemoryPointer.new(:uint, count)
+ Pdfium.FPDF_GetTrailerEnds(@document_ptr, buffer, count)
+
+ buffer.read_array_of_uint(count)
+ end
+ end
+ end
+
def save(io, flags: Pdfium::FPDF_NO_INCREMENTAL)
ensure_not_closed!
@@ -589,7 +691,74 @@ class Pdfium
end
end
+ class Signature
+ attr_reader :document, :index, :signature_ptr
+
+ def initialize(document, index)
+ @document = document
+ @index = index
+ @signature_ptr = Pdfium.FPDF_GetSignatureObject(document.document_ptr, index)
+
+ raise PdfiumError, "Failed to load signature #{index}, pointer is NULL." if @signature_ptr.null?
+ end
+
+ def byte_range
+ @byte_range ||=
+ begin
+ count = Pdfium.FPDFSignatureObj_GetByteRange(signature_ptr, nil, 0)
+ buffer = FFI::MemoryPointer.new(:int, count)
+ Pdfium.FPDFSignatureObj_GetByteRange(signature_ptr, buffer, count)
+
+ buffer.read_array_of_int(count)
+ end
+ end
+
+ def signed_end
+ @signed_end ||= byte_range.last(2).sum
+ end
+
+ def contents
+ @contents ||= read_bytes(:FPDFSignatureObj_GetContents)
+ end
+
+ def sub_filter
+ @sub_filter ||= read_bytes(:FPDFSignatureObj_GetSubFilter).to_s.delete("\u0000")
+ end
+
+ def time
+ @time ||= read_bytes(:FPDFSignatureObj_GetTime).to_s.delete("\u0000")
+ end
+
+ def reason
+ @reason ||=
+ begin
+ bytes = read_bytes(:FPDFSignatureObj_GetReason)
+
+ bytes&.force_encoding('UTF-16LE')&.encode('UTF-8').to_s.delete("\u0000").presence
+ end
+ end
+
+ def doc_mdp_permission
+ @doc_mdp_permission ||= Pdfium.FPDFSignatureObj_GetDocMDPPermission(signature_ptr)
+ end
+
+ private
+
+ def read_bytes(function)
+ length = Pdfium.public_send(function, signature_ptr, nil, 0)
+
+ return if length.zero?
+
+ buffer = FFI::MemoryPointer.new(:char, length)
+ Pdfium.public_send(function, signature_ptr, buffer, length)
+
+ buffer.read_bytes(length)
+ end
+ end
+
class Page
+ RECT_KEYS = %i[left top right bottom].freeze
+
attr_reader :document, :page_index, :page_ptr
def initialize(document, page_index)
@@ -619,6 +788,34 @@ class Pdfium
@height ||= Pdfium.FPDF_GetPageHeightF(@page_ptr)
end
+ def annotations
+ (0...Pdfium.FPDFPage_GetAnnotCount(page_ptr)).filter_map do |index|
+ annotation = Pdfium.FPDFPage_GetAnnot(page_ptr, index)
+
+ next if annotation.null?
+
+ begin
+ rect = Pdfium::FS_RECTF.new
+ Pdfium.FPDFAnnot_GetRect(annotation, rect)
+
+ [Pdfium.FPDFAnnot_GetSubtype(annotation),
+ *RECT_KEYS.map { |key| rect[key].round(3) }]
+ ensure
+ Pdfium.FPDFPage_CloseAnnot(annotation)
+ end
+ end
+ end
+
+ def objects
+ (0...Pdfium.FPDFPage_CountObjects(page_ptr)).map do |index|
+ object = Pdfium.FPDFPage_GetObject(page_ptr, index)
+ bounds = Array.new(4) { FFI::MemoryPointer.new(:float) }
+ Pdfium.FPDFPageObj_GetBounds(object, *bounds)
+
+ [Pdfium.FPDFPageObj_GetType(object), *bounds.map { |b| b.read_float.round(3) }]
+ end
+ end
+
def rotation
@rotation ||= Pdfium.FPDFPage_GetRotation(@page_ptr)
end
diff --git a/lib/verify_pdf_signature.rb b/lib/verify_pdf_signature.rb
new file mode 100644
index 00000000..22717759
--- /dev/null
+++ b/lib/verify_pdf_signature.rb
@@ -0,0 +1,170 @@
+# frozen_string_literal: true
+
+module VerifyPdfSignature
+ COMMON_NAME = 'CN'
+ TIME_FORMAT = '%Y%m%d%H%M%S%z'
+
+ SignatureStruct = Struct.new(:messages, :reason, :signing_time, :common_name, :type)
+ MessageStruct = Struct.new(:text, :status)
+
+ module_function
+
+ def call(io, trusted_certs)
+ Pdfium::Document.open_io(io) do |document|
+ signatures = document.signatures.select { |e| e.byte_range.any?(&:positive?) && e.contents.present? }
+
+ next [] if signatures.blank?
+
+ has_unsigned_changes = unsigned_changes?(document, io)
+
+ signatures.map.with_index do |signature, index|
+ build_signature(signature, io, trusted_certs,
+ has_unsigned_changes && index == signatures.size - 1)
+ end
+ end
+ end
+
+ def build_signature(signature, io, trusted_certs, has_unsigned_changes)
+ pkcs7 = OpenSSL::PKCS7.new(signature.contents)
+ verified = verify_contents(pkcs7, signed_data(io, signature.byte_range), trusted_certs)
+
+ SignatureStruct.new(
+ messages: build_messages(pkcs7, verified, trusted_certs, has_unsigned_changes),
+ reason: signature.reason,
+ signing_time: signing_time(pkcs7, signature),
+ common_name: common_name(pkcs7),
+ type: signature.sub_filter
+ )
+ rescue OpenSSL::PKCS7::PKCS7Error
+ SignatureStruct.new(
+ messages: [MessageStruct.new(text: I18n.t('signature_verification_failed'), status: :error)],
+ reason: signature.reason,
+ signing_time: parse_time(signature.time),
+ type: signature.sub_filter
+ )
+ end
+
+ def build_messages(pkcs7, verified, trusted_certs, has_unsigned_changes)
+ messages =
+ if verified
+ [MessageStruct.new(text: I18n.t('signature_valid'), status: :success),
+ certificate_message(pkcs7, trusted_certs)]
+ else
+ [MessageStruct.new(text: I18n.t('signature_verification_failed'), status: :error)]
+ end
+
+ if has_unsigned_changes
+ messages << MessageStruct.new(text: I18n.t('contains_unsigned_changes_after_the_last_signature'),
+ status: :warning)
+ end
+
+ messages << MessageStruct.new(text: "Certificate chain: #{certificate_chain(pkcs7).join(' -> ')}")
+ end
+
+ def certificate_message(pkcs7, trusted_certs)
+ public_key = signer_certificate(pkcs7)&.public_key&.to_der
+
+ if trusted_certs.any? { |e| e.public_key.to_der == public_key }
+ MessageStruct.new(text: I18n.t('signed_with_trusted_certificate'), status: :success)
+ else
+ MessageStruct.new(text: I18n.t('signed_with_external_certificate'), status: :error)
+ end
+ end
+
+ def verify_contents(pkcs7, signed_data, trusted_certs)
+ return false if digest_algorithms(pkcs7).blank?
+
+ store = OpenSSL::X509::Store.new
+ store.set_default_paths
+ store.purpose = OpenSSL::X509::PURPOSE_SMIME_SIGN
+ store.verify_callback = ->(_success, _context) { true }
+ trusted_certs.each { |cert| store.add_cert(cert) }
+
+ pkcs7.verify(pkcs7.certificates, store, signed_data,
+ OpenSSL::PKCS7::DETACHED | OpenSSL::PKCS7::BINARY)
+ end
+
+ def digest_algorithms(pkcs7)
+ OpenSSL::ASN1.decode(pkcs7.to_der).value[1].value[0].value[1].value
+ end
+
+ def common_name(pkcs7)
+ cert = signer_certificate(pkcs7)
+
+ return if cert.nil?
+
+ cert.subject.to_a.assoc(COMMON_NAME)&.dig(1)
+ end
+
+ def signer_certificate(pkcs7)
+ info = pkcs7.signers.first
+
+ pkcs7.certificates&.find { |cert| cert.issuer == info.issuer && cert.serial == info.serial }
+ end
+
+ def certificate_chain(pkcs7)
+ signer = signer_certificate(pkcs7)
+
+ return [] if signer.nil?
+
+ certs = [signer]
+
+ while (issuer = pkcs7.certificates.find { |cert| cert.subject == certs.last.issuer })
+ break if certs.include?(issuer)
+
+ certs << issuer
+ end
+
+ certs.map { |cert| cert.subject.to_a.assoc(COMMON_NAME)&.dig(1) }
+ end
+
+ def signing_time(pkcs7, signature)
+ cms_signing_time(pkcs7) || parse_time(signature.time)
+ end
+
+ def cms_signing_time(pkcs7)
+ pkcs7.signers.first&.signed_time
+ rescue StandardError
+ nil
+ end
+
+ def parse_time(value)
+ return if value.blank?
+
+ time = value.delete("'").delete_prefix('D:')
+ offset = time[14..].to_s
+
+ Time.strptime("#{time.first(14)}#{offset.start_with?('+', '-') ? offset : '+0000'}", TIME_FORMAT)
+ end
+
+ def unsigned_changes?(document, io)
+ signed_end = document.signatures.map(&:signed_end).max
+
+ return false if document.trailer_ends.none? { |offset| offset > signed_end }
+
+ io.seek(0)
+
+ Pdfium::Document.open_bytes(io.read(signed_end)) do |signed_document|
+ next false unless signed_document.valid_cross_reference_table?
+
+ serialized_document(signed_document) != serialized_document(document)
+ end
+ end
+
+ def serialized_document(document)
+ pages = (0...document.page_count).map do |index|
+ page = document.get_page(index)
+
+ [page.rotation, page.objects, page.annotations, page.text]
+ end
+
+ [pages, document.bookmarks]
+ end
+
+ def signed_data(io, byte_range)
+ byte_range.each_slice(2).map do |offset, length|
+ io.seek(offset)
+ io.read(length)
+ end.join
+ end
+end