diff --git a/app/controllers/verify_pdf_signature_controller.rb b/app/controllers/verify_pdf_signature_controller.rb index a81a6da8..d1af8e97 100644 --- a/app/controllers/verify_pdf_signature_controller.rb +++ b/app/controllers/verify_pdf_signature_controller.rb @@ -4,16 +4,16 @@ class VerifyPdfSignatureController < ApplicationController skip_authorization_check def create - pdfs = - params[:files].map do |file| - HexaPDF::Document.new(io: file.open) - end - trusted_certs = Accounts.load_trusted_certs(current_account) + signatures = + params[:files].map do |file| + VerifyPdfSignature.call(file.open, trusted_certs) + end + render turbo_stream: turbo_stream.replace('result', partial: 'result', - locals: { pdfs:, files: params[:files], trusted_certs: }) - rescue HexaPDF::MalformedPDFError + locals: { signatures:, files: params[:files] }) + rescue Pdfium::PdfiumError render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div(I18n.t('invalid_pdf'), id: 'result')) end end diff --git a/app/views/verify_pdf_signature/_result.html.erb b/app/views/verify_pdf_signature/_result.html.erb index 6f44c2da..73dabae2 100644 --- a/app/views/verify_pdf_signature/_result.html.erb +++ b/app/views/verify_pdf_signature/_result.html.erb @@ -1,7 +1,7 @@
- <% 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