Разработка системы управления документооборотом на блокчейне
Классическая задача: нотариально заверенный контракт, акт приёмки или финансовый документ должен быть верифицируем любой стороной без обращения к центральной организации. Существующие решения — централизованные реестры или PKI-инфраструктура с CA — работают, пока организации доверяют друг другу и центральному регулятору. В cross-border сценариях или при спорах это условие перестаёт выполняться.
Блокчейн здесь не заменяет систему хранения документов — он заменяет нотариуса. Документ живёт в защищённом хранилище (IPFS, S3), а факт его существования в определённый момент времени, неизменность и список авторизованных подписантов фиксируются on-chain. Это принципиальное разделение нужно понимать с самого начала.
Cryptographic foundations документооборота
Document commitment
Любая proof-of-existence система строится на одном принципе: hash(document) публикуется on-chain. Но реализация важна в деталях.
Простой хеш (anti-pattern):
mapping(bytes32 => uint256) public timestamps;
function notarize(bytes32 docHash) external {
timestamps[docHash] = block.timestamp;
}
Проблема: если два документа отличаются только timestamp'ом — они дадут разные хеши, и оба будут «нотариально заверены». Проблема сильнее: если документ отозван — нет механизма это отразить. Нет связи между хешем и подписантом — кто угодно может нотариально заверить чужой документ.
Правильная структура:
struct DocumentRecord {
bytes32 contentHash; // keccak256 от содержимого
bytes32 metadataHash; // хеш от метаданных (без содержимого)
address registrant; // кто зарегистрировал
uint256 registeredAt;
DocumentStatus status;
bytes32[] signatories; // DID хеши подписантов
uint256 expiresAt; // 0 = бессрочно
}
enum DocumentStatus { PENDING, ACTIVE, REVOKED, EXPIRED }
mapping(bytes32 => DocumentRecord) public documents;
// docId = keccak256(contentHash + registrant + registeredAt)
event DocumentRegistered(
bytes32 indexed docId,
bytes32 indexed contentHash,
address indexed registrant
);
event DocumentSigned(
bytes32 indexed docId,
bytes32 indexed signatoryDid,
bytes signature
);
event DocumentRevoked(
bytes32 indexed docId,
address revokedBy,
string reason
);
Cryptographic timestamping (RFC 3161 on-chain)
Для юридически значимых документов важен не просто факт публикации хеша, а доказательство что документ существовал в момент T. Проблема: block.timestamp в EVM можно манипулировать майнером/валидатором в пределах ~15 секунд. Для большинства use cases это несущественно, но для юридических документов лучше использовать:
-
Block hash commitment: публикуем
keccak256(docHash || blockHash(N-1))— привязка к конкретному блоку, не просто timestamp - Chainlink или другой VRF oracle для дополнительного источника энтропии
- Anchor to Bitcoin через OP_RETURN — Bitcoin timestamping через сервисы типа OpenTimestamps
function registerWithBlockCommitment(
bytes32 contentHash,
bytes32 metadataHash
) external returns (bytes32 docId) {
// Привязываем к хешу предыдущего блока
bytes32 blockCommitment = keccak256(abi.encodePacked(
contentHash,
blockhash(block.number - 1),
block.timestamp,
msg.sender
));
docId = keccak256(abi.encodePacked(contentHash, msg.sender, block.timestamp));
documents[docId] = DocumentRecord({
contentHash: contentHash,
metadataHash: metadataHash,
registrant: msg.sender,
registeredAt: block.timestamp,
status: DocumentStatus.ACTIVE,
signatories: new bytes32[](0),
expiresAt: 0
});
emit DocumentRegistered(docId, contentHash, msg.sender);
return docId;
}
Электронная подпись и multi-party signing workflow
EIP-712 структурированные подписи
Для подписания документов вместо простого ecrecover используем EIP-712 — типизированные структурированные данные. Это даёт читаемые prompt'ы в MetaMask и защиту от межсетевого replay:
bytes32 private constant DOCUMENT_SIGNING_TYPEHASH = keccak256(
"DocumentSigning(bytes32 docId,bytes32 contentHash,uint256 signedAt,string signatoryRole)"
);
struct DocumentSigning {
bytes32 docId;
bytes32 contentHash;
uint256 signedAt;
string signatoryRole;
}
function signDocument(
bytes32 docId,
string calldata signatoryRole,
uint256 signedAt,
bytes calldata signature
) external {
DocumentRecord storage doc = documents[docId];
require(doc.status == DocumentStatus.ACTIVE, "Document not active");
require(signedAt <= block.timestamp, "Future timestamp");
require(block.timestamp - signedAt < 3600, "Signature too old");
// Восстанавливаем подписанта из EIP-712 подписи
bytes32 structHash = keccak256(abi.encode(
DOCUMENT_SIGNING_TYPEHASH,
docId,
doc.contentHash,
signedAt,
keccak256(bytes(signatoryRole))
));
bytes32 digest = _hashTypedDataV4(structHash);
address signer = ECDSA.recover(digest, signature);
require(isAuthorizedSignatory(signer, docId), "Not authorized signatory");
require(!hasAlreadySigned(signer, docId), "Already signed");
doc.signatories.push(keccak256(abi.encode(signer)));
emit DocumentSigned(docId, keccak256(abi.encode(signer)), signature);
// Проверяем completion
if (_checkSigningComplete(docId)) {
doc.status = DocumentStatus.ACTIVE;
emit DocumentFullySigned(docId);
}
}
Workflow multi-party approval
Для сложных документов (договор купли-продажи, акт приёмки) нужен workflow с порядком подписания:
struct SigningWorkflow {
bytes32 docId;
SigningStep[] steps;
uint256 currentStep;
bool isSequential; // последовательная или параллельная подпись
}
struct SigningStep {
address[] requiredSigners;
uint256 threshold; // сколько из requiredSigners должны подписать
uint256 deadline;
bool completed;
}
Пример: акт приёмки работ
- Исполнитель подписывает (шаг 1, обязателен)
- Технадзор заказчика подписывает (шаг 2, обязателен)
- Финансовый директор заказчика подписывает ИЛИ любой из двух авторизованных представителей (шаг 3, порог 1 из 2)
Хранение документов: гибридная архитектура
IPFS + Encryption
Документы хранятся зашифрованными в IPFS. Ключ шифрования управляется отдельно — через on-chain access control:
import { create } from 'ipfs-http-client'
import { encrypt, decrypt } from '@metamask/eth-sig-util'
async function uploadEncryptedDocument(
file: Buffer,
docId: string,
authorizedAddresses: string[]
): Promise<string> {
// Генерируем симметричный ключ
const aesKey = crypto.getRandomValues(new Uint8Array(32))
// Шифруем документ
const iv = crypto.getRandomValues(new Uint8Array(12))
const cipher = await crypto.subtle.importKey('raw', aesKey, 'AES-GCM', false, ['encrypt'])
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cipher, file)
// Шифруем AES ключ публичными ключами каждого авторизованного адреса
const encryptedKeys: Record<string, string> = {}
for (const address of authorizedAddresses) {
const pubKey = await getPublicEncryptionKey(address) // из MetaMask
encryptedKeys[address] = encryptForPublicKey(pubKey, Buffer.from(aesKey))
}
// Публикуем в IPFS: зашифрованный документ + encrypted keys bundle
const ipfs = create({ url: process.env.IPFS_API })
const { cid } = await ipfs.add(JSON.stringify({
encrypted: Buffer.from(encrypted).toString('base64'),
iv: Buffer.from(iv).toString('base64'),
encryptedKeys,
}))
return cid.toString()
}
Версионирование документов
Документы в business workflow редактируются. Нужна chain of versions с возможностью доказать что версия V2 является апдейтом V1:
struct DocumentVersion {
bytes32 contentHash;
bytes32 previousVersionId; // 0x0 для первой версии
address updatedBy;
uint256 updatedAt;
string changeDescription;
}
mapping(bytes32 => bytes32[]) public documentVersions; // docId → version hashes
mapping(bytes32 => DocumentVersion) public versionDetails;
function publishNewVersion(
bytes32 docId,
bytes32 newContentHash,
string calldata changeDescription
) external onlyDocumentOwner(docId) {
bytes32[] storage versions = documentVersions[docId];
bytes32 previousHash = versions.length > 0 ? versions[versions.length - 1] : bytes32(0);
bytes32 versionId = keccak256(abi.encodePacked(
docId, newContentHash, block.timestamp, versions.length
));
versionDetails[versionId] = DocumentVersion({
contentHash: newContentHash,
previousVersionId: previousHash,
updatedBy: msg.sender,
updatedAt: block.timestamp,
changeDescription: changeDescription
});
versions.push(versionId);
emit DocumentVersionPublished(docId, versionId, versions.length - 1);
}
Access Control и конфиденциальность
Zero-Knowledge disclosure
В ряде случаев нужно доказать факт о документе без раскрытия его содержимого. Пример: доказать что в договоре сумма > X, не раскрывая точную сумму.
Это решается через ZK-proof (например, Groth16 или PLONK):
// Circom circuit: доказательство что сумма в контракте > threshold
pragma circom 2.0.0;
template ContractAmountProof() {
signal input contractAmount; // приватный инпут
signal input threshold; // публичный
signal input documentHash; // публичный (верификатор знает документ)
signal output isAboveThreshold;
// Проверяем что сумма > порога
component gt = GreaterThan(64);
gt.in[0] <== contractAmount;
gt.in[1] <== threshold;
isAboveThreshold <== gt.out;
// Привязка к конкретному документу (commitment)
// Верификатор убеждается что мы знаем contractAmount для этого документа
}
Selective disclosure через NFC/QR
Для физических документов (паспорт транспортного средства, сертификат качества): QR-код содержит docId. Сканируя QR, верификатор запрашивает on-chain запись и получает только публичные метаданные. Для доступа к полному документу нужен авторизованный кошелёк.
Интеграция с legacy системами
Большинство enterprise-клиентов имеют ERP/ECM системы (SAP, 1C, OpenText). Интеграция через:
Webhook подход: при создании документа в ECM — вызывается webhook → oracle service → on-chain регистрация. Прозрачно для пользователей ERP.
OpenAPI adapter:
openapi: "3.0.0"
info:
title: Document Notarization API
paths:
/documents/register:
post:
summary: Register document hash on blockchain
requestBody:
content:
application/json:
schema:
type: object
properties:
documentId: { type: string }
contentHash: { type: string, pattern: "^0x[0-9a-f]{64}$" }
metadata: { type: object }
signatories:
type: array
items: { type: string } # Ethereum addresses
Блокчейн-агностичный слой: разрабатываем абстракцию поверх конкретного блокчейна. Клиент получает documentId — UUID в привычном формате. Маппинг UUID → on-chain docId хранится в PostgreSQL. Это позволяет мигрировать между блокчейнами без изменения API контрактов.
Юридические аспекты
Блокчейн-нотаризация имеет различный правовой статус в разных юрисдикциях:
- EU (eIDAS 2.0) — признаёт электронные подписи, но blockchain timestamp ≠ квалифицированная электронная подпись автоматически
- США — ESIGN Act признаёт электронные подписи, но для specific use cases (ипотека, нотариат) требуется больше
- ОАЭ — Dubai Blockchain Strategy, активное принятие blockchain-based documents
Для юридически обязывающих документов разрабатываем гибридный подход: blockchain timestamp + квалифицированная электронная подпись (КЭП) через сертифицированный CA.
Этапы проекта
| Фаза | Содержание | Срок |
|---|---|---|
| Requirements | Анализ документооборота, типы документов, участники, юрисдикция | 2–3 нед |
| Core contracts | Document registry, signing workflow, access control | 3–4 нед |
| Storage layer | IPFS integration, encryption, versioning | 2–3 нед |
| API & Integration | REST API, webhooks, ERP-коннекторы | 3–4 нед |
| Frontend | Интерфейс подписания, верификации, управления | 3–4 нед |
| Security audit | Смарт-контракты + backend | 2–3 нед |
| Pilot | Запуск с реальными документами, обратная связь | 2–3 нед |
Итого: 17–24 недели. Критический путь — интеграция с ERP-системами клиентов и юридическая экспертиза в конкретной юрисдикции.







