Привет, Хабр. Компаниям часто приходится подписывать договоры и акты с клиентами. Полноценный ЭДО — это долго и дорого для простых задач, а сканы по почте и личные визиты — неудобны.

Закон № 63-ФЗ разрешает использовать простую электронную подпись (ПЭП). Это обычный код из СМС на телефон. Такой способ подтверждает согласие клиента и подходит для большинства гражданских договоров.

В статье расскажем, как собрать на Python сервис для подписания документов. Вы сможете встроить его в свои ИТ-процессы.

Архитектура решения

Мы соберём простой сервис, чтобы подтверждать согласие клиента и проверять подлинность подписанных документов.

Как работает подписание

  1. Пользователь загружает PDF-документ

  2. Сервис вычисляет и сохраняет хеш документа

  3. Клиент вводит номер телефона и получает СМС с одноразовым кодом

  4. После ввода кода система создаст штамп, встраивает его в PDF и добавляет хеш файла со штампом

Документ всегда сопровождается проверяемым цифровым слепком.

Компоненты системы

  • Бэкенд на Python и Flask принимает файлы, проверяет коды и управляет статусами подписания

  • Хранилище SQLite с номерами телефонов, хешами и временем подписания. Сами PDF лежат отдельно — в облаке или на диске

  • СМС-подтверждение через SMS API МТС Exolve

  • PDF-движок рисует штамп и встраивает его в файл, не меняя основной текст страниц

Контроль целостности

Мы сохраняем хеш до и после подписи. Это поможет вам убедиться, что документ настоящий и в него не вносили правки после того, как клиент ввёл код.

Возможности и ограничения

Сервис подтверждает, что клиент владеет номером телефона и согласен с условиями. Он обеспечивает проверяемость файла, но не заменяет полноценный ЭДО и не устанавливает личность по паспорту. Это простое решение для быстрой автоматизации.

Шаг 0. Подготовка

Соберём настройки в одном конфиге, вынесем туда пути к файлам, секреты и доступы к СМС. Так вы быстрее запустите проект и перенесёте его в продакшен без правок кода.

Также ограничим размер файла до 16 МБ — этого хватит для обычных PDF и защитит систему от слишком тяжёлых загрузок.

import os
class Config:
   # В реальном проекте ключи берем из os.environ
   SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key')
   EXOLVE_API_KEY = "YOUR_API_KEY"  # Ваш ключ от МТС Exolve
   EXOLVE_SENDER = "ExolveSMS"      # Имя отправителя


   # Настройки базы и папок
   SQLALCHEMY_DATABASE_URI = 'sqlite:///edo.db'
   UPLOAD_FOLDER = os.path.join(os.getcwd(), 'uploads')


   # Security: Ограничиваем размер файла (16 MB) и расширения
   MAX_CONTENT_LENGTH = 16 * 1024 * 1024
   ALLOWED_EXTENSIONS = {'pdf'}

Шаг 1. Модель данных

Сервис хранит не сами документы, а информацию о процессе. В базу мы записываем ключи доступа и контрольные суммы, а PDF-файлы отправляем в отдельное хранилище. В нашем примере это локальная папка, но для реальных задач лучше выбрать S3-совместимое решение.

Все этапы работы мы фиксируем в таблице sign_requests. В ней видно всё: от первой загрузки файла до готового документа со штампом.

Поля таблицы sign_requests

  • id — уникальный номер операции

  • status — состояние процесса: отправлено СМС, подтверждено или подписано

  • original_key — ссылка на исходный PDF в хранилище

  • original_hash — хеш оригинала. Он подтверждает, что клиент подписал именно ту версию файла, которую видел на экране

  • phone — номер телефона для проверки

  • sms_request_id или sms_code_hash — ID сообщения из SMS API

  • confirmed_at — время, когда клиент подтвердил код

  • signed_key — ссылка на готовый файл со штампом

  • signed_hash — хеш итогового документа для контроля целостности

  • created_at и updated_at — временные метки

models.py

import hashlib
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime


db = SQLAlchemy()


class DocumentTransaction(db.Model):
   id = db.Column(db.Integer, primary_key=True)
   filename = db.Column(db.String(255))
   phone_number = db.Column(db.String(20))


   # Храним оба состояния документа
   original_hash = db.Column(db.String(64), index=True)
   signed_hash = db.Column(db.String(64), index=True, nullable=True)


   status = db.Column(db.String(20), default='PENDING')  # PENDING -> SIGNED
   created_at = db.Column(db.DateTime, default=datetime.utcnow)
   signed_at = db.Column(db.DateTime, nullable=True)




def calculate_file_hash(file_stream):
   """Считает SHA-256 хеш файла. Важно: читаем чанками, чтобы не забить память."""
   sha256 = hashlib.sha256()
   file_stream.seek(0)
   while True:
       chunk = file_stream.read(65536)
       if not chunk:
           break
       sha256.update(chunk)
   file_stream.seek(0)  # Возвращаем каретку в начало для дальнейшей работы
   return sha256.hexdigest()

Шаг 2. Загрузка документа и отправка СМС

На этом шаге привязываем файл к конкретному подписанту и отправляем СМС для проверки.

Пользователь загружает PDF-файл и указываете номер телефона. Система сохранит документ в хранилище, вычислит его хеш и создаст запись в таблице sign_requests со статусом pending. Мы сразу привязываем номер к операции — он станет фактором подтверждения для простой электронной подписи.

Далее сервис отправляет СМС с одноразовым кодом через SMS API. Метод вернёт идентификатор sms_request_id: система запишет его в базу и переведёт операцию в статус sms_sent. На этом этапе мы не храним код в открытом виде. Сервис только подтверждает запрос к API и ждёт действий от пользователя.

С системе сохранится:

  • Исходный PDF и его хеш

  • Номер телефона подписанта

  • Идентификатор отправленного СМС

  • Статус sms_sent

Теперь можно переходить к проверке кода и фиксации сделки.

app.py

import logging
import os
import random
import requests
from datetime import datetime
from flask import Flask, request, session, render_template, send_file
from werkzeug.utils import secure_filename


# Импортируем наши модули (которые мы описали в других шагах)
from config import Config
from models import db, DocumentTransaction, calculate_file_hash
from pdf_utils import add_sign_stamp


app = Flask(__name__)
app.config.from_object(Config)


# Инициализируем БД
db.init_app(app)


# Настраиваем профессиональное логирование
# Audit Log важен для разбора спорных ситуаций в будущем
logging.basicConfig(
   level=logging.INFO,
   format='%(asctime)s [%(levelname)s] AUDIT: %(message)s',
   handlers=[
       logging.FileHandler("edo_service.log"),
       logging.StreamHandler()
   ]
)
logger = logging.getLogger("EDO_Service")


# Создаем таблицы БД при первом запуске (для простоты демо)
with app.app_context():
   if not os.path.exists(app.config['UPLOAD_FOLDER']):
       os.makedirs(app.config['UPLOAD_FOLDER'])
   db.create_all()




def allowed_file(filename):
   """Проверка расширения файла (Security Check)"""
   return '.' in filename and \
       filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']




def send_sms_code(phone, code):
   """Отправка SMS через МТС Exolve"""
   url = "https://api.exolve.ru/messaging/v1/SendSMS"
   headers = {"Authorization": f"Bearer {app.config['EXOLVE_API_KEY']}"}
   payload = {
       "number": app.config['EXOLVE_SENDER'],
       "destination": phone,
       "text": f"Код подписи: {code}. Никому не сообщайте."
   }
   try:
       resp = requests.post(url, headers=headers, json=payload, timeout=5)
       if resp.status_code == 200:
           logger.info(f"SMS отправлено на {phone}")
           return True
       logger.error(f"Ошибка SMS провайдера: {resp.text}")
       return False
   except Exception as e:
       logger.critical(f"Сбой сети при отправке SMS: {e}")
       return False




# --- Роуты приложения ---


@app.route('/', methods=['GET'])
def index():
   return render_template('index.html')




@app.route('/upload', methods=['POST'])
def upload_file():
   # 1. Валидация входных данных
   if 'file' not in request.files or 'phone' not in request.form:
       return "Некорректный запрос", 400


   file = request.files['file']
   phone = request.form['phone']


   if file.filename == '' or not allowed_file(file.filename):
       logger.warning(f"Попытка загрузки недопустимого файла: {file.filename}")
       return "Разрешены только PDF файлы", 400


   # 2. Сохраняем оригинал
   # Считаем хеш прямо из потока, не сохраняя пока файл
   orig_hash = calculate_file_hash(file.stream)


   safe_name = secure_filename(f"{orig_hash}.pdf")
   file_path = os.path.join(app.config['UPLOAD_FOLDER'], safe_name)
   file.save(file_path)


   logger.info(f"Загружен документ. Хеш: {orig_hash}, Инициатор: {phone}")


   # 3. Генерируем код и отправляем
   code = str(random.randint(1000, 9999))


   if send_sms_code(phone, code):
       # Сохраняем контекст операции в сессии
       session['signing_context'] = {
           'phone': phone,
           'orig_hash': orig_hash,
           'file_path': file_path,
           'code': code,
           'original_filename': file.filename
       }
       return render_template('verify.html', phone=phone)


   return "Ошибка отправки SMS", 500


if __name__ == '__main__':
   app.run(debug=True, port=5000)

Микрофронтенд

Мы сделали простой интерфейс для работы с клиентом. Это одна серверная HTML-страница без лишней логики: она принимает номер телефона и код из СМС, а затем передаёт данные на сервер.

<!DOCTYPE html>
<html lang="ru">
<head>
   <meta charset="UTF-8">
   <title>ПЭП Подпись</title>
   <style>
       body { font-family: sans-serif; display: flex; justify-content: center; height: 100vh; align-items: center; background: #f0f2f5; }
       .card { background: white; padding: 30px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); text-align: center; width: 300px; }
       input { width: 100%; padding: 10px; margin: 10px 0; border: 1px solid #ccc; border-radius: 6px; box-sizing: border-box; }
       button { width: 100%; padding: 10px; background: #007bff; color: white; border: none; border-radius: 6px; cursor: pointer; }
       button:hover { background: #0056b3; }
   </style>
</head>
<body>
   <div class="card">
       <h2>📑 Подписать PDF</h2>
       <form action="/upload" method="post" enctype="multipart/form-data">
           <input type="text" name="phone" placeholder="+79990000000" required>
           <input type="file" name="file" accept="application/pdf" required>
           <button type="submit">Получить код</button>
       </form>
   </div>
</body>
</html>

Шаг 3. Создание штампа

Когда клиент подтвердит СМС, сервис создаст штамп и встроит его в PDF. Это не криптографическая подпись, а наглядное подтверждение сделки.

Мы создаём штамп как отдельный PDF-слой и накладываем его на документ. Так мы не меняем исходный файл и избегаем ошибок с вёрсткой и шрифтами оригинала. В штампе мы фиксируем:

  • номер телефона клиента

  • дату и время подписи

  • ID операции

Для работы используем библиотеку reportlab — она точно расставляет элементы по заданным координатам.

Стандартные шрифты reportlab не понимают русский язык. Если оставить всё как есть, вместо текста в штампе появятся пустые квадраты. Поэтому подключаем TTF-шрифт arial.ttf с поддержкой Unicode.

Готовый штамп объединяем с оригиналом через библиотеку pypdf. В итоге:

  • Страницы оригинала остаются прежними

  • Штамп ложится отдельным слоем

  • Результат всегда предсказуем

Затем сервис вычислит хеш готового файла и сохранит его вместе с данными о документе.

pdf_utils.py

import io
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import letter
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont  # Исправлено: TTFont
from pypdf import PdfReader, PdfWriter


# Регистрируем шрифт с поддержкой кириллицы
try:
   # Скачайте файл arial.ttf (например, из Windows/Fonts) и положите в папку с проектом
   pdfmetrics.registerFont(TTFont('Arial', 'arial.ttf'))
   FONT_NAME = 'Arial'
except:
   print("⚠️ Шрифт arial.ttf не найден. Кириллица не будет отображаться!")
   FONT_NAME = 'Helvetica'




def add_sign_stamp(input_path, output_path, phone, date_str):
   packet = io.BytesIO()
   can = canvas.Canvas(packet, pagesize=letter)


   # Рисуем синюю рамку и текст
   can.setStrokeColorRGB(0, 0, 1)  # Синий цвет
   can.rect(50, 50, 500, 60)


   can.setFillColorRGB(0, 0, 1)  # Текст тоже синий
   can.setFont(FONT_NAME, 12)
   can.drawString(60, 90, f"ДОКУМЕНТ ПОДПИСАН ПРОСТОЙ ЭЛЕКТРОННОЙ ПОДПИСЬЮ")


   can.setFont(FONT_NAME, 10)
   can.drawString(60, 75, f"Телефон: {phone}")
   can.drawString(60, 60, f"Дата: {date_str} | ID: SMS-CONFIRMED")


   can.save()
   packet.seek(0)


   # Склеиваем слои
   new_pdf = PdfReader(packet)
   existing_pdf = PdfReader(open(input_path, "rb"))
   output = PdfWriter()


   for i in range(len(existing_pdf.pages)):
       page = existing_pdf.pages[i]
       if i == len(existing_pdf.pages) - 1:  # Штамп только на последней странице
           page.merge_page(new_pdf.pages[0])
       output.add_page(page)


   with open(output_path, "wb") as f:
       output.write(f)

Шаг 4. Сохранение результата и проверка подписи

Когда сервис создаст PDF, он запишет результат в базу и завершит операцию. Теперь файл можно проверить в любой момент.

Что сохраняет система

  • ссылку на подписанный PDF в хранилище

  • хеш итогового файла — signed_hash

  • время завершения сделки

После этого статус операции меняется на signed. Теперь исходный файл и данные о нём нельзя изменить.

Чтобы убедиться в подлинности документа, достаточно двух действий:

  1. Вычислите хеш файла

  2. Сравните его с signed_hash в базе

Если значения совпадают, значит, документ не меняли после подписи. При необходимости можно также проверить хеш оригинала через original_hash.

app.py:

from datetime import datetime
from models import DocumentTransaction, db
from pdf_utils import add_sign_stamp


@app.route('/verify', methods=['POST'])
def verify_code():
   user_code = request.form['code']
   ctx = session.get('signing_context')


   # Проверка кода (в продакшене добавьте лимит попыток!)
   if not ctx or ctx['code'] != user_code:
       return "Неверный код", 400


   signed_filename = f"signed_{ctx['orig_hash']}.pdf"
   signed_path = f"uploads/{signed_filename}"


   # Ставим штамп
   add_sign_stamp(ctx['file_path'], signed_path, ctx['phone'], datetime.utcnow().isoformat())


   # Считаем хеш ПОДПИСАННОГО файла
   with open(signed_path, 'rb') as f:
       signed_hash = calculate_file_hash(f)


   # Пишем транзакцию
   doc = DocumentTransaction(
       phone_number=ctx['phone'],
       original_hash=ctx['orig_hash'],
       signed_hash=signed_hash,  # Сохраняем "слепок" результата
       status='SIGNED',
       signed_at=datetime.utcnow()
   )
   db.session.add(doc)
   db.session.commit()


   return f"Успешно! <a href='/download/{signed_filename}'>Скачать подписанный файл</a>"

Шаг 5. Проверка подлинности документа

Когда документ стал самостоятельным артефактом, который можно передавать другим людям, должен быть способ установить его истинность. Для этого мы создали валидатор — он подтверждает, что файл не меняли после подписи.

Валидатор вычисляет хеш загруженного документа и сравнивает его с signed_hash, который мы сохранили в базе. Если значения совпадают, значит, в файл не вносили правки. Благодаря двойному хешированию, мы узнаем документ, даже если пользователь загрузит чистый оригинал, который был у него до подписания.

@app.route('/check_validity', methods=['POST'])
def check_validity():
   file = request.files['file']
   file_hash = calculate_file_hash(file.stream)


   # Ищем хеш в ОБЕИХ колонках БД (SQLAlchemy OR)
   doc = DocumentTransaction.query.filter(
       (DocumentTransaction.original_hash == file_hash) |
       (DocumentTransaction.signed_hash == file_hash)
   ).first()


   if not doc:
       return "❌ Документ не найден в реестре."


   if doc.status == 'SIGNED':
       return f"✅ Корректно. Подписан: {doc.signed_at} владельцем {doc.phone_number}"
   else:
       return "⚠️ Документ загружен, но процесс подписания не завершен."

Заключение

ПЭП через СМС помогает быстро подписывать документы с клиентами, когда скорость важнее формальностей. В основе решения лежит не сам PDF-файл, а фиксация процесса: контрольные суммы, время и владение номером телефона.

Помните об ограничениях модели. СМС подтверждает владение номером, но не личность человека. Если нужна строгая проверка, добавьте другие факторы — например, перевод с именной карты. Так вы свяжете ФИО и телефон в одной операции.

В нашем примере за целостность данных отвечает владелец базы. Чтобы исключить подмену записей, публикуйте хеши во внешнем хранилище или блокчейне. Это усложнит систему, зато подделать подпись задним числом станет математически невозможно.

Развивайте архитектуру под свои задачи. Вы сможете извлекать номер из реквизитов, генерировать PDF на лету или отправлять ссылку на подписание в мессенджеры. Сервис сам найдёт контакт и отправит СМС.

Этот подход даёт прозрачный результат, если вам нужно автоматизировать конкретный процесс, а не внедрять громоздкий ЭДО. Усиливайте систему, когда требования вырастут.

Код проекта на гитхабе.