Development of Document Management System on Blockchain
Classic task: notarized contract, work acceptance certificate or financial document must be verifiable by any party without contacting central organization. Existing solutions — centralized registries or PKI-infrastructure with CA — work while organizations trust each other and central regulator. In cross-border scenarios or disputes this condition breaks.
Blockchain here doesn't replace document storage system — it replaces notary. Document lives in secure storage (IPFS, S3), and fact of its existence at given time, immutability and list of authorized signers fixed on-chain. This fundamental separation must be understood from start.
Cryptographic Foundations of Document Flow
Document Commitment
Any proof-of-existence system built on one principle: hash(document) published on-chain. But implementation matters in details.
Simple hash (anti-pattern):
mapping(bytes32 => uint256) public timestamps;
function notarize(bytes32 docHash) external {
timestamps[docHash] = block.timestamp;
}
Problem: if two documents differ only in timestamp — they give different hashes, both will be "notarized". Problem deeper: if document revoked — no mechanism to reflect this. No link between hash and signatory — anyone can notarize someone else's document.
Correct structure:
struct DocumentRecord {
bytes32 contentHash; // keccak256 from content
bytes32 metadataHash; // hash from metadata (without content)
address registrant; // who registered
uint256 registeredAt;
DocumentStatus status;
bytes32[] signatories; // DID hashes of signatories
uint256 expiresAt; // 0 = indefinite
}
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)
For legally significant documents important not just fact of hash publication but proof document existed at time T. Problem: block.timestamp in EVM manipulable by miner/validator within ~15 seconds. For most use cases insignificant, but for legal documents better use:
-
Block hash commitment: publish
keccak256(docHash || blockHash(N-1))— tie to specific block, not just timestamp - Chainlink or other VRF oracle for additional entropy source
- Anchor to Bitcoin via OP_RETURN — Bitcoin timestamping via services like OpenTimestamps
function registerWithBlockCommitment(
bytes32 contentHash,
bytes32 metadataHash
) external returns (bytes32 docId) {
// Tie to hash of previous block
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;
}
Electronic Signature and Multi-Party Signing Workflow
EIP-712 Structured Signatures
For document signing use EIP-712 instead of simple ecrecover — typed structured data. This gives readable prompts in MetaMask and protection from cross-chain 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");
// Recover signer from EIP-712 signature
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);
// Check completion
if (_checkSigningComplete(docId)) {
doc.status = DocumentStatus.ACTIVE;
emit DocumentFullySigned(docId);
}
}
Multi-Party Approval Workflow
For complex documents (sale contract, work acceptance) need workflow with signing order:
struct SigningWorkflow {
bytes32 docId;
SigningStep[] steps;
uint256 currentStep;
bool isSequential; // sequential or parallel signature
}
struct SigningStep {
address[] requiredSigners;
uint256 threshold; // how many of requiredSigners must sign
uint256 deadline;
bool completed;
}
Example: work acceptance certificate
- Contractor signs (step 1, mandatory)
- Customer's technical supervisor signs (step 2, mandatory)
- Customer's financial director signs OR any of two authorized representatives (step 3, threshold 1 of 2)
Document Storage: Hybrid Architecture
IPFS + Encryption
Documents stored encrypted in IPFS. Encryption key managed separately — via 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> {
// Generate symmetric key
const aesKey = crypto.getRandomValues(new Uint8Array(32))
// Encrypt document
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)
// Encrypt AES key with public keys of each authorized address
const encryptedKeys: Record<string, string> = {}
for (const address of authorizedAddresses) {
const pubKey = await getPublicEncryptionKey(address) // from MetaMask
encryptedKeys[address] = encryptForPublicKey(pubKey, Buffer.from(aesKey))
}
// Publish to IPFS: encrypted document + 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()
}
Document Versioning
Documents in business workflow are edited. Need chain of versions with ability to prove version V2 is update of V1:
struct DocumentVersion {
bytes32 contentHash;
bytes32 previousVersionId; // 0x0 for first version
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 and Confidentiality
Zero-Knowledge Disclosure
In some cases need prove fact about document without revealing content. Example: prove contract sum > X without revealing exact sum.
Solved via ZK-proof (e.g., Groth16 or PLONK):
// Circom circuit: proof that contract sum > threshold
pragma circom 2.0.0;
template ContractAmountProof() {
signal input contractAmount; // private input
signal input threshold; // public
signal input documentHash; // public (verifier knows document)
signal output isAboveThreshold;
// Check that sum > threshold
component gt = GreaterThan(64);
gt.in[0] <== contractAmount;
gt.in[1] <== threshold;
isAboveThreshold <== gt.out;
// Binding to specific document (commitment)
// Verifier convinced we know contractAmount for this document
}
Selective Disclosure via NFC/QR
For physical documents (vehicle passport, quality certificate): QR-code contains docId. Scanning QR, verifier requests on-chain record and gets only public metadata. For access to full document need authorized wallet.
Integration with Legacy Systems
Most enterprise clients have ERP/ECM systems (SAP, 1C, OpenText). Integration via:
Webhook approach: on document creation in ECM — webhook called → oracle service → on-chain registration. Transparent to ERP users.
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
Blockchain-agnostic layer: develop abstraction over specific blockchain. Client receives documentId — UUID in familiar format. UUID → on-chain docId mapping stored in PostgreSQL. This allows blockchain migration without contract API changes.
Legal Aspects
Blockchain notarization has different legal status in different jurisdictions:
- EU (eIDAS 2.0) — recognizes electronic signatures, but blockchain timestamp ≠ qualified electronic signature automatically
- USA — ESIGN Act recognizes electronic signatures, but for specific use cases (mortgage, notary) requires more
- UAE — Dubai Blockchain Strategy, active blockchain document adoption
For legally binding documents develop hybrid approach: blockchain timestamp + qualified electronic signature (QES) via certified CA.
Project Stages
| Phase | Content | Duration |
|---|---|---|
| Requirements | Analysis of document flow, document types, participants, jurisdiction | 2–3 weeks |
| Core contracts | Document registry, signing workflow, access control | 3–4 weeks |
| Storage layer | IPFS integration, encryption, versioning | 2–3 weeks |
| API & Integration | REST API, webhooks, ERP-connectors | 3–4 weeks |
| Frontend | Signing interface, verification, management | 3–4 weeks |
| Security audit | Smart contracts + backend | 2–3 weeks |
| Pilot | Launch with real documents, feedback | 2–3 weeks |
Total: 17–24 weeks. Critical path — ERP system integration and legal expertise in specific jurisdiction.







