From ced88476cc89280da5c19da94e0fd213041c5d47 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sat, 2 Dec 2023 18:14:33 +0200 Subject: [PATCH] add ability to use timestamp server --- .../timestamp_server_controller.rb | 52 ++++++++++++++++++ app/models/encrypted_config.rb | 1 + app/views/esign_settings/show.html.erb | 26 +++++++++ config/routes.rb | 1 + lib/accounts.rb | 8 +++ lib/docuseal.rb | 1 + lib/submissions/generate_audit_trail.rb | 15 ++++-- .../generate_result_attachments.rb | 21 +++++--- lib/submissions/timestamp_handler.rb | 53 +++++++++++++++++++ 9 files changed, 168 insertions(+), 10 deletions(-) create mode 100644 app/controllers/timestamp_server_controller.rb create mode 100644 lib/submissions/timestamp_handler.rb diff --git a/app/controllers/timestamp_server_controller.rb b/app/controllers/timestamp_server_controller.rb new file mode 100644 index 00000000..86d45b9a --- /dev/null +++ b/app/controllers/timestamp_server_controller.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class TimestampServerController < ApplicationController + before_action :build_encrypted_config + authorize_resource :encrypted_config + + def create + return head :not_found if Docuseal.multitenant? + + test_timeserver_url(@encrypted_config.value) if @encrypted_config.value.present? + + if @encrypted_config.value.present? ? @encrypted_config.save : @encrypted_config.delete + redirect_back fallback_location: settings_notifications_path, notice: 'Changes have been saved' + else + redirect_back fallback_location: settings_notifications_path, alert: 'Unable to save' + end + rescue HexaPDF::Error, SocketError, Submissions::TimestampHandler::TimestampError + redirect_back fallback_location: settings_notifications_path, alert: 'Invalid Timeserver' + end + + private + + def test_timeserver_url(url) + pdf = HexaPDF::Document.new + pdf.pages.add + + pkcs = Accounts.load_signing_pkcs(current_account) + + pdf.sign(StringIO.new, + reason: 'Test', + certificate: pkcs.certificate, + key: pkcs.key, + certificate_chain: pkcs.ca_certs || [], + timestamp_handler: Submissions::TimestampHandler.new(tsa_url: url)) + end + + def load_encrypted_config + @encrypted_config + end + + def build_encrypted_config + @encrypted_config = + EncryptedConfig.find_or_initialize_by(account: current_account, + key: EncryptedConfig::TIMESTAMP_SERVER_URL_KEY) + + @encrypted_config.assign_attributes(encrypted_config_params) + end + + def encrypted_config_params + params.require(:encrypted_config).permit(:value) + end +end diff --git a/app/models/encrypted_config.rb b/app/models/encrypted_config.rb index 17e3269a..21d51baa 100644 --- a/app/models/encrypted_config.rb +++ b/app/models/encrypted_config.rb @@ -25,6 +25,7 @@ class EncryptedConfig < ApplicationRecord FILES_STORAGE_KEY = 'active_storage', EMAIL_SMTP_KEY = 'action_mailer_smtp', ESIGN_CERTS_KEY = 'esign_certs', + TIMESTAMP_SERVER_URL_KEY = 'timestamp_server_url', APP_URL_KEY = 'app_url', WEBHOOK_URL_KEY = 'webhook_url' ].freeze diff --git a/app/views/esign_settings/show.html.erb b/app/views/esign_settings/show.html.erb index 6987d74c..5f93bacd 100644 --- a/app/views/esign_settings/show.html.erb +++ b/app/views/esign_settings/show.html.erb @@ -97,5 +97,31 @@ + <% encrypted_config = EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::TIMESTAMP_SERVER_URL_KEY) %> + <% if !Docuseal.multitenant? && can?(:manage, encrypted_config) %> +
+
+

Timestamp Server

+
+ <%= form_for encrypted_config, url: timestamp_server_index_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %> +
+ <%= f.label :value, class: 'label' do %> + + + Timeserver URL + + + <%= svg_icon('info_circle', class: 'w-4 h-4') %> + + + <% end %> + <%= f.url_field :value, autocomplete: 'off', class: 'base-input', placeholder: 'URL (optional)' %> +
+
+ <%= f.button button_title(title: 'Save', disabled_with: 'Updating'), class: 'base-button' %> +
+ <% end %> + <% end %> +
diff --git a/config/routes.rb b/config/routes.rb index 0d90dd88..a73c17a0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -50,6 +50,7 @@ Rails.application.routes.draw do resources :verify_pdf_signature, only: %i[create] resource :mfa_setup, only: %i[show new edit create destroy], controller: 'mfa_setup' resources :account_configs, only: %i[create] + resources :timestamp_server, only: %i[create] resources :dashboard, only: %i[index] resources :setup, only: %i[index create] resource :newsletter, only: %i[show update] diff --git a/lib/accounts.rb b/lib/accounts.rb index fcab3cad..0f76fe4c 100644 --- a/lib/accounts.rb +++ b/lib/accounts.rb @@ -71,6 +71,14 @@ module Accounts end end + def load_timeserver_url(account) + if Docuseal.multitenant? + Docuseal::TIMESERVER_URL + else + EncryptedConfig.find_by(account:, key: EncryptedConfig::TIMESTAMP_SERVER_URL_KEY)&.value + end.presence + end + def can_send_emails?(_account) return true if Docuseal.multitenant? return true if ENV['SMTP_ADDRESS'].present? diff --git a/lib/docuseal.rb b/lib/docuseal.rb index fa7b8773..0fecc306 100644 --- a/lib/docuseal.rb +++ b/lib/docuseal.rb @@ -28,6 +28,7 @@ module Docuseal end CERTS = JSON.parse(ENV.fetch('CERTS', '{}')) + TIMESERVER_URL = ENV.fetch('TIMESERVER_URL', nil) DEFAULT_URL_OPTIONS = { host: HOST, diff --git a/lib/submissions/generate_audit_trail.rb b/lib/submissions/generate_audit_trail.rb index b574a79c..83d30d42 100644 --- a/lib/submissions/generate_audit_trail.rb +++ b/lib/submissions/generate_audit_trail.rb @@ -32,6 +32,7 @@ module Submissions def call(submission) account = submission.template.account pkcs = Accounts.load_signing_pkcs(account) + tsa_url = Accounts.load_timeserver_url(account) verify_url = Rails.application.routes.url_helpers.settings_esign_url(**Docuseal.default_url_options) composer = HexaPDF::Composer.new(skip_page_creation: true) @@ -254,10 +255,16 @@ module Submissions composer.document.trailer.info[:Creator] = INFO_CREATOR - composer.document.sign(io, reason: SIGN_REASON, - certificate: pkcs.certificate, - key: pkcs.key, - certificate_chain: pkcs.ca_certs || []) + sign_params = { + reason: SIGN_REASON, + certificate: pkcs.certificate, + key: pkcs.key, + certificate_chain: pkcs.ca_certs || [] + } + + sign_params[:timestamp_handler] = Submissions::TimestampHandler.new(tsa_url:) if tsa_url + + composer.document.sign(io, **sign_params) ActiveStorage::Attachment.create!( blob: ActiveStorage::Blob.create_and_upload!( diff --git a/lib/submissions/generate_result_attachments.rb b/lib/submissions/generate_result_attachments.rb index f26a28dc..54450dba 100644 --- a/lib/submissions/generate_result_attachments.rb +++ b/lib/submissions/generate_result_attachments.rb @@ -29,6 +29,7 @@ module Submissions account = submitter.submission.template.account pkcs = Accounts.load_signing_pkcs(account) + tsa_url = Accounts.load_timeserver_url(account) pdfs_index = build_pdfs_index(submitter) @@ -190,7 +191,9 @@ module Submissions submitter.submission.template_schema.map do |item| pdf = pdfs_index[item['attachment_uuid']] - attachment = save_signed_pdf(pdf:, submitter:, pkcs:, uuid: item['attachment_uuid'], name: item['name']) + attachment = save_signed_pdf(pdf:, submitter:, pkcs:, tsa_url:, + uuid: item['attachment_uuid'], + name: item['name']) image_pdfs << pdf if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image? @@ -217,15 +220,21 @@ module Submissions end # rubocop:enable Metrics - def save_signed_pdf(pdf:, submitter:, pkcs:, uuid:, name:) + def save_signed_pdf(pdf:, submitter:, pkcs:, tsa_url:, uuid:, name:) io = StringIO.new pdf.trailer.info[:Creator] = info_creator - pdf.sign(io, reason: sign_reason(submitter.email), - certificate: pkcs.certificate, - key: pkcs.key, - certificate_chain: pkcs.ca_certs || []) + sign_params = { + reason: sign_reason(submitter.email), + certificate: pkcs.certificate, + key: pkcs.key, + certificate_chain: pkcs.ca_certs || [] + } + + sign_params[:timestamp_handler] = Submissions::TimestampHandler.new(tsa_url:) if tsa_url + + pdf.sign(io, **sign_params) ActiveStorage::Attachment.create!( uuid:, diff --git a/lib/submissions/timestamp_handler.rb b/lib/submissions/timestamp_handler.rb new file mode 100644 index 00000000..96f09f41 --- /dev/null +++ b/lib/submissions/timestamp_handler.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Submissions + class TimestampHandler + HASH_ALGORITHM = 'SHA512' + + TimestampError = Class.new(StandardError) + + attr_reader :tsa_url + + def initialize(tsa_url:) + @tsa_url = tsa_url + end + + def finalize_objects(_signature_field, signature) + signature.document.version = '2.0' + + signature[:Type] = :DocTimeStamp + signature[:Filter] = :'Adobe.PPKLite' + signature[:SubFilter] = :'ETSI.RFC3161' + end + + def sign(io, byte_range) + digest = OpenSSL::Digest.new(HASH_ALGORITHM) + + io.pos = byte_range[0] + digest << io.read(byte_range[1]) + io.pos = byte_range[2] + digest << io.read(byte_range[3]) + + uri = Addressable::URI.parse(tsa_url) + + conn = Faraday.new(uri.origin) do |c| + c.basic_auth(uri.user, uri.password) if uri.password.present? + end + + response = conn.post(uri.path, build_payload(digest.digest), + 'content-type' => 'application/timestamp-query') + + raise TimestampError if response.status != 200 || response.body.blank? + + OpenSSL::Timestamp::Response.new(response.body).token.to_der + end + + def build_payload(digest) + req = OpenSSL::Timestamp::Request.new + req.algorithm = HASH_ALGORITHM + req.message_imprint = digest + + req.to_der + end + end +end