From d3a5f36555e23739de0c5f594175708deb832aec Mon Sep 17 00:00:00 2001 From: Alex Turchyn Date: Thu, 31 Aug 2023 01:12:24 +0300 Subject: [PATCH] add ability to use own signing cert --- app/controllers/esign_settings_controller.rb | 110 ++++++++++++++++-- .../verify_pdf_signature_controller.rb | 29 +++++ app/javascript/elements/file_dropzone.js | 6 +- app/views/esign_settings/_alert.html.erb | 0 app/views/esign_settings/index.html.erb | 41 ------- app/views/esign_settings/new.html.erb | 24 ++++ app/views/esign_settings/show.html.erb | 99 ++++++++++++++++ app/views/shared/_navbar.html.erb | 2 +- app/views/shared/_settings_nav.html.erb | 2 +- app/views/users/index.html.erb | 2 +- .../_result.html.erb | 4 +- config/routes.rb | 3 +- lib/accounts.rb | 17 ++- lib/generate_certificate.rb | 15 +++ .../generate_result_attachments.rb | 14 +-- spec/system/esign_spec.rb | 2 +- spec/system/team_settings_spec.rb | 4 +- 17 files changed, 296 insertions(+), 78 deletions(-) create mode 100644 app/controllers/verify_pdf_signature_controller.rb create mode 100644 app/views/esign_settings/_alert.html.erb delete mode 100644 app/views/esign_settings/index.html.erb create mode 100644 app/views/esign_settings/new.html.erb create mode 100644 app/views/esign_settings/show.html.erb rename app/views/{esign_settings => verify_pdf_signature}/_result.html.erb (93%) 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 @@ -
- <%= render 'shared/settings_nav' %> -
-

PDF Signature

-
-

- Upload signed PDF file to validate its signature: -

-
- <%= form_for '', url: settings_esign_index_path, method: :post, html: { enctype: 'multipart/form-data' } do |f| %> - <%= f.button type: 'submit', class: 'flex' do %> -
- <%= svg_icon('loader', class: 'w-5 h-5 animate-spin inline') %> - Analyzing... -
- <% end %> - - - - <% end %> -
-
-
diff --git a/app/views/esign_settings/new.html.erb b/app/views/esign_settings/new.html.erb new file mode 100644 index 00000000..45c36046 --- /dev/null +++ b/app/views/esign_settings/new.html.erb @@ -0,0 +1,24 @@ +<%= render 'shared/turbo_modal', title: 'Upload Certificate' do %> + <%= form_for @cert_record, url: settings_esign_path, html: { class: 'space-y-4', enctype: 'multipart/form-data' }, data: { turbo_frame: :_top } do |f| %> +
+
+ <%= f.label :name, class: 'label' %> + <%= f.text_field :name, required: true, class: 'base-input' %> +
+
+ <%= f.label :file, class: 'label' %> + <%= f.file_field :file, required: true %> + +
+
+ <%= f.label :password, 'Password (optional)', class: 'label' %> + <%= f.text_field :password, class: 'base-input' %> +
+
+
+ <%= f.button button_title, class: 'base-button' %> +
+ <% end %> +<% end %> diff --git a/app/views/esign_settings/show.html.erb b/app/views/esign_settings/show.html.erb new file mode 100644 index 00000000..4b9a9476 --- /dev/null +++ b/app/views/esign_settings/show.html.erb @@ -0,0 +1,99 @@ +
+ <%= render 'shared/settings_nav' %> +
+
+

PDF Signature

+
+

+ Upload signed PDF file to validate its signature: +

+
+ <%= form_for '', url: verify_pdf_signature_index_path, method: :post, html: { enctype: 'multipart/form-data' } do |f| %> + <%= f.button type: 'submit', class: 'flex' do %> +
+ <%= svg_icon('loader', class: 'w-5 h-5 animate-spin inline') %> + Analyzing... +
+ <% end %> + + + + <% end %> +
+
+

Signing Certificates

+ <%= link_to new_settings_esign_path, class: 'btn btn-primary btn-md', data: { turbo_frame: 'modal' } do %> + <%= svg_icon('plus', class: 'w-6 h-6') %> + Upload Cert + <% end %> +
+ <%= render 'alert' %> +
+ + + + + + + + + + + <% @pkcs_list.each do |item| %> + + + + + + + <% end %> + +
+ 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 %> +
+
+
+
diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index 66cbddd9..37dacf53 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -44,7 +44,7 @@ <% end %>
  • - <%= link_to settings_esign_index_path, class: 'flex items-center' do %> + <%= link_to settings_esign_path, class: 'flex items-center' do %> <%= svg_icon('zoom_check', class: 'w-5 h-5 stroke-2') %> Verify PDF <% end %> diff --git a/app/views/shared/_settings_nav.html.erb b/app/views/shared/_settings_nav.html.erb index 5423831a..ab414a4b 100644 --- a/app/views/shared/_settings_nav.html.erb +++ b/app/views/shared/_settings_nav.html.erb @@ -18,7 +18,7 @@
  • <% end %>
  • - <%= link_to 'Signature', settings_esign_index_path, class: 'text-base hover:bg-base-300' %> + <%= link_to 'E-Signature', settings_esign_path, class: 'text-base hover:bg-base-300' %>
  • <%= link_to 'Team', settings_users_path, class: 'text-base hover:bg-base-300' %> diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index 4530c31e..ba06d97f 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -49,7 +49,7 @@ <%= link_to edit_user_path(user), class: 'btn btn-outline btn-xs', title: 'Edit', data: { turbo_frame: 'modal' } do %> Edit <% end %> - <%= link_to user_path(user), class: 'btn btn-outline btn-error btn-xs', title: 'Delete', data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' } do %> + <%= button_to user_path(user), method: :delete, class: 'btn btn-outline btn-error btn-xs', title: 'Delete', data: { turbo_confirm: 'Are you sure?' } do %> Remove <% end %> diff --git a/app/views/esign_settings/_result.html.erb b/app/views/verify_pdf_signature/_result.html.erb similarity index 93% rename from app/views/esign_settings/_result.html.erb rename to app/views/verify_pdf_signature/_result.html.erb index 2cfbec7a..ba29e708 100644 --- a/app/views/esign_settings/_result.html.erb +++ b/app/views/verify_pdf_signature/_result.html.erb @@ -29,10 +29,10 @@

    <% if message == 'Signature valid' %>

    - <% 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 })