diff --git a/app/controllers/esign_settings_controller.rb b/app/controllers/esign_settings_controller.rb index a8a62f02..da0782e6 100644 --- a/app/controllers/esign_settings_controller.rb +++ b/app/controllers/esign_settings_controller.rb @@ -1,19 +1,109 @@ # frozen_string_literal: true class EsignSettingsController < ApplicationController + DEFAULT_CERT_NAME = 'DocuSeal Self-Host Autogenerated' + + CertFormRecord = Struct.new(:name, :file, :password, keyword_init: true) do + include ActiveModel::Validations + + def to_key + [] + end + end + + def show + cert_data = EncryptedConfig.find_by(account: current_account, + key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {} + + default_pkcs = GenerateCertificate.load_pkcs(cert_data) if cert_data['cert'].present? + + custom_pkcs_list = (cert_data['custom'] || []).map do |e| + { 'pkcs' => OpenSSL::PKCS12.new(Base64.urlsafe_decode64(e['data']), e['password']), + 'name' => e['name'], + 'status' => e['status'] } + end + + @pkcs_list = [ + if default_pkcs + { + 'pkcs' => default_pkcs, + 'name' => DEFAULT_CERT_NAME, + 'status' => custom_pkcs_list.any? { |e| e['status'] == 'default' } ? 'validate' : 'default' + } + end, + *custom_pkcs_list + ].compact.reverse + end + + def new + @cert_record = CertFormRecord.new + end + def create - pdfs = - params[:files].map do |file| - HexaPDF::Document.new(io: file.open) - end + @cert_record = CertFormRecord.new(**cert_params) + + cert_configs = EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY) + + if cert_configs.value['custom']&.any? { |e| e['name'] == @cert_record.name } || + @cert_record.name == DEFAULT_CERT_NAME + + @cert_record.errors.add(:name, 'already exists') + + return render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'), + status: :unprocessable_entity + end + + save_new_cert!(cert_configs, @cert_record) + + redirect_to settings_esign_path, notice: 'Certificate has been successfully added!' + rescue OpenSSL::PKCS12::PKCS12Error + @cert_record.errors.add(:password, "is invalid. Make sure you're uploading a valid .p12 file") - certs = Accounts.load_signing_certs(current_account) + render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'), status: :unprocessable_entity + end + + def update + cert_configs = EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY) + + cert_configs.value['custom'].each { |e| e['status'] = 'validate' } + custom_cert_data = cert_configs.value['custom'].find { |e| e['name'] == params[:name] } + custom_cert_data['status'] = 'default' if custom_cert_data + + cert_configs.save! + + redirect_to settings_esign_path, notice: 'Default certificate has been selected' + end + + def destroy + cert_configs = EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY) + + cert_configs.value['custom'].reject! { |e| e['name'] == params[:name] } + + cert_configs.save! + + redirect_to settings_esign_path, notice: 'Certificate has been removed' + end + + private + + def save_new_cert!(cert_configs, cert_record) + pkcs = OpenSSL::PKCS12.new(cert_record.file.read, cert_record.password) + + cert_configs.value['custom'] ||= [] + cert_configs.value['custom'].each { |e| e['status'] = 'validate' } + cert_configs.value['custom'] << { + data: Base64.urlsafe_encode64(pkcs.to_der), + password: cert_record.password, + name: cert_record.name, + status: 'default' + } + + cert_configs.save! + end - trusted_certs = [certs[:cert], certs[:sub_ca], certs[:root_ca]] + def cert_params + return {} if params[:esign_settings_controller_cert_form_record].blank? - render turbo_stream: turbo_stream.replace('result', partial: 'result', - locals: { pdfs:, files: params[:files], trusted_certs: }) - rescue HexaPDF::MalformedPDFError - render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div('Invalid PDF', id: 'result')) + params.require(:esign_settings_controller_cert_form_record).permit(:name, :file, :password) end end diff --git a/app/controllers/verify_pdf_signature_controller.rb b/app/controllers/verify_pdf_signature_controller.rb new file mode 100644 index 00000000..e184d4dd --- /dev/null +++ b/app/controllers/verify_pdf_signature_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class VerifyPdfSignatureController < ApplicationController + def create + pdfs = + params[:files].map do |file| + HexaPDF::Document.new(io: file.open) + end + + cert_data = EncryptedConfig.find_by(account: current_account, + key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {} + + default_pkcs = GenerateCertificate.load_pkcs(cert_data) + + custom_certs = (cert_data['custom'] || []).map do |e| + OpenSSL::PKCS12.new(Base64.urlsafe_decode64(e['data']), e['password']) + end + + trusted_certs = [default_pkcs.certificate, + *default_pkcs.ca_certs, + *custom_certs.map(&:certificate), + *custom_certs.flat_map(&:ca_certs).compact] + + render turbo_stream: turbo_stream.replace('result', partial: 'result', + locals: { pdfs:, files: params[:files], trusted_certs: }) + rescue HexaPDF::MalformedPDFError + render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div('Invalid PDF', id: 'result')) + end +end diff --git a/app/javascript/elements/file_dropzone.js b/app/javascript/elements/file_dropzone.js index f4f5c163..1bf0c8bd 100644 --- a/app/javascript/elements/file_dropzone.js +++ b/app/javascript/elements/file_dropzone.js @@ -38,7 +38,11 @@ export default actionable(targetable(class extends HTMLElement { this.uploadFiles(this.input.files) } - toggleLoading = () => { + toggleLoading = (e) => { + if (e && e.target && !e.target.contains(this)) { + return + } + this.loading.classList.toggle('hidden') this.icon.classList.toggle('hidden') this.classList.toggle('opacity-50') diff --git a/app/views/esign_settings/_alert.html.erb b/app/views/esign_settings/_alert.html.erb new file mode 100644 index 00000000..e69de29b diff --git a/app/views/esign_settings/index.html.erb b/app/views/esign_settings/index.html.erb deleted file mode 100644 index 08f566b9..00000000 --- a/app/views/esign_settings/index.html.erb +++ /dev/null @@ -1,41 +0,0 @@ -
- Upload signed PDF file to validate its signature: -
-+ Upload signed PDF file to validate its signature: +
+| + Name + | ++ Valid To + | ++ Status + | ++ | +
|---|---|---|---|
| + <%= item['name'] %> + | ++ <%= l(item['pkcs'].certificate.not_after.to_date, format: :long, locale: current_account.locale) %> + | ++ <% if item['status'] == 'default' %> + + <%= item['status'] %> + + <% else %> + <%= button_to settings_esign_path, method: :put, params: { name: item['name'] }, class: 'btn btn-outline btn-neutral btn-xs whitespace-nowrap', title: 'Delete', data: { turbo_confirm: 'Are you sure?' } do %> + Make Default + <% end %> + <% end %> + | ++ <% if item['name'] != EsignSettingsController::DEFAULT_CERT_NAME && item['status'] != 'default' %> + <%= button_to settings_esign_path, params: { name: item['name'] }, method: :delete, class: 'btn btn-outline btn-error btn-xs', title: 'Delete', data: { turbo_confirm: 'Are you sure?' } do %> + Remove + <% end %> + <% end %> + | +
- <% if signature.signature_handler.signer_certificate.public_key.to_der == trusted_certs.first.public_key.to_der %> + <% 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') %> - Signed with DocuSeal certificate + Signed with trusted certificate <% else %> <%= svg_icon('x_circle', class: 'w-6 h-6 text-red-500') %> diff --git a/config/routes.rb b/config/routes.rb index 3d06bd50..8904cc7c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -38,6 +38,7 @@ Rails.application.routes.draw do end end + resources :verify_pdf_signature, only: %i[create] resources :dashboard, only: %i[index] resources :setup, only: %i[index create] resource :newsletter, only: %i[show update] @@ -71,7 +72,7 @@ Rails.application.routes.draw do resources :storage, only: %i[index create], controller: 'storage_settings' resources :email, only: %i[index create], controller: 'email_settings' end - resources :esign, only: %i[index create], controller: 'esign_settings' + resource :esign, only: %i[show create new update destroy], controller: 'esign_settings' resources :users, only: %i[index] resource :personalization, only: %i[show create], controller: 'personalization_settings' if !Docuseal.multitenant? || Docuseal.demo? diff --git a/lib/accounts.rb b/lib/accounts.rb index a99bf61e..1ae0c702 100644 --- a/lib/accounts.rb +++ b/lib/accounts.rb @@ -42,22 +42,19 @@ module Accounts new_template end - def load_signing_certs(account) - certs = + def load_signing_pkcs(account) + cert_data = if Docuseal.multitenant? Docuseal::CERTS else EncryptedConfig.find_by(account:, key: EncryptedConfig::ESIGN_CERTS_KEY).value end - { - cert: OpenSSL::X509::Certificate.new(certs['cert']), - key: OpenSSL::PKey::RSA.new(certs['key']), - sub_ca: OpenSSL::X509::Certificate.new(certs['sub_ca']), - sub_key: OpenSSL::PKey::RSA.new(certs['sub_key']), - root_ca: OpenSSL::X509::Certificate.new(certs['root_ca']), - root_key: OpenSSL::PKey::RSA.new(certs['root_key']) - } + if (default_cert = cert_data['custom']&.find { |e| e['status'] == 'default' }) + OpenSSL::PKCS12.new(Base64.urlsafe_decode64(default_cert['data']), default_cert['password']) + else + GenerateCertificate.load_pkcs(cert_data) + end end def can_send_emails?(account) diff --git a/lib/generate_certificate.rb b/lib/generate_certificate.rb index 3c35df13..f3c2cb9a 100644 --- a/lib/generate_certificate.rb +++ b/lib/generate_certificate.rb @@ -87,4 +87,19 @@ module GenerateCertificate [cert, key] end + + def load_pkcs(cert_data) + cert = OpenSSL::X509::Certificate.new(cert_data['cert']) + key = OpenSSL::PKey::RSA.new(cert_data['key']) + sub_ca = OpenSSL::X509::Certificate.new(cert_data['sub_ca']) + root_ca = OpenSSL::X509::Certificate.new(cert_data['root_ca']) + + OpenSSL::PKCS12.create( + '', + '', + key, + cert, + [sub_ca, root_ca] + ) + end end diff --git a/lib/submissions/generate_result_attachments.rb b/lib/submissions/generate_result_attachments.rb index 2fd6f89b..dc9893b1 100644 --- a/lib/submissions/generate_result_attachments.rb +++ b/lib/submissions/generate_result_attachments.rb @@ -28,7 +28,7 @@ module Submissions template = submitter.submission.template - certs = Accounts.load_signing_certs(submitter.submission.template.account) + pkcs = Accounts.load_signing_pkcs(submitter.submission.template.account) pdfs_index = build_pdfs_index(submitter) @@ -171,7 +171,7 @@ module Submissions submitter.submission.template_schema.map do |item| pdf = pdfs_index[item['attachment_uuid']] - attachment = save_signed_pdf(pdf:, submitter:, certs:, uuid: item['attachment_uuid'], name: item['name']) + attachment = save_signed_pdf(pdf:, submitter:, pkcs:, uuid: item['attachment_uuid'], name: item['name']) image_pdfs << pdf if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image? @@ -189,7 +189,7 @@ module Submissions save_signed_pdf( pdf: images_pdf, submitter:, - certs:, + pkcs:, uuid: images_pdf_uuid(original_documents.select(&:image?)), name: template.name ) @@ -198,15 +198,15 @@ module Submissions end # rubocop:enable Metrics - def save_signed_pdf(pdf:, submitter:, certs:, uuid:, name:) + def save_signed_pdf(pdf:, submitter:, pkcs:, uuid:, name:) io = StringIO.new pdf.trailer.info[:Creator] = INFO_CREATOR pdf.sign(io, reason: format(SIGN_REASON, email: submitter.email), - certificate: certs[:cert], - key: certs[:key], - certificate_chain: [certs[:sub_ca], certs[:root_ca]]) + certificate: pkcs.certificate, + key: pkcs.key, + certificate_chain: pkcs.ca_certs || []) ActiveStorage::Attachment.create!( uuid:, diff --git a/spec/system/esign_spec.rb b/spec/system/esign_spec.rb index c5f7a395..b1a48f62 100644 --- a/spec/system/esign_spec.rb +++ b/spec/system/esign_spec.rb @@ -8,7 +8,7 @@ RSpec.describe 'PDF Signature Settings' do before do sign_in(user) - visit settings_esign_index_path + visit settings_esign_path end it 'shows verify signed PDF page' do diff --git a/spec/system/team_settings_spec.rb b/spec/system/team_settings_spec.rb index f8b24ceb..090e746f 100644 --- a/spec/system/team_settings_spec.rb +++ b/spec/system/team_settings_spec.rb @@ -70,7 +70,7 @@ RSpec.describe 'Team Settings' do it 'removes a user' do expect do accept_confirm('Are you sure?') do - first(:link, 'Delete').click + first(:button, 'Delete').click end end.to change { User.active.count }.by(-1) @@ -86,7 +86,7 @@ RSpec.describe 'Team Settings' do it 'does not allow to remove the current user' do expect do accept_confirm('Are you sure?') do - first(:link, 'Delete').click + first(:button, 'Delete').click end end.not_to(change { User.admins.count })