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' %>
+
+
+
+
+ |
+ Name
+ |
+
+ Valid To
+ |
+
+ Status
+ |
+
+ |
+
+
+
+ <% @pkcs_list.each do |item| %>
+
+ |
+ <%= 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 %>
+ |
+
+ <% 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 })