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