add ability to use own signing cert

pull/105/head
Alex Turchyn 2 years ago
parent c9f39e7b1a
commit d3a5f36555

@ -1,19 +1,109 @@
# frozen_string_literal: true # frozen_string_literal: true
class EsignSettingsController < ApplicationController 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 def create
pdfs = @cert_record = CertFormRecord.new(**cert_params)
params[:files].map do |file|
HexaPDF::Document.new(io: file.open) cert_configs = EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY)
end
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', params.require(:esign_settings_controller_cert_form_record).permit(:name, :file, :password)
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
end end

@ -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

@ -38,7 +38,11 @@ export default actionable(targetable(class extends HTMLElement {
this.uploadFiles(this.input.files) this.uploadFiles(this.input.files)
} }
toggleLoading = () => { toggleLoading = (e) => {
if (e && e.target && !e.target.contains(this)) {
return
}
this.loading.classList.toggle('hidden') this.loading.classList.toggle('hidden')
this.icon.classList.toggle('hidden') this.icon.classList.toggle('hidden')
this.classList.toggle('opacity-50') this.classList.toggle('opacity-50')

@ -1,41 +0,0 @@
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0">
<%= render 'shared/settings_nav' %>
<div class="flex-grow max-w-xl mx-auto">
<h1 class="text-4xl font-bold mb-4">PDF Signature</h1>
<div id="result">
<p class="mb-2">
Upload signed PDF file to validate its signature:
</p>
</div>
<%= form_for '', url: settings_esign_index_path, method: :post, html: { enctype: 'multipart/form-data' } do |f| %>
<%= f.button type: 'submit', class: 'flex' do %>
<div class="disabled mb-3">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin inline') %>
Analyzing...
</div>
<% end %>
<file-dropzone data-is-direct-upload="false" data-name="verify_attachments" data-submit-on-upload="true" class="w-full">
<label for="file" class="w-full block h-32 relative bg-base-200 hover:bg-base-200/70 rounded-md border border-base-content border-dashed">
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
<div class="flex flex-col items-center">
<span data-target="file-dropzone.icon">
<%= svg_icon('cloud_upload', class: 'w-10 h-10') %>
</span>
<span data-target="file-dropzone.loading" class="hidden">
<%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %>
</span>
<div class="font-medium mb-1">
Verify Signed PDF
</div>
<div class="text-xs">
<span class="font-medium">Click to upload</span> or drag and drop files
</div>
</div>
<input id="file" name="files[]" class="hidden" data-action="change:file-dropzone#onSelectFiles" data-target="file-dropzone.input" type="file" accept="application/pdf" multiple>
</div>
</label>
</file-dropzone>
<% end %>
</div>
<div class="w-0 md:w-52"></div>
</div>

@ -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| %>
<div class="space-y-2">
<div class="form-control">
<%= f.label :name, class: 'label' %>
<%= f.text_field :name, required: true, class: 'base-input' %>
</div>
<div class="form-control">
<%= f.label :file, class: 'label' %>
<%= f.file_field :file, required: true %>
<label class="label">
<span class="label-text-alt">Use a valid .der, .p12 or .pfx file.</span>
</label>
</div>
<div class="form-control">
<%= f.label :password, 'Password (optional)', class: 'label' %>
<%= f.text_field :password, class: 'base-input' %>
</div>
</div>
<div class="form-control pt-2">
<%= f.button button_title, class: 'base-button' %>
</div>
<% end %>
<% end %>

@ -0,0 +1,99 @@
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0 md:space-x-10">
<%= render 'shared/settings_nav' %>
<div class="flex-grow">
<div class="max-w-xl">
<h1 class="text-4xl font-bold mb-4">PDF Signature</h1>
<div id="result">
<p class="mb-2">
Upload signed PDF file to validate its signature:
</p>
</div>
<%= form_for '', url: verify_pdf_signature_index_path, method: :post, html: { enctype: 'multipart/form-data' } do |f| %>
<%= f.button type: 'submit', class: 'flex' do %>
<div class="disabled mb-3">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin inline') %>
Analyzing...
</div>
<% end %>
<file-dropzone data-is-direct-upload="false" data-name="verify_attachments" data-submit-on-upload="true" class="w-full">
<label for="file" class="w-full block h-32 relative bg-base-200 hover:bg-base-200/70 rounded-md border border-base-content border-dashed">
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
<div class="flex flex-col items-center">
<span data-target="file-dropzone.icon">
<%= svg_icon('cloud_upload', class: 'w-10 h-10') %>
</span>
<span data-target="file-dropzone.loading" class="hidden">
<%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %>
</span>
<div class="font-medium mb-1">
Verify Signed PDF
</div>
<div class="text-xs">
<span class="font-medium">Click to upload</span> or drag and drop files
</div>
</div>
<input id="file" name="files[]" class="hidden" data-action="change:file-dropzone#onSelectFiles" data-target="file-dropzone.input" type="file" accept="application/pdf" multiple>
</div>
</label>
</file-dropzone>
<% end %>
</div>
<div class="flex justify-between items-end mb-4 mt-8">
<h2 class="text-3xl font-bold">Signing Certificates</h2>
<%= 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') %>
<span>Upload Cert</span>
<% end %>
</div>
<%= render 'alert' %>
<div class="overflow-x-auto">
<table class="table w-full table-lg rounded-b-none overflow-hidden">
<thead class="bg-base-200">
<tr class="text-neutral uppercase">
<th>
Name
</th>
<th>
Valid To
</th>
<th>
Status
</th>
<th class="text-right" width="1px">
</th>
</tr>
</thead>
<tbody>
<% @pkcs_list.each do |item| %>
<tr scope="row" class="group">
<td>
<%= item['name'] %>
</td>
<td>
<%= l(item['pkcs'].certificate.not_after.to_date, format: :long, locale: current_account.locale) %>
</td>
<td>
<% if item['status'] == 'default' %>
<span class="badge badge-lg badge-info badge-outline">
<%= item['status'] %>
</span>
<% 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 %>
</td>
<td>
<% 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 %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>

@ -44,7 +44,7 @@
</li> </li>
<% end %> <% end %>
<li> <li>
<%= 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') %> <%= svg_icon('zoom_check', class: 'w-5 h-5 stroke-2') %>
<span class="mr-1">Verify PDF</span> <span class="mr-1">Verify PDF</span>
<% end %> <% end %>

@ -18,7 +18,7 @@
</li> </li>
<% end %> <% end %>
<li> <li>
<%= 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' %>
</li> </li>
<li> <li>
<%= link_to 'Team', settings_users_path, class: 'text-base hover:bg-base-300' %> <%= link_to 'Team', settings_users_path, class: 'text-base hover:bg-base-300' %>

@ -49,7 +49,7 @@
<%= link_to edit_user_path(user), class: 'btn btn-outline btn-xs', title: 'Edit', data: { turbo_frame: 'modal' } do %> <%= link_to edit_user_path(user), class: 'btn btn-outline btn-xs', title: 'Edit', data: { turbo_frame: 'modal' } do %>
Edit Edit
<% end %> <% 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 Remove
<% end %> <% end %>
</td> </td>

@ -29,10 +29,10 @@
</p> </p>
<% if message == 'Signature valid' %> <% if message == 'Signature valid' %>
<p class="flex space-x-1 items-center"> <p class="flex space-x-1 items-center">
<% 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') %> <%= svg_icon('circle_check', class: 'w-6 h-6 text-green-500') %>
<span> <span>
Signed with DocuSeal certificate Signed with trusted certificate
</span> </span>
<% else %> <% else %>
<%= svg_icon('x_circle', class: 'w-6 h-6 text-red-500') %> <%= svg_icon('x_circle', class: 'w-6 h-6 text-red-500') %>

@ -38,6 +38,7 @@ Rails.application.routes.draw do
end end
end end
resources :verify_pdf_signature, only: %i[create]
resources :dashboard, only: %i[index] resources :dashboard, only: %i[index]
resources :setup, only: %i[index create] resources :setup, only: %i[index create]
resource :newsletter, only: %i[show update] 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 :storage, only: %i[index create], controller: 'storage_settings'
resources :email, only: %i[index create], controller: 'email_settings' resources :email, only: %i[index create], controller: 'email_settings'
end 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] resources :users, only: %i[index]
resource :personalization, only: %i[show create], controller: 'personalization_settings' resource :personalization, only: %i[show create], controller: 'personalization_settings'
if !Docuseal.multitenant? || Docuseal.demo? if !Docuseal.multitenant? || Docuseal.demo?

@ -42,22 +42,19 @@ module Accounts
new_template new_template
end end
def load_signing_certs(account) def load_signing_pkcs(account)
certs = cert_data =
if Docuseal.multitenant? if Docuseal.multitenant?
Docuseal::CERTS Docuseal::CERTS
else else
EncryptedConfig.find_by(account:, key: EncryptedConfig::ESIGN_CERTS_KEY).value EncryptedConfig.find_by(account:, key: EncryptedConfig::ESIGN_CERTS_KEY).value
end end
{ if (default_cert = cert_data['custom']&.find { |e| e['status'] == 'default' })
cert: OpenSSL::X509::Certificate.new(certs['cert']), OpenSSL::PKCS12.new(Base64.urlsafe_decode64(default_cert['data']), default_cert['password'])
key: OpenSSL::PKey::RSA.new(certs['key']), else
sub_ca: OpenSSL::X509::Certificate.new(certs['sub_ca']), GenerateCertificate.load_pkcs(cert_data)
sub_key: OpenSSL::PKey::RSA.new(certs['sub_key']), end
root_ca: OpenSSL::X509::Certificate.new(certs['root_ca']),
root_key: OpenSSL::PKey::RSA.new(certs['root_key'])
}
end end
def can_send_emails?(account) def can_send_emails?(account)

@ -87,4 +87,19 @@ module GenerateCertificate
[cert, key] [cert, key]
end 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 end

@ -28,7 +28,7 @@ module Submissions
template = submitter.submission.template 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) pdfs_index = build_pdfs_index(submitter)
@ -171,7 +171,7 @@ module Submissions
submitter.submission.template_schema.map do |item| submitter.submission.template_schema.map do |item|
pdf = pdfs_index[item['attachment_uuid']] 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? image_pdfs << pdf if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image?
@ -189,7 +189,7 @@ module Submissions
save_signed_pdf( save_signed_pdf(
pdf: images_pdf, pdf: images_pdf,
submitter:, submitter:,
certs:, pkcs:,
uuid: images_pdf_uuid(original_documents.select(&:image?)), uuid: images_pdf_uuid(original_documents.select(&:image?)),
name: template.name name: template.name
) )
@ -198,15 +198,15 @@ module Submissions
end end
# rubocop:enable Metrics # rubocop:enable Metrics
def save_signed_pdf(pdf:, submitter:, certs:, uuid:, name:) def save_signed_pdf(pdf:, submitter:, pkcs:, uuid:, name:)
io = StringIO.new io = StringIO.new
pdf.trailer.info[:Creator] = INFO_CREATOR pdf.trailer.info[:Creator] = INFO_CREATOR
pdf.sign(io, reason: format(SIGN_REASON, email: submitter.email), pdf.sign(io, reason: format(SIGN_REASON, email: submitter.email),
certificate: certs[:cert], certificate: pkcs.certificate,
key: certs[:key], key: pkcs.key,
certificate_chain: [certs[:sub_ca], certs[:root_ca]]) certificate_chain: pkcs.ca_certs || [])
ActiveStorage::Attachment.create!( ActiveStorage::Attachment.create!(
uuid:, uuid:,

@ -8,7 +8,7 @@ RSpec.describe 'PDF Signature Settings' do
before do before do
sign_in(user) sign_in(user)
visit settings_esign_index_path visit settings_esign_path
end end
it 'shows verify signed PDF page' do it 'shows verify signed PDF page' do

@ -70,7 +70,7 @@ RSpec.describe 'Team Settings' do
it 'removes a user' do it 'removes a user' do
expect do expect do
accept_confirm('Are you sure?') do accept_confirm('Are you sure?') do
first(:link, 'Delete').click first(:button, 'Delete').click
end end
end.to change { User.active.count }.by(-1) 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 it 'does not allow to remove the current user' do
expect do expect do
accept_confirm('Are you sure?') do accept_confirm('Are you sure?') do
first(:link, 'Delete').click first(:button, 'Delete').click
end end
end.not_to(change { User.admins.count }) end.not_to(change { User.admins.count })

Loading…
Cancel
Save