adjust verify signature

This commit is contained in:
Pete Matsyburka
2026-08-12 11:15:43 +03:00
parent e867bf5c48
commit e361cbabf4
5 changed files with 408 additions and 33 deletions
+197
View File
@@ -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
+170
View File
@@ -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