diff --git a/.gitlab/auto-deploy-values.yaml b/.gitlab/auto-deploy-values.yaml index 4a2d21e..34c354f 100644 --- a/.gitlab/auto-deploy-values.yaml +++ b/.gitlab/auto-deploy-values.yaml @@ -29,3 +29,10 @@ persistence: accessMode: ReadWriteMany size: 1Gi storageClass: nfs-client + - name: autogram-server-well-known + mount: + path: /app/public/.well-known + claim: + accessMode: ReadWriteMany + size: 1Mi + storageClass: nfs-client diff --git a/Gemfile b/Gemfile index 61874a1..9397205 100644 --- a/Gemfile +++ b/Gemfile @@ -39,6 +39,8 @@ gem "rack-cors" gem "faraday" gem "base64" +gem "jwt" +gem "fcm" group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem @@ -51,3 +53,4 @@ group :development do # gem "spring" end +gem "good_job", "~> 3.28" diff --git a/Gemfile.lock b/Gemfile.lock index 9482519..d26e50b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -75,6 +75,8 @@ GEM minitest (>= 5.1) mutex_m tzinfo (~> 2.0) + addressable (2.8.6) + public_suffix (>= 2.0.2, < 6.0) base64 (0.2.0) bigdecimal (3.1.6) bootsnap (1.18.3) @@ -94,12 +96,36 @@ GEM drb (2.2.0) ruby2_keywords erubi (1.12.0) + et-orbi (1.2.11) + tzinfo faraday (2.9.0) faraday-net_http (>= 2.0, < 3.2) faraday-net_http (3.1.0) net-http + fcm (1.0.8) + faraday (>= 1.0.0, < 3.0) + googleauth (~> 1) + fugit (1.10.1) + et-orbi (~> 1, >= 1.2.7) + raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) + good_job (3.28.0) + activejob (>= 6.0.0) + activerecord (>= 6.0.0) + concurrent-ruby (>= 1.0.2) + fugit (>= 1.1) + railties (>= 6.0.0) + thor (>= 0.14.1) + google-cloud-env (2.1.1) + faraday (>= 1.0, < 3.a) + googleauth (1.11.0) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.1) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) i18n (1.14.1) concurrent-ruby (~> 1.0) io-console (0.7.2) @@ -109,6 +135,8 @@ GEM jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) + jwt (2.8.1) + base64 loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -119,8 +147,10 @@ GEM net-smtp marcel (1.0.2) mini_mime (1.1.5) + mini_portile2 (2.8.5) minitest (5.22.2) msgpack (1.7.2) + multi_json (1.15.0) mutex_m (0.2.0) net-http (0.4.1) uri @@ -134,6 +164,9 @@ GEM net-smtp (0.4.0.1) net-protocol nio4r (2.7.0) + nokogiri (1.16.2) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) nokogiri (1.16.2-aarch64-linux) racc (~> 1.4) nokogiri (1.16.2-arm-linux) @@ -146,11 +179,14 @@ GEM racc (~> 1.4) nokogiri (1.16.2-x86_64-linux) racc (~> 1.4) + os (1.1.4) pg (1.5.5) psych (5.1.2) stringio + public_suffix (5.0.5) puma (6.4.2) nio4r (~> 2.0) + raabro (1.4.0) racc (1.7.3) rack (3.0.9.1) rack-cors (2.0.1) @@ -197,6 +233,11 @@ GEM reline (0.4.3) io-console (~> 0.5) ruby2_keywords (0.0.5) + signet (0.19.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) stringio (3.1.0) thor (1.3.0) timeout (0.4.1) @@ -213,6 +254,7 @@ PLATFORMS aarch64-linux arm-linux arm64-darwin + ruby x86-linux x86_64-darwin x86_64-linux @@ -223,7 +265,10 @@ DEPENDENCIES debug (>= 1.6.2) dotenv-rails faraday + fcm + good_job (~> 3.28) jbuilder + jwt pg puma (>= 5.0) rack-cors diff --git a/app/controllers/api/v1/device_integrations_controller.rb b/app/controllers/api/v1/device_integrations_controller.rb new file mode 100644 index 0000000..c118ce3 --- /dev/null +++ b/app/controllers/api/v1/device_integrations_controller.rb @@ -0,0 +1,25 @@ +class Api::V1::DeviceIntegrationsController < ApiController + before_action :set_device + + def create + integration = ApiEnvironment.integration_token_authenticator.verify_token(params.require(:integration_pairing_token), expected_aud: 'device', max_exp_in: 24.hours) + @device.integrations << integration + + head 204 + end + + def index + @integrations = @device.integrations + end + + def destroy + @device.integrations.delete(Integration.find(params.require(:id))) + head 204 + end + + private + + def set_device + @device = ApiEnvironment.device_token_authenticator.verify_token(authenticity_token) + end +end diff --git a/app/controllers/api/v1/devices_controller.rb b/app/controllers/api/v1/devices_controller.rb new file mode 100644 index 0000000..31f2f54 --- /dev/null +++ b/app/controllers/api/v1/devices_controller.rb @@ -0,0 +1,11 @@ +class Api::V1::DevicesController < ApiController + def create + @device = Device.create!(device_params) + end + + private + + def device_params + params.permit(:display_name, :platform, :public_key, :registration_id) + end +end diff --git a/app/controllers/api/v1/documents_controller.rb b/app/controllers/api/v1/documents_controller.rb index d044d19..ca7ad93 100644 --- a/app/controllers/api/v1/documents_controller.rb +++ b/app/controllers/api/v1/documents_controller.rb @@ -1,21 +1,39 @@ class Api::V1::DocumentsController < ApplicationController - before_action :set_document, only: %i[ show datatosign sign visualization destroy ] - before_action :set_key, only: %i[ show create datatosign sign visualization ] - before_action :decrypt_document_content, only: %i[ show sign datatosign visualization destroy] + before_action :set_document, only: %i[ show datatosign sign visualization destroy parameters ] + before_action :set_key, only: %i[ create datatosign sign visualization ] + before_action :decrypt_document_content, only: %i[ sign datatosign visualization destroy] # GET /documents/1 def show - @signers = @document.signers + modified_since = request.headers.to_h['HTTP_IF_MODIFIED_SINCE'] + + response.set_header('Last-Modified', @document.last_signed_at + 1.seconds) + if modified_since && Time.zone.parse(modified_since) >= @document.last_signed_at + render json: nil, status: 304 + else + # expecting 1s polling on this operation + set_key + decrypt_document_content + + @signers = @document.signers + end end # POST /documents def create p = document_params - @document = Document.new(parameters: p[:parameters]) - @document.encrypt_file(@key, p[:document][:filename], p[:payloadMimeType], p[:document][:content]) - @document.validate_parameters(p[:document][:content]) + filename, mimetype = create_filename_and_mimetype(p[:document][:filename], p[:payload_mime_type]) + mimetype, content, parameters = Document.convert_to_b64(mimetype, p[:document][:content], p[:parameters]) - render json: @document.errors, status: :unprocessable_entity unless @document.save + @document = Document.new(parameters: parameters) + @document.encrypt_file(@key, filename, mimetype, content) + @document.validate_parameters(content, mimetype) + + unless @document.save + render json: @document.errors, status: :unprocessable_entity + else + response.set_header('Last-Modified', @document.last_signed_at + 1.seconds) + end end # POST /documents/1/visualization @@ -25,14 +43,14 @@ def visualization # POST /documents/1/datatosign def datatosign - @document.set_add_timestamp if datatosign_params[:addTimestamp] - @signing_certificate = datatosign_params.require(:signingCertificate) + @document.set_add_timestamp if datatosign_params[:add_timestamp] + @signing_certificate = datatosign_params.require(:signing_certificate) @result = @document.datatosign(@signing_certificate) end # POST /documents/1/sign def sign - @signer = @document.sign(@key, sign_params[:dataToSignStructure], sign_params[:signedData]) + @signer = @document.sign(@key, sign_params[:data_to_sign_structure], sign_params[:signed_data]) render json: @document.errors, status: :unprocessable_entity unless @signer @document = Document.find(params[:id]) @@ -44,20 +62,34 @@ def destroy @document.destroy! end + # GET /documents/1/parameters + def parameters + end + private def set_document @document = Document.find(params[:id]) end def set_key - @key = request.headers.to_h['HTTP_X_ENCRYPTION_KEY'] || params[:encryptionKey] + key_b64 = request.headers.to_h['HTTP_X_ENCRYPTION_KEY'] || params[:encryption_key] + begin + begin + # AVM app somehow sends urlsafe base64 even if its source code doesn't seem so + @key = Base64.urlsafe_decode64(key_b64) + rescue ArgumentError + @key = Base64.strict_decode64(key_b64) + end + rescue => e + raise AvmUnauthorizedError.new("ENCRYPTION_KEY_MALFORMED", "Encryption key Base64 decryption failed.", e.message) + end + raise AvmUnauthorizedError.new("ENCRYPTION_KEY_MISSING", "Encryption key not provided.", "Encryption key must be provided either in X-Encryption-Key header or as encryptionKey query parameter.") unless @key - # TODO - # raise AvmUnauthorizedError.new("ENCRYPTION_KEY_MALFORMED", "Encryption key invalid.", "Encryption key must be a 64 character long hexadecimal string.") unless validate_key(@key) + raise AvmUnauthorizedError.new("ENCRYPTION_KEY_MALFORMED", "Encryption key invalid.", "Encryption key must be a base64 string encoding 32 bytes long key.") unless validate_key(@key) end def validate_key(key) - key.length == 64 and !key[/\H/] + key.length == 32 end def decrypt_document_content @@ -68,22 +100,69 @@ def document_params params.require(:parameters) d = params.require(:document) d.require(:content) - d.require(:filename) - params.require(:payloadMimeType) - params.permit(:encryptionKey, :payloadMimeType, :key, :document => [:filename, :content], :parameters => [:level, :container]) + params.permit( + :encryption_key, + :payload_mime_type, + :document => [:filename, :content], + :parameters => [ + :checkPDFACompliance, + :autoLoadEform, + :level, + :container, + :containerXmlns, + :embedUsedSchemas, + :identifier, + :packaging, + :digestAlgorithm, + :en319132, + :infoCanonicalization, + :propertiesCanonicalization, + :keyInfoCanonicalization, + :schema, + :schemaIdentifier, + :transformation, + :transformationIdentifier, + :transformationLanguage, + :transformationMediaDestinationTypeDescription, + :transformationTargetEnvironment + ] + ) end def datatosign_params - params.permit(:encryptionKey, :id, :signingCertificate, :addTimestamp) + params.permit(:encryption_key, :id, :signing_certificate, :add_timestamp) end def sign_params - params.require(:signedData) - dts = params.require(:dataToSignStructure) + params.require(:signed_data) + dts = params.require(:data_to_sign_structure) dts.require(:dataToSign) dts.require(:signingTime) dts.require(:signingCertificate) - params.permit(:encryptionKey, :id, :signedData, :returnSignedDocument, :dataToSignStructure => [:dataToSign, :signingTime, :signingCertificate]) + params.permit(:encryption_key, :id, :signed_data, :return_signed_document, :data_to_sign_structure => [:dataToSign, :signingTime, :signingCertificate]) + end + + def create_filename_and_mimetype(filename, mimetype) + return [filename, mimetype] if filename && mimetype + + Mime::Type.register("application/vnd.etsi.asic-e+zip", "asice", [], [".sce"]) + Mime::Type.register("application/vnd.etsi.asic-s+zip", "asics", [], [".scs"]) + Mime::Type.register("application/vnd.gov.sk.xmldatacontainer+xml", "xdcf") + Mime::Type.register("application/msword", "doc") + Mime::Type.register("application/vnd.openxmlformats-officedocument.wordprocessingml.document", "docx") + Mime::Type.register("application/vnd.ms-excel", "xls") + Mime::Type.register("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx") + Mime::Type.register("application/vnd.ms-powerpoint", "ppt") + Mime::Type.register("application/vnd.openxmlformats-officedocument.presentationml.presentation", "pptx") + + unless filename + filename = 'document.' + Mime::Type.lookup(mimetype).symbol.to_s + else + mimetype = Mime::Type.lookup_by_extension(File.extname(filename).downcase.gsub('.', '')).to_s + raise AvmServiceBadRequestError.new({code: "FAILED_PARSING_MIMETYPE", message: "Could not parse mimetype", details: "Could not parse mimetype from: #{filename}"}.to_json) if mimetype.empty? + end + + [filename, mimetype] end end diff --git a/app/controllers/api/v1/integration_devices_controller.rb b/app/controllers/api/v1/integration_devices_controller.rb new file mode 100644 index 0000000..eb2789b --- /dev/null +++ b/app/controllers/api/v1/integration_devices_controller.rb @@ -0,0 +1,18 @@ +class Api::V1::IntegrationDevicesController < ApiController + before_action :set_integration + + def index + @devices = @integration.devices + end + + def destroy + @integration.devices.delete(Device.find(params.require(:id))) + render :head + end + + private + + def set_integration + @integration = ApiEnvironment.integration_token_authenticator.verify_token(authenticity_token) + end +end diff --git a/app/controllers/api/v1/integrations_controller.rb b/app/controllers/api/v1/integrations_controller.rb new file mode 100644 index 0000000..4384b2f --- /dev/null +++ b/app/controllers/api/v1/integrations_controller.rb @@ -0,0 +1,11 @@ +class Api::V1::IntegrationsController < ApiController + def create + @integration = Integration.create!(integration_params) + end + + private + + def integration_params + params.permit(:display_name, :platform, :public_key, :pushkey) + end +end diff --git a/app/controllers/api/v1/sign_requests_controller.rb b/app/controllers/api/v1/sign_requests_controller.rb new file mode 100644 index 0000000..209696c --- /dev/null +++ b/app/controllers/api/v1/sign_requests_controller.rb @@ -0,0 +1,14 @@ +class Api::V1::SignRequestsController < ApiController + before_action :set_integration + + def create + @integration.notify_devices(params.require(:document_guid), params.require(:document_encryption_key)) + render json: {}, status: 200 + end + + private + + def set_integration + @integration = ApiEnvironment.integration_token_authenticator.verify_token(authenticity_token) + end +end diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb new file mode 100644 index 0000000..d77ab0f --- /dev/null +++ b/app/controllers/api_controller.rb @@ -0,0 +1,15 @@ +class ApiController < ApplicationController + rescue_from JWT::DecodeError do |error| + if error.message == 'Nil JSON web token' + render_bad_request(RuntimeError.new(:no_credentials)) + else + render_unauthorized(error.message) + end + end + + private + + def authenticity_token + (ActionController::HttpAuthentication::Token.token_and_options(request)&.first || params[:token])&.squish.presence + end +end diff --git a/app/controllers/apple_controller.rb b/app/controllers/apple_controller.rb new file mode 100644 index 0000000..41c7f0f --- /dev/null +++ b/app/controllers/apple_controller.rb @@ -0,0 +1,20 @@ +class AppleController < ApplicationController + def apple_app_site_association + render :json => { + "applinks": { + "details": [ + { + "appIDs": [ + "44U4JSRX4Z.digital.slovensko.avm" + ], + "components": [ + { + "/": "/api/*" + } + ] + } + ] + } + } + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 8999038..f95109f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,6 @@ class ApplicationController < ActionController::API + before_action :transform_params + rescue_from ActionController::ParameterMissing do |e| render json: { code: "PARAMETER_MISSING", @@ -7,6 +9,10 @@ class ApplicationController < ActionController::API }, status: 400 end + rescue_from AvmServiceSignatureNotInTactError do |e| + render json: JSON.parse(e.message), status: 409 + end + rescue_from AvmServiceBadRequestError do |e| render json: JSON.parse(e.message), status: 422 end @@ -30,4 +36,19 @@ class ApplicationController < ActionController::API details: "Provided encryption key failed to decrypt document." }, status: 403 end + + private + + def render_bad_request(exception) + render status: :bad_request, json: { message: exception.message } + end + + def render_unauthorized(key = "credentials") + headers['WWW-Authenticate'] = 'Token realm="API"' + render status: :unauthorized, json: { message: "Unauthorized " + key } + end + + def transform_params + request.parameters.transform_keys!(&:underscore) + end end diff --git a/app/errors/avm_bad_encryption_key_error.rb b/app/errors/avm_bad_encryption_key_error.rb index 055d8aa..71fd59d 100644 --- a/app/errors/avm_bad_encryption_key_error.rb +++ b/app/errors/avm_bad_encryption_key_error.rb @@ -1,9 +1,2 @@ class AvmBadEncryptionKeyError < StandardError - def initialize(body_str) - @message = body_str - end - - def message - @message - end end diff --git a/app/errors/avm_service_signature_not_in_tact_error.rb b/app/errors/avm_service_signature_not_in_tact_error.rb new file mode 100644 index 0000000..8f9e8ce --- /dev/null +++ b/app/errors/avm_service_signature_not_in_tact_error.rb @@ -0,0 +1,9 @@ +class AvmServiceSignatureNotInTactError < StandardError + def initialize(body_str) + @message = body_str + end + + def message + @message + end +end diff --git a/app/jobs/delete_expired_documents_job.rb b/app/jobs/delete_expired_documents_job.rb new file mode 100644 index 0000000..d6939e1 --- /dev/null +++ b/app/jobs/delete_expired_documents_job.rb @@ -0,0 +1,5 @@ +class DeleteExpiredDocumentsJob < ApplicationJob + def perform + Document.where('created_at < ?', Time.now - 24.hours).destroy_all + end +end diff --git a/app/jobs/delete_expired_tokens_job.rb b/app/jobs/delete_expired_tokens_job.rb new file mode 100644 index 0000000..babf122 --- /dev/null +++ b/app/jobs/delete_expired_tokens_job.rb @@ -0,0 +1,5 @@ +class DeleteExpiredTokensJob < ApplicationJob + def perform + Token.where('expires_at < ?', Time.now).destroy_all + end +end diff --git a/app/lib/api_environment.rb b/app/lib/api_environment.rb new file mode 100644 index 0000000..562ae23 --- /dev/null +++ b/app/lib/api_environment.rb @@ -0,0 +1,21 @@ +module ApiEnvironment + extend self + + def integration_token_authenticator + @integration_token_authenticator ||= ApiTokenAuthenticator.new( + public_key_reader: -> (sub) { OpenSSL::PKey::EC.new(Integration.find(sub).public_key) }, + return_handler: -> (sub) { Integration.find(sub) } + ) + end + + def device_token_authenticator + @device_token_authenticator ||= ApiTokenAuthenticator.new( + public_key_reader: -> (sub) { OpenSSL::PKey::EC.new(Device.find(sub).public_key) }, + return_handler: -> (sub) { Device.find(sub) } + ) + end + + def fcm_notifier + @fcm_notifier ||= FcmNotifer.new + end +end diff --git a/app/lib/api_token_authenticator.rb b/app/lib/api_token_authenticator.rb new file mode 100644 index 0000000..bf3169a --- /dev/null +++ b/app/lib/api_token_authenticator.rb @@ -0,0 +1,35 @@ +# See https://tools.ietf.org/html/rfc7519 + +class ApiTokenAuthenticator + JTI_PATTERN = /\A[0-9a-z\-_]{32,256}\z/i + + def initialize(public_key_reader:, return_handler:) + @public_key_reader = public_key_reader + @return_handler = return_handler + end + + def verify_token(token, expected_aud: nil, max_exp_in: 15.minutes) + options = { + algorithm: 'ES256', + verify_jti: -> (jti) { jti =~ JTI_PATTERN }, + } + + key_finder = -> (_, payload) do + puts payload + @public_key_reader.call(payload['sub']) + rescue => e + raise e + raise JWT::InvalidSubError + end + + payload, _ = JWT.decode(token, nil, true, options, &key_finder) + sub, exp, jti, aud = payload['sub'], payload['exp'], payload['jti'], payload['aud'] + + raise JWT::ExpiredSignature unless exp.is_a?(Integer) + raise JWT::InvalidPayload, :jwt_expired if exp > (Time.now + max_exp_in).to_i + raise JWT::InvalidPayload, :invalid_aud unless aud == expected_aud + raise JWT::InvalidJtiError unless Token.write("#{sub}-#{jti}", '1', expires_in: max_exp_in, namespace: 'jti') + + @return_handler.call(sub) + end +end diff --git a/app/lib/fcm_notifier.rb b/app/lib/fcm_notifier.rb new file mode 100644 index 0000000..d8ed19a --- /dev/null +++ b/app/lib/fcm_notifier.rb @@ -0,0 +1,46 @@ +class FcmNotifier + def initialize + @fcm = FCM.new( + ENV.fetch('FIREBASE_API_TOKEN', ''), + StringIO.new(ENV.fetch('FIREBASE_CREDENTIALS')), + ENV.fetch('FIREBASE_PROJECT_ID') + ) + end + + def notify(registration_id, message) + Rails.logger.debug "FcmNorifier.notify for registration_id: #{registration_id} with message: #{message}" + + message = { + 'token': registration_id, + 'data': { + encrypted_message: message + }, + 'notification': { + title: "Podpisovanie dokumentu", + body: "Podpíšte elektronický dokument", + }, + 'android': { + priority: "high", + ttl: "300s" + }, + 'apns': { + payload: { + headers:{ + 'apns-priority': "10", + 'apns-expiration': "#{Time.zone.now.to_i + 300}" + }, + aps: { + category: "SIGN_EXTERNAL_DOCUMENT" + } + } + } + # TODO: consider analytic labels + # 'fcm_options': { + # analytics_label: 'Label' + # } + } + + # TODO: handle errors and implement exponential back-off + fcm.send_v1(message) + end +end diff --git a/app/models/device.rb b/app/models/device.rb new file mode 100644 index 0000000..6948424 --- /dev/null +++ b/app/models/device.rb @@ -0,0 +1,51 @@ +class Device < ApplicationRecord + has_many :devices_integrations + has_many :integrations, -> { distinct }, through: :devices_integrations + + validates :platform, presence: true + validates :display_name, presence: true + validates :registration_id, presence: true + validates :public_key, presence: true + validate :public_key_format_should_be_valid + + + def notify(integration, document_guid, document_encryption_key) + encrpyted_message = encrypt_message({ + document_guid: document_guid, + key: document_encryption_key + }.to_json, + integration.pushkey + ) + + if ['ios', 'android'].include? platform + ApiEnvironment.fcm_notifier.notify(registration_id, encrpyted_message) + elsif ['ntfy'].include? platform + conn = Faraday.new(url: registration_id) do |f| + f.headers["X-Actions"] = "view, Sign, https://autogram.slovensko.digital/api/v1/qr-code?guid=#{document_guid}&key=#{CGI.escape document_encryption_key}" + end + + conn.post + else + Rails.logger.warn "Unrecognized device platform: #{platform}" + end + end + + private + + def public_key_format_should_be_valid + begin + begin + OpenSSL::PKey::EC.new(public_key) + rescue + OpenSSL::PKey::EC.new("-----BEGIN PUBLIC KEY-----\n#{public_key}\n-----END PUBLIC KEY-----") + end + rescue OpenSSL::PKey::ECError => e + errors.add(:public_key, e.message) + end + end + + def encrypt_message(message, key) + encryptor = ActiveSupport::MessageEncryptor.new(Base64.decode64 key) + encryptor.encrypt_and_sign(message) + end +end diff --git a/app/models/devices_integration.rb b/app/models/devices_integration.rb new file mode 100644 index 0000000..daa2586 --- /dev/null +++ b/app/models/devices_integration.rb @@ -0,0 +1,5 @@ +class DevicesIntegration < ApplicationRecord + belongs_to :device + belongs_to :integration + validates :device, uniqueness: {scope: :integration, message: 'integration pair already exists'} +end diff --git a/app/models/document.rb b/app/models/document.rb index fc658f9..15e79fd 100644 --- a/app/models/document.rb +++ b/app/models/document.rb @@ -1,12 +1,25 @@ class Document < ApplicationRecord has_one_attached :encrypted_content + attr_accessor :decrypted_content - def decrypt_content(key) - @decrypted_content = encrypted_content.download + before_create :set_last_signed_at + + def self.convert_to_b64(mimetype, content, params) + return [mimetype, content, params] if mimetype.include?('base64') + + params[:schema] = Base64.strict_encode64(params[:schema]) if params[:schema] + params[:transformation] = Base64.strict_encode64(params[:transformation]) if params[:transformation] + + [mimetype + ';base64', Base64.strict_encode64(content), params] end - def decrypted_content - Base64.strict_encode64(@decrypted_content) + def decrypt_content(key) + decryptor = ActiveSupport::MessageEncryptor.new(key) + begin + @decrypted_content = decryptor.decrypt_and_verify(encrypted_content.download) + rescue ActiveSupport::MessageEncryptor::InvalidMessage => e + raise AvmBadEncryptionKeyError.new + end end def decrypted_content_mimetype_b64 @@ -14,14 +27,14 @@ def decrypted_content_mimetype_b64 end def encrypt_file(key, filename, mimetype, content) - if mimetype.include?('base64') - content = Base64.decode64(content) - end - encrypted_content.attach(filename: filename, content_type: mimetype, io: StringIO.new(content)) + encryptor = ActiveSupport::MessageEncryptor.new(key) + encrypted_data = encryptor.encrypt_and_sign(Base64.strict_encode64(Base64.decode64(content))) + + encrypted_content.attach(filename: filename, content_type: mimetype, io: StringIO.new(encrypted_data)) end - def validate_parameters(content) - avm_service.validate_parameters(self, content) + def validate_parameters(content, mimetype) + avm_service.validate_parameters(self, content, mimetype) end def signers @@ -51,12 +64,18 @@ def sign(key, data_to_sign, signed_data) document = response['documentResponse'] encrypt_file(key, document.dig('filename'), document['mimeType'], document['content']) + self.last_signed_at = Time.current + save! response['signer'] end private + def set_last_signed_at + self.last_signed_at = self.created_at + end + def avm_service Avm::Environment.avm_api end diff --git a/app/models/integration.rb b/app/models/integration.rb new file mode 100644 index 0000000..27c8f0d --- /dev/null +++ b/app/models/integration.rb @@ -0,0 +1,47 @@ +class Integration < ApplicationRecord + has_many :devices_integrations + has_many :devices, -> { distinct }, through: :devices_integrations + + encrypts :pushkey + + validates :platform, presence: true + validates :display_name, presence: true + validates :public_key, presence: true + validates :pushkey, presence: true + validate :public_key_format_should_be_valid + + + def notify_devices(document_guid, document_encryption_key) + devices.each { |d| d.notify(self, document_guid, document_encryption_key) } + end + + private + + def public_key_format_should_be_valid + begin + begin + OpenSSL::PKey::EC.new(public_key) + rescue + OpenSSL::PKey::EC.new("-----BEGIN PUBLIC KEY-----\n#{public_key}\n-----END PUBLIC KEY-----") + end + rescue OpenSSL::PKey::ECError => e + errors.add(:public_key, e.message) + end + end + + def pushkey_format_should_be_vakud + begin + key = nil + begin + key = Base64.urlsafe_decode64(pushkey) + rescue ArgumentError + key = Base64.strict_decode64(pushkey) + end + + errors.add(:pushkey, "aes256 key must be 32 bytes long") unless key.length == 32 + rescue => e + errors.add(:pushkey, e.message) + end + + end +end diff --git a/app/models/token.rb b/app/models/token.rb new file mode 100644 index 0000000..fef762e --- /dev/null +++ b/app/models/token.rb @@ -0,0 +1,11 @@ +class Token < ApplicationRecord + validates :key, uniqueness: {scope: :namespace} + + def self.write(key, value, expires_in: 1.hour, namespace: 'default') + begin + create!(namespace: namespace, key: key, value: value, expires_at: Time.now + expires_in) + rescue ActiveRecord::RecordInvalid + nil + end + end +end diff --git a/app/views/api/v1/device_integrations/index.json.jbuilder b/app/views/api/v1/device_integrations/index.json.jbuilder new file mode 100644 index 0000000..34cde7d --- /dev/null +++ b/app/views/api/v1/device_integrations/index.json.jbuilder @@ -0,0 +1,5 @@ +json.array! @integrations do |i| + json.integration_id i.id + json.platform i.platform + json.display_name i.display_name +end diff --git a/app/views/api/v1/devices/create.json.jbuilder b/app/views/api/v1/devices/create.json.jbuilder new file mode 100644 index 0000000..192fe3b --- /dev/null +++ b/app/views/api/v1/devices/create.json.jbuilder @@ -0,0 +1 @@ +json.guid @device.id \ No newline at end of file diff --git a/app/views/api/v1/documents/parameters.json.jbuilder b/app/views/api/v1/documents/parameters.json.jbuilder new file mode 100644 index 0000000..126c15d --- /dev/null +++ b/app/views/api/v1/documents/parameters.json.jbuilder @@ -0,0 +1 @@ +json.parameters @document.parameters diff --git a/app/views/api/v1/integrations/create.json.jbuilder b/app/views/api/v1/integrations/create.json.jbuilder new file mode 100644 index 0000000..03c319c --- /dev/null +++ b/app/views/api/v1/integrations/create.json.jbuilder @@ -0,0 +1 @@ +json.guid @integration.id \ No newline at end of file diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb deleted file mode 100644 index 3aac900..0000000 --- a/app/views/layouts/mailer.html.erb +++ /dev/null @@ -1,13 +0,0 @@ - - -
- - - - - - <%= yield %> - - diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb deleted file mode 100644 index 37f0bdd..0000000 --- a/app/views/layouts/mailer.text.erb +++ /dev/null @@ -1 +0,0 @@ -<%= yield %> diff --git a/config/application.rb b/config/application.rb index 67aed6e..888beb8 100644 --- a/config/application.rb +++ b/config/application.rb @@ -8,7 +8,7 @@ Bundler.require(*Rails.groups) if ['development', 'test'].include? ENV['RAILS_ENV'] - Dotenv::Railtie.load + Dotenv::Rails.load end module AutogramServer @@ -37,5 +37,29 @@ class Application < Rails::Application config.exceptions_app = self.routes Rails.application.config.generators { |g| g.orm :active_record, primary_key_type: :uuid } + + config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'] + config.active_record.encryption.deterministic_key = ENV['ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY'] + config.active_record.encryption.key_derivation_salt = ENV['ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT'] + + config.active_job.queue_adapter = :good_job + config.good_job.max_threads = 2 + config.good_job.execution_mode = :async + config.good_job.enable_cron = true + config.good_job.smaller_number_is_higher_priority = true + + config.good_job.cron = { + delete_expired_documents: { + cron: "*/15 * * * *", + class: "DeleteExpiredDocumentsJob", + set: {priority: -5} + }, + delete_expired_tokens: { + cron: "*/15 * * * *", + class: "DeleteExpiredTokensJob", + set: {priority: -10} + } + } + end end diff --git a/config/routes.rb b/config/routes.rb index 1c93c38..7105a26 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,10 +9,21 @@ get 'visualization' post 'datatosign' post 'sign' + get 'parameters' end end + + resources :devices, only: [:create] + resources :integrations, only: [:create] + resources :device_integrations, path: '/device-integrations', only: [:index, :create, :destroy] + resources :integration_devices, path: '/integration-devices', only: [:index, :destroy] + resource :sign_request, path: '/sign-request', only: [:create] + + get '/qr-code', to: redirect('https://sluzby.slovensko.digital/autogram-v-mobile/#download', status: 302) end end + + get '/apple-app-site-association' => 'apple#apple_app_site_association' # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. diff --git a/db/migrate/20240412122434_add_integrations_and_devices.rb b/db/migrate/20240412122434_add_integrations_and_devices.rb new file mode 100644 index 0000000..0a96c71 --- /dev/null +++ b/db/migrate/20240412122434_add_integrations_and_devices.rb @@ -0,0 +1,24 @@ +class AddIntegrationsAndDevices < ActiveRecord::Migration[7.1] + def change + create_table :devices, id: :uuid do |t| + t.string :registration_id, null: false + t.string :platform, null: false + t.string :display_name, null: false + t.string :public_key, null: false + + t.timestamps + end + + create_table :integrations, id: :uuid do |t| + t.string :platform, null: false + t.string :display_name, null: false + t.string :public_key, null: false + t.string :pushkey, null: false + + t.timestamps + end + + create_join_table :devices, :integrations, column_options: {type: :uuid} + add_index :devices_integrations, [:device_id, :integration_id], unique: true + end +end diff --git a/db/migrate/20240422051835_add_token.rb b/db/migrate/20240422051835_add_token.rb new file mode 100644 index 0000000..9284070 --- /dev/null +++ b/db/migrate/20240422051835_add_token.rb @@ -0,0 +1,14 @@ +class AddToken < ActiveRecord::Migration[7.1] + def change + create_table :tokens do |t| + t.string :namespace, null: false + t.string :key, null: false + t.string :value, null: false + t.datetime :expires_at, null: false + + t.timestamps + end + + add_index :tokens, [:namespace, :key], unique: true + end +end diff --git a/db/migrate/20240422052823_create_good_jobs.rb b/db/migrate/20240422052823_create_good_jobs.rb new file mode 100644 index 0000000..ff16de0 --- /dev/null +++ b/db/migrate/20240422052823_create_good_jobs.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +class CreateGoodJobs < ActiveRecord::Migration[7.1] + def change + # Uncomment for Postgres v12 or earlier to enable gen_random_uuid() support + # enable_extension 'pgcrypto' + + create_table :good_jobs, id: :uuid do |t| + t.text :queue_name + t.integer :priority + t.jsonb :serialized_params + t.datetime :scheduled_at + t.datetime :performed_at + t.datetime :finished_at + t.text :error + + t.timestamps + + t.uuid :active_job_id + t.text :concurrency_key + t.text :cron_key + t.uuid :retried_good_job_id + t.datetime :cron_at + + t.uuid :batch_id + t.uuid :batch_callback_id + + t.boolean :is_discrete + t.integer :executions_count + t.text :job_class + t.integer :error_event, limit: 2 + t.text :labels, array: true + end + + create_table :good_job_batches, id: :uuid do |t| + t.timestamps + t.text :description + t.jsonb :serialized_properties + t.text :on_finish + t.text :on_success + t.text :on_discard + t.text :callback_queue_name + t.integer :callback_priority + t.datetime :enqueued_at + t.datetime :discarded_at + t.datetime :finished_at + end + + create_table :good_job_executions, id: :uuid do |t| + t.timestamps + + t.uuid :active_job_id, null: false + t.text :job_class + t.text :queue_name + t.jsonb :serialized_params + t.datetime :scheduled_at + t.datetime :finished_at + t.text :error + t.integer :error_event, limit: 2 + t.text :error_backtrace, array: true + end + + create_table :good_job_processes, id: :uuid do |t| + t.timestamps + t.jsonb :state + end + + create_table :good_job_settings, id: :uuid do |t| + t.timestamps + t.text :key + t.jsonb :value + t.index :key, unique: true + end + + add_index :good_jobs, :scheduled_at, where: "(finished_at IS NULL)", name: :index_good_jobs_on_scheduled_at + add_index :good_jobs, [:queue_name, :scheduled_at], where: "(finished_at IS NULL)", name: :index_good_jobs_on_queue_name_and_scheduled_at + add_index :good_jobs, [:active_job_id, :created_at], name: :index_good_jobs_on_active_job_id_and_created_at + add_index :good_jobs, :concurrency_key, where: "(finished_at IS NULL)", name: :index_good_jobs_on_concurrency_key_when_unfinished + add_index :good_jobs, [:cron_key, :created_at], where: "(cron_key IS NOT NULL)", name: :index_good_jobs_on_cron_key_and_created_at_cond + add_index :good_jobs, [:cron_key, :cron_at], where: "(cron_key IS NOT NULL)", unique: true, name: :index_good_jobs_on_cron_key_and_cron_at_cond + add_index :good_jobs, [:finished_at], where: "retried_good_job_id IS NULL AND finished_at IS NOT NULL", name: :index_good_jobs_jobs_on_finished_at + add_index :good_jobs, [:priority, :created_at], order: { priority: "DESC NULLS LAST", created_at: :asc }, + where: "finished_at IS NULL", name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished + add_index :good_jobs, [:priority, :created_at], order: { priority: "ASC NULLS LAST", created_at: :asc }, + where: "finished_at IS NULL", name: :index_good_job_jobs_for_candidate_lookup + add_index :good_jobs, [:batch_id], where: "batch_id IS NOT NULL" + add_index :good_jobs, [:batch_callback_id], where: "batch_callback_id IS NOT NULL" + add_index :good_jobs, :labels, using: :gin, where: "(labels IS NOT NULL)", name: :index_good_jobs_on_labels + + add_index :good_job_executions, [:active_job_id, :created_at], name: :index_good_job_executions_on_active_job_id_and_created_at + end +end diff --git a/db/migrate/20240610211535_add_last_signed_at_to_document.rb b/db/migrate/20240610211535_add_last_signed_at_to_document.rb new file mode 100644 index 0000000..4cf0352 --- /dev/null +++ b/db/migrate/20240610211535_add_last_signed_at_to_document.rb @@ -0,0 +1,7 @@ +class AddLastSignedAtToDocument < ActiveRecord::Migration[7.1] + def change + add_column :documents, :last_signed_at, :datetime + Document.update_all('last_signed_at = updated_at') + change_column_null :documents, :last_signed_at, false + end +end diff --git a/db/schema.rb b/db/schema.rb index 2d8f083..3d11631 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_02_16_154821) do +ActiveRecord::Schema[7.1].define(version: 2024_06_10_211535) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -43,10 +43,125 @@ t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end + create_table "devices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "registration_id", null: false + t.string "platform", null: false + t.string "display_name", null: false + t.string "public_key", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "devices_integrations", id: false, force: :cascade do |t| + t.uuid "device_id", null: false + t.uuid "integration_id", null: false + t.index ["device_id", "integration_id"], name: "index_devices_integrations_on_device_id_and_integration_id", unique: true + end + create_table "documents", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.json "parameters" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.datetime "last_signed_at", null: false + end + + create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "description" + t.jsonb "serialized_properties" + t.text "on_finish" + t.text "on_success" + t.text "on_discard" + t.text "callback_queue_name" + t.integer "callback_priority" + t.datetime "enqueued_at" + t.datetime "discarded_at" + t.datetime "finished_at" + end + + create_table "good_job_executions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "active_job_id", null: false + t.text "job_class" + t.text "queue_name" + t.jsonb "serialized_params" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.text "error" + t.integer "error_event", limit: 2 + t.text "error_backtrace", array: true + t.index ["active_job_id", "created_at"], name: "index_good_job_executions_on_active_job_id_and_created_at" + end + + create_table "good_job_processes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.jsonb "state" + end + + create_table "good_job_settings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "key" + t.jsonb "value" + t.index ["key"], name: "index_good_job_settings_on_key", unique: true + end + + create_table "good_jobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.text "queue_name" + t.integer "priority" + t.jsonb "serialized_params" + t.datetime "scheduled_at" + t.datetime "performed_at" + t.datetime "finished_at" + t.text "error" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "active_job_id" + t.text "concurrency_key" + t.text "cron_key" + t.uuid "retried_good_job_id" + t.datetime "cron_at" + t.uuid "batch_id" + t.uuid "batch_callback_id" + t.boolean "is_discrete" + t.integer "executions_count" + t.text "job_class" + t.integer "error_event", limit: 2 + t.text "labels", array: true + t.index ["active_job_id", "created_at"], name: "index_good_jobs_on_active_job_id_and_created_at" + t.index ["batch_callback_id"], name: "index_good_jobs_on_batch_callback_id", where: "(batch_callback_id IS NOT NULL)" + t.index ["batch_id"], name: "index_good_jobs_on_batch_id", where: "(batch_id IS NOT NULL)" + t.index ["concurrency_key"], name: "index_good_jobs_on_concurrency_key_when_unfinished", where: "(finished_at IS NULL)" + t.index ["cron_key", "created_at"], name: "index_good_jobs_on_cron_key_and_created_at_cond", where: "(cron_key IS NOT NULL)" + t.index ["cron_key", "cron_at"], name: "index_good_jobs_on_cron_key_and_cron_at_cond", unique: true, where: "(cron_key IS NOT NULL)" + t.index ["finished_at"], name: "index_good_jobs_jobs_on_finished_at", where: "((retried_good_job_id IS NULL) AND (finished_at IS NOT NULL))" + t.index ["labels"], name: "index_good_jobs_on_labels", where: "(labels IS NOT NULL)", using: :gin + t.index ["priority", "created_at"], name: "index_good_job_jobs_for_candidate_lookup", where: "(finished_at IS NULL)" + t.index ["priority", "created_at"], name: "index_good_jobs_jobs_on_priority_created_at_when_unfinished", order: { priority: "DESC NULLS LAST" }, where: "(finished_at IS NULL)" + t.index ["queue_name", "scheduled_at"], name: "index_good_jobs_on_queue_name_and_scheduled_at", where: "(finished_at IS NULL)" + t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)" + end + + create_table "integrations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "platform", null: false + t.string "display_name", null: false + t.string "public_key", null: false + t.string "pushkey", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "tokens", force: :cascade do |t| + t.string "namespace", null: false + t.string "key", null: false + t.string "value", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["namespace", "key"], name: "index_tokens_on_namespace_and_key", unique: true end add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" diff --git a/lib/avm_api.rb b/lib/avm_api.rb index 5812e77..9e7675a 100644 --- a/lib/avm_api.rb +++ b/lib/avm_api.rb @@ -3,14 +3,14 @@ def initialize(host:) @host = host end - def validate_parameters(document, content) + def validate_parameters(document, content, mimetype) response = Faraday.post(url('/parameters/validate'), { document: { filename: document.encrypted_content.filename, content: content }, parameters: document.parameters, - payloadMimeType: document.decrypted_content_mimetype_b64 + payloadMimeType: mimetype }.to_json) handle_response(response) @@ -76,6 +76,7 @@ def url(endpoint) def handle_response(response) raise AvmServiceInternalError.new(response.body) if response.status >= 500 + raise AvmServiceSignatureNotInTactError.new(response.body) if (response.status == 400 && JSON.parse(response.body)['code'] == 'SIGNATURE_NOT_IN_TACT') raise AvmServiceBadRequestError.new(response.body) if response.status >= 400 end end diff --git a/public/openapi.yaml b/public/openapi.yaml index 8044b0a..cdcabbe 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -12,10 +12,9 @@ tags: - name: Mobile2App - name: Desktop2App - Extension - name: Desktop2App - Client - -security: - - Header: [] - - Parameter: [] + - name: Minimal Integration + - name: QR code + - name: Integration paths: /documents: @@ -23,12 +22,29 @@ paths: tags: - Mobile2App - Desktop2App - Extension + security: + - Header: [ ] + - Parameter: [ ] + summary: Client app posts document along with some parameters to Server. description: Client app posts document along with some parameters to Server. Server encrypts the document using the provided EncryptionKey and stores it on a disk. requestBody: content: "application/json": schema: $ref: "#/components/schemas/DocumentPostRequestBody" + examples: + Plain TXT document: + $ref: "#/components/examples/XAdES-ASiC_E-TXT" + Slovak EForm with resources: + $ref: "#/components/examples/XAdES-ASiC_E-Base64-HTML" + Slovak EForm with auto-loaded resources: + $ref: "#/components/examples/XAdES-ASiC_E-Auto" + PDF document: + $ref: "#/components/examples/PAdES-PDF" + DOCX document: + $ref: "#/components/examples/XAdES-ASiC_E-DOCX" + Image with CAdES level: + $ref: "#/components/examples/CAdES-ASiC_E-PNG_md" parameters: - name: Accept in: header @@ -86,10 +102,11 @@ paths: get: tags: - Desktop2App - Extension - description: | - External system requests signed document at the end of the process. - - This endpoint is also designed for polling with the `If-Modified-Since` header (`TODO`). + security: + - Header: [ ] + - Parameter: [ ] + summary: External system requests signed document at the end of the process. + description: This endpoint is also designed for polling with the `If-Modified-Since` header. parameters: - name: guid in: path @@ -102,8 +119,7 @@ paths: required: false schema: type: string - pattern: '^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$' - example: 'Tue, 15 Oct 2019 12:45:26 GMT' + example: '2024-05-05 11:52:06 UTC' - name: Accept in: header schema: @@ -122,17 +138,15 @@ paths: Last-Modified: schema: type: string - pattern: '^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$' - example: 'Tue, 15 Oct 2019 12:45:26 GMT' - description: "`TODO` Datetime of the last-modified attribute of the requeste document." + example: '2024-05-05 11:52:06 UTC' + description: "Datetime of the last-modified attribute of the requeste document." 304: description: Requested document has not been modified since `If-Modified-Since` header headers: Last-Modified: schema: type: string - pattern: '^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$' - example: 'Tue, 15 Oct 2019 12:45:26 GMT' + example: '2024-05-05 11:52:06 UTC' description: Datetime of the last-modified attribute of the requeste document. 400: description: Bad request @@ -174,10 +188,13 @@ paths: $ref: "#/components/schemas/BadGatewayErrorResponseBody" delete: - description: | - External system requests signed document at the end of the process. - - This endpoint is also designed for polling with the `If-Modified-Since` header. + tags: + - Desktop2App - Extension + security: + - Header: [ ] + - Parameter: [ ] + summary: External system requests signed document at the end of the process. + description: This endpoint is also designed for polling with the `If-Modified-Since` header. parameters: - name: guid in: path @@ -232,7 +249,10 @@ paths: tags: - Desktop2App - Client - Mobile2App - description: Client app requests encrypted document to visualize it. + security: + - Header: [ ] + - Parameter: [ ] + summary: Client app requests encrypted document to visualize it. parameters: - name: guid in: path @@ -293,11 +313,58 @@ paths: schema: $ref: "#/components/schemas/BadGatewayErrorResponseBody" + /documents/{guid}/parameters: + get: + tags: + - Desktop2App - Client + security: + - Header: [ ] + - Parameter: [ ] + summary: Client app gets the signature parameters of the doucment + parameters: + - name: guid + in: path + schema: + type: string + required: true + example: bfde97b4-ee27-47bc-97e2-5164ed96a92a + - name: Accept + in: header + schema: + type: string + default: application/json + required: true + allowEmptyValue: false + responses: + 200: + description: OK + content: + "application/json": + schema: + $ref: "#/components/schemas/SigningParameters" + 401: + description: EncryptionKey not provided + content: + application/json: + schema: + $ref: "#/components/schemas/EncryptionKeyNotProvidedErrorResponseBody" + 403: + description: EncryptionKey mismatch + content: + application/json: + schema: + $ref: "#/components/schemas/EncryptionKeyMismatchErrorResponseBody" + 404: + description: Not found + /documents/{guid}/validation: get: tags: - Mobile2App - description: Client app requests a signature validation report of the document. + security: + - Header: [ ] + - Parameter: [ ] + summary: Client app requests a signature validation report of the document. parameters: - name: guid in: path @@ -365,6 +432,10 @@ paths: tags: - Mobile2App - Desktop2App - Client + security: + - Header: [ ] + - Parameter: [ ] + summary: Client app gets datatosign based on provided signing certificate. description: Client app posts signing certificate to the server. Server decrypts the encrypted document from disk, computes DataToSign and returns it along exact signing time in milliseconds. The whole response object is later required for POST /sign request. parameters: - name: guid @@ -436,7 +507,10 @@ paths: tags: - Mobile2App - Desktop2App - Client - description: Create signed document using the SignedData obtained from client. + security: + - Header: [ ] + - Parameter: [ ] + summary: Create signed document using the SignedData obtained from client. parameters: - name: guid in: path @@ -518,8 +592,361 @@ paths: schema: $ref: "#/components/schemas/BadGatewayErrorResponseBody" + /integrations: + post: + tags: + - Integration + - Minimal Integration + summary: Integration registers itself at the server + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PostIntegrationRequestBody" + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/PostIntegrationResponse" + + /devices: + post: + tags: + - Integration + - Minimal Integration + summary: Device registers itself at the server + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PostDeviceRequestBody" + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/PostDeviceResponse" + + /device-integrations: + post: + tags: + - Integration + - Minimal Integration + security: + - Device JWT: [] + summary: Device registers itself for receiving sign requests (push notification) from given integration + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PostDeviceIntegrationsRequestBody" + responses: + 204: + description: OK + + get: + tags: + - Integration + security: + - Device JWT: [] + summary: Device retrieves a list of paired integrations + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/GetDeviceIntegrationsResponseBody" + + /device-integrations/{integration_id}: + delete: + tags: + - Integration + security: + - Device JWT: [] + summary: Device deletes integration from its paired integrations + parameters: + - name: integration_id + in: path + schema: + type: string + description: Identifier of the integration + example: 0d939eb1-8e14-41e5-9c7e-05e77641cc7b + required: true + allowEmptyValue: false + responses: + 204: + description: OK + 404: + description: Not found + + /integration-devices: + get: + tags: + - Integration + security: + - Integration JWT: [] + summary: Integration retrieves a list of paired devices + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/GetIntegrationDevicesResponseBody" + + /integration-devices/{device_id}: + delete: + tags: + - Integration + security: + - Integration JWT: [] + summary: Integration deletes device from its paired devices + parameters: + - name: device_id + in: path + schema: + type: string + description: Identifier of the device + example: 03de5319-a40d-48ea-b4fb-29d11e7017bb + required: true + allowEmptyValue: false + responses: + 204: + description: OK + 404: + description: Not found + + /sign-request: + post: + tags: + - Integration + - Minimal Integration + security: + - Integration JWT: [] + summary: Integration sends a sign request (push notification) to all paired signing devices + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PostSignRequestBody" + responses: + 200: + description: OK + callbacks: + myEvent: + 'FCM-notificaiton-SDK': + post: + description: | + This is a pseudo-description of data sent to subscibing device in `data` payload through Firebase Cloud Messaging. + + `encryptedMessage` conatins document GUID and its encryption key encrypted with AES256 pushkey shared between integration and device, + + Encrpyted message JSON: + ```json + { + "documentGuid": "bfde97b4-ee27-47bc-97e2-5164ed96a92a", + "key": "EeESAfZQh9OTf5qZhHZtgaDJpYtxZD6TIOQJzRgRFgQ=" + } + ``` + requestBody: + content: + application/json: + schema: + type: object + properties: + encryptedMessage: + type: string + example: ZhHZtgaDJpYtxZD6TIOQJzRgRFgQ... + required: + - message + responses: + 200: + description: OK + + /qr-code: + get: + tags: + - QR code + description: | + Example: `https://autogram.slovensko.digital/api/v1/qr-code?guid=e7e95411-66a1-d401-e063-0a64dbb6b796&key=EeESAfZQh9OTf5qZhHZtgaDJpYtxZD6TIOQJzRgRFgQ%3D&pushkey=R%2FrfN%2Bz129w1H2iftbr1GOKXdC3OxSJU9PZeHs%2BW7ts%3D&integration=eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiI3OGQ5MWRlNy0xY2MyLTQwZTQtOWE3MS0zODU4YjRmMDMxOWQiLCJleHAiOjE3MTI5MDk3MjAsImp0aSI6IjAwZTAxN2Y1LTI4MTAtNDkyNS04ODRlLWNiN2FhZDAzZDFhNiIsImF1ZCI6ImRldmljZSJ9.7Op6W2BvbX2_mgj9dkz1IiolEsQ1Z2a0AzpS5bj4pcG3CJ4Z8j9W3RQE95wrAj3t6nmd9JaGZSlCJNSV_myyLQ` + + + parameters: + - name: guid + in: query + schema: + type: string + description: GUID of a document + example: e7e95411-66a1-d401-e063-0a64dbb6b796 + required: true + - name: key + in: query + schema: + type: string + description: AES256 key in Base64 for the document + example: EeESAfZQh9OTf5qZhHZtgaDJpYtxZD6TIOQJzRgRFgQ= + required: true + - name: pushkey + in: query + schema: + type: string + description: AES256 key in Base64 for push notification content + example: R/rfN+z129w1H2iftbr1GOKXdC3OxSJU9PZeHs+W7ts= + required: false + - name: integration + in: query + schema: + type: string + description: 'JWT of source integration. Can be used to pair device with the integration. Must contain `aud: "device"` claim' + example: eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiI3OGQ5MWRlNy0xY2MyLTQwZTQtOWE3MS0zODU4YjRmMDMxOWQiLCJleHAiOjE3MTI5MDk3MjAsImp0aSI6IjAwZTAxN2Y1LTI4MTAtNDkyNS04ODRlLWNiN2FhZDAzZDFhNiIsImF1ZCI6ImRldmljZSJ9.7Op6W2BvbX2_mgj9dkz1IiolEsQ1Z2a0AzpS5bj4pcG3CJ4Z8j9W3RQE95wrAj3t6nmd9JaGZSlCJNSV_myyLQ + required: false + responses: + 200: + description: OK + components: schemas: + PostSignRequestBody: + type: object + properties: + documentGuid: + type: string + example: e1b2fafc-59c4-46de-ac0a-83aa782184e9 + description: GUID of the document to sign + documentEncryptionKey: + type: string + description: AES256 encryption key in hexadecimal form (64 characters) that is used to encrypt and decrypt signing doucment. + example: EeESAfZQh9OTf5qZhHZtgaDJpYtxZD6TIOQJzRgRFgQ= + required: + - documentGuid + - documentEncryptionKey + + PostIntegrationRequestBody: + type: object + properties: + platform: + type: string + description: Platform identifier + example: extension + displayName: + type: string + description: Human-readable name of the integration + example: Autogram browser extension + publicKey: + type: string + description: Integration's ES256 public key that shall be used to authenticate its JWT tokens. Either with or without "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----". + example: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEGoqUt0JPQgvvMhLNxFQkwOoClKxDK8D8oW+qmakxQuOJuy/V0uKPJbhRkEWz8WPFZCUXUr1LsD5E667h5StmLw==\n-----END PUBLIC KEY-----" + pushkey: + type: string + description: Integration's AES256 key in Base64 form that shall be used to encrypt notificaiton messages sent to device + example: "R/rfN+z129w1H2iftbr1GOKXdC3OxSJU9PZeHs+W7ts=" + required: + - platform + - displayName + - publicKey + - pushkey + + PostIntegrationResponse: + type: object + properties: + guid: + type: string + description: Assigned identifier + example: 57820662-f56c-4b2f-97d1-e95306aee6db + required: + - guid + + PostDeviceRequestBody: + type: object + properties: + platform: + type: string + description: Platform of the signing device used to determine which notification service to use. (`android` or `ios`) + example: android + registrationId: + type: string + description: Identifier of the app instance registration entry in the notification service (APNS or GCM) + example: idk32b83ef7-21fe-4120-b8fa-d9f6aba05731 + displayName: + type: string + description: Human-readable name of the device + example: John's phone + publicKey: + type: string + description: Device's ES256 public key that shall be used to authenticate its JWT tokens. Either with or without "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----". + example: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1iPVm0v/ZNM04587g10F54JVIrMZqWnlOXuGjOvcYsuweTYxuXafP8aJ6kIXe+jQhjeldm2mQZzSZ4ceLRq0yA==\n-----END PUBLIC KEY-----" + required: + - platform + - registrationId + - displayName + - publicKey + + PostDeviceResponse: + type: object + properties: + guid: + type: string + description: Assigned identifier + example: 03de5319-a40d-48ea-b4fb-29d11e7017bb + + GetDeviceIntegrationsResponseBody: + type: array + items: + type: object + properties: + integrationId: + type: string + description: Identifier of the integration + example: 0d939eb1-8e14-41e5-9c7e-05e77641cc7b + platform: + type: string + description: Platform identifier + example: extension + displayName: + type: string + description: Human-readable name of the integration + example: Autogram browser extension + required: + - platform + - integrationId + - displayName + + GetIntegrationDevicesResponseBody: + type: array + items: + type: object + properties: + deviceId: + type: string + description: Identifier of the device + example: 03de5319-a40d-48ea-b4fb-29d11e7017bb + platform: + type: string + description: Platform of the signing device used to determine which notification service to use. (`android` or `ios`) + example: android + displayName: + type: string + description: Human-readable name of the device + example: John's phone + required: + - platform + - deviceId + - displayName + + PostDeviceIntegrationsRequestBody: + type: object + properties: + integrationPairingToken: + type: string + description: "JWT token provided by integration on pairing. The token must contain `aud: \"device\"` claim." + example: eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiI3OGQ5MWRlNy0xY2MyLTQwZTQtOWE3MS0zODU4YjRmMDMxOWQiLCJleHAiOjE3MTI5MDk3MjAsImp0aSI6IjAwZTAxN2Y1LTI4MTAtNDkyNS04ODRlLWNiN2FhZDAzZDFhNiIsImF1ZCI6ImRldmljZSJ9.7Op6W2BvbX2_mgj9dkz1IiolEsQ1Z2a0AzpS5bj4pcG3CJ4Z8j9W3RQE95wrAj3t6nmd9JaGZSlCJNSV_myyLQ + required: + - integrationPairingToken + DocumentPostRequestBody: type: object properties: @@ -529,7 +956,7 @@ components: $ref: "#/components/schemas/SigningParameters" payloadMimeType: type: string - example: application/pdf;base64 + example: text/plain;base64 description: | MIME type for document content and signature parameters XSLT transformation and XSD schema. Binary files should be encoded using base64, e.g., `application/pdf;base64`. @@ -538,7 +965,6 @@ components: If omitted, mimetype is decided based on document.filename and content is expected to be in Base64. required: - document - - parameters SigningCertificate: type: string @@ -671,29 +1097,60 @@ components: - issuedBy Document: - description: JSON object that is encrypted for type: object properties: filename: type: string - description: Filename of the document - example: sample_document.pdf + description: Filename of the document. payloadMimeType must be provided if empty. Also, if payloadMimeType is empty, filename must be provided and mimetype must be understood from extension. + example: sample_document.txt content: type: string description: Base64 encrypted content of the document example: ZXhhbXBsZSBzdHJpbmcgaW4gYmFzZTY0Cg== required: - content - - filename + + SignatureLevelResponse: + type: object + properties: + level: + type: string + description: Signature level. + example: XAdES_BASELINE_B + enum: + - PAdES_BASELINE_B + - PAdES_BASELINE_T + - XAdES_BASELINE_B + - XAdES_BASELINE_T + - CAdES_BASELINE_B + - CAdES_BASELINE_T SigningParameters: type: object description: Signing parameters same as in the Autogram API properties: + checkPDFACompliance: + type: boolean + default: false + description: "Check for PDF/A compliance and show warning if not compliant." + + autoLoadEform: + type: boolean + default: false + description: + Try to find XSD and XSLT for a given eForm and load them automatically. Useful for visualizing and signing eForms. + If true, schema, transformation, conatinerXmlns, container, packaging, and identifier parameters are ignored. + If resources are not found, the response is 422. + If provided document is an ASiC_E container conatining XML Datacontainer or it is an XML Datacontainer itself, XSLT found is used for visualiztion of signing document. Also, XSD and XSLT hashes are compared with hashes of XSD and XSLT found in XML Data Container EForm. If they differ, the response is 422. + If the provided document is an XML document, Autogram will try to parse xmlns from root element and find resources based on its value. + If successful, XML Datacontainer with xmls="http://data.gov.sk/def/container/xmldatacontainer+xml/1.1" is created, the document is validated against the XSD and visualized using the XSLT. If XSD validation fails, the response is 422. + The XSLT transformation is found based on transformationLanguage (defaults to user preferred), transformationMediaDestinationTypeDescription (default XHTML, then HTML, then TXT), and transformationTargetEnvironment. + If multiple transformations meet the criteria, the first one found is used. + level: type: string description: Signature level. - example: PAdES_BASELINE_B + example: XAdES_BASELINE_B enum: - PAdES_BASELINE_B - PAdES_BASELINE_T @@ -701,15 +1158,131 @@ components: - XAdES_BASELINE_T - CAdES_BASELINE_B - CAdES_BASELINE_T + container: type: string description: Type of Advanced Signature Container. Defaults to null - no container. - example: + example: ASiC-E enum: - ASiC-E - ASiC-S - required: - - level + + containerXmlns: + type: string + enum: + - http://data.gov.sk/def/container/xmldatacontainer+xml/1.1 + example: http://data.gov.sk/def/container/xmldatacontainer+xml/1.1 + description: XML namespace for the XML Datacontainer. Specifies if xmldatacontainer should be created from XML. Doesn't create xmldatacontainer if payloadMimeType is application/vnd.gov.sk.xmldatacontainer+xml already. Accepts http://data.gov.sk/def/container/xmldatacontainer+xml/1.1 only. Defaults to null. Is ignored with autoLoadEform true. + + embedUsedSchemas: + type: boolean + example: false + description: When creating XML Datacontainer, parameter indicates whether to embed XSD and XML or reference them. Practically this should be only used for ORSR EForms in which case (when identifier contains "justice.gov.sk/Forms") this parameter is overridden to true. + + identifier: + type: string + example: https://data.gov.sk/id/egov/eform/App.GeneralAgenda/1.9 + description: Optional identifier of the document template. Required if containerXmlns is http://data.gov.sk/def/container/xmldatacontainer+xml/1.1. Defaults to null. Is ignored with autoLoadEform true. + + packaging: + type: string + enum: + - ENVELOPED + - ENVELOPING + default: ENVELOPED + description: Optional form of packaging used with XML. ENVELOPED adds the signature as a child of the root element while ENVELOPING wraps the XML in a new element. Only applies to XAdES signatures. Must be ENVELOPING when used without ASiC container and with non XML documents. Is ignored with autoLoadEform true. + + digestAlgorithm: + type: string + enum: + - SHA256 + - SHA384 + - SHA512 + default: SHA256 + description: Optional algorithm used to calculate digests. + + en319132: + type: boolean + default: false + description: Optional flag to control whether the signature should be made according to ETSI EN 319132 for XAdES and ETSI EN 319122 for CAdES and PAdES. + + infoCanonicalization: + type: string + enum: + - INCLUSIVE + - EXCLUSIVE + - INCLUSIVE_WITH_COMMENTS + - EXCLUSIVE_WITH_COMMENTS + - INCLUSIVE_11 + - INCLUSIVE_11_WITH_COMMENTS + default: INCLUSIVE + description: Optional info canonicalization method. + + propertiesCanonicalization: + type: string + enum: + - INCLUSIVE + - EXCLUSIVE + - INCLUSIVE_WITH_COMMENTS + - EXCLUSIVE_WITH_COMMENTS + - INCLUSIVE_11 + - INCLUSIVE_11_WITH_COMMENTS + default: INCLUSIVE + description: Optional properties canonicalization method. + + keyInfoCanonicalization: + type: string + enum: + - INCLUSIVE + - EXCLUSIVE + - INCLUSIVE_WITH_COMMENTS + - EXCLUSIVE_WITH_COMMENTS + - INCLUSIVE_11 + - INCLUSIVE_11_WITH_COMMENTS + default: INCLUSIVE + description: Optional key info canonicalization method. + + schema: + type: string + example: '