A robust, centralized document signing and tracking system built with Clean Architecture, leveraging asymmetric cryptography and chained digital signatures to ensure absolute data integrity, non-repudiation, and an immutable chronological audit trail.
+-------------------------------------------------------------------+
| MOBILE CLIENT (Flutter) |
| |
| +-----------------------+ +---------------------------+ |
| | Biometric Auth | | Local Secure Storage | |
| | (Local Authentication)| | (Private Key - NEVER exits) |
| +-----------------------+ +---------------------------+ |
| | | |
| +-----------------+----------------+ |
| | |
| Generates Signature |
| (Hash + Private Key -> Digital Signature) |
+---------------------------------|---------------------------------+
| HTTP (REST API)
v
+-------------------------------------------------------------------+
| BACKEND SERVER (Hono + TypeScript) |
| |
| +-----------------------------------------------------------+ |
| | Clean Architecture Layers: | |
| | - Domain Core (Entities & Chaining Rules) | |
| | - Use Cases (Upload, Sign, Verify) | |
| | - Interface Adapters (Hono Controllers) | |
| | - Infrastructure (Database ORM / Storage) | |
| +-----------------------------------------------------------+ |
| | |
| Verifies using Public Key |
+---------------------------------|---------------------------------+
|
v
+-------------------------------------------------------------------+
| DATABASE (PostgreSQL / Relational) |
| |
| - Users (id, username, email, publicKey) |
| - Documents (id, title, filePath, originalHash, uploaderId) |
| - Signatures (id, documentId, userId, previousSignatureId, data) |
+-------------------------------------------------------------------+
- On Device: When a user registers through the Flutter mobile app, a cryptographic key pair (Private Key and Public Key) is generated locally.
- Storage Split:
- Private Key: Stored securely in the device's hardware-backed secure storage (
flutter_secure_storage) and locked behind biometric verification (local_auth). It never leaves the physical phone. - Public Key: Transmitted to the server and stored in the PostgreSQL database mapped to the user profile.
- Private Key: Stored securely in the device's hardware-backed secure storage (
- An administrator or user uploads a document (e.g., PDF) to the platform via the mobile application.
- The server stores the file and calculates its initial cryptographic hash (
originalHash).
When a user signs a document, the application builds a secure verification chain:
- First Signer: Combines the document hash with their Private Key to generate
Signature 1. - Subsequent Signers: Combines the document hash, the previous signature reference (
previousSignatureId), and their own Private Key to generate the next signature in the stack. - Database Representation: Stored sequentially using relational foreign keys (
previousSignatureId), allowing the backend to reconstruct and verify the complete history of approvals.
| Column | Type | Constraints | Description |
|---|---|---|---|
userId |
UUID / Serial | Primary Key | Unique identifier for the user |
username |
VARCHAR | NOT NULL | User display name |
email |
VARCHAR | UNIQUE, NOT NULL | User account email |
publicKey |
TEXT | NOT NULL | Public key uploaded from the mobile device |
createdAt |
TIMESTAMP | DEFAULT NOW() | Account creation timestamp |
| Column | Type | Constraints | Description |
|---|---|---|---|
documentId |
UUID / Serial | Primary Key | Unique document identifier |
title |
VARCHAR | NOT NULL | Title or description of the document |
filePath |
TEXT | NOT NULL | Storage path (e.g., cloud object storage or server disk) |
originalHash |
TEXT | NOT NULL | Initial SHA-256 hash of the uploaded document |
uploaderId |
UUID | Foreign Key (Users) |
User who initially uploaded the document |
uploadDate |
TIMESTAMP | DEFAULT NOW() | Timestamp of upload |
| Column | Type | Constraints | Description |
|---|---|---|---|
signatureId |
UUID / Serial | Primary Key | Unique signature event identifier |
documentId |
UUID | Foreign Key (Documents) |
Target document being signed |
userId |
UUID | Foreign Key (Users) |
User who applied this signature |
previousSignatureId |
UUID | Foreign Key (Signatures, Nullable) |
Points to the prior signature in the chain (enforces stacking) |
signatureData |
TEXT | NOT NULL | The cryptographic signature output |
signedAt |
TIMESTAMP | DEFAULT NOW() | Timestamp when the signature was applied |
- Mobile Frontend: Flutter (Cross-platform UI, Secure Storage, Local Biometric Authentication)
- Backend API: Hono running on TypeScript (High performance, minimal footprint, edge-ready)
- Architecture Pattern: Clean Architecture (Strict separation of concerns: Domain Core, Use Cases, Controllers, Infrastructure)
- Database: PostgreSQL (Relational integrity, ACID compliance, optimized for sequential querying and relational audit trails)
- Absolute Data Integrity: Any modification to the document or prior signatures invalidates the entire mathematical chain instantly.
- Non-Repudiation: Because private keys are bound to user hardware and biometrics, signers cannot falsely deny their actions.
- Zero-Trust Server Design: Compromising the central database or server does not allow an attacker to forge user signatures, as private keys are never stored server-side.
- Verifiable Audit Trail: Provides an unalterable, chronological sequence of approvals suitable for administrative and official workflows.
Set DATABASE_URL and JWT_SECRET before running the server or any of the scripts below (both are documented in .env.example; the server refuses to start without JWT_SECRET).
Only one endpoint requires the admin role (POST /documents), and by design there is no way to grant it over HTTP — registration (POST /users) always creates a regular user, on purpose. A fresh deployment therefore starts with zero admins, and the product stays inert (nobody can upload a document) until an operator with database access promotes someone. This is expected, not a bug — do this once per environment:
- Run migrations:
npm run db:migrate - (Dev only) Seed fixture data:
npm run db:seed - Register a user through the app (or via
POST /users) — this is how that person gets an identity. - Promote that user to admin from a machine with
DATABASE_URLaccess:npm run db:promote-admin -- <that user's email> - Restart the client, or wait for the user's current session token to expire.
Promotion is not instant. The server reads the admin flag from the JWT, not from the database, on every request — so a promotion only takes effect the next time that user obtains a token: immediately if they restart the app (or otherwise re-authenticate), or automatically within an hour when their current token expires. Seeing the upload control stay hidden right after running the script is expected, not a failure — it is the fail-closed trade-off described in the design spec, and it resolves itself without further action.
npm run db:demote-admin -- <email> returns an admin to a regular user. Same constraints as promotion: it needs DATABASE_URL access, there is no HTTP path to it, and it takes effect on that user's next token rather than immediately.
Note that the same delay cuts the other way here: a revoked admin keeps whatever privileges their current token carries until it expires, up to an hour. If you are revoking someone urgently rather than tidying up, rotate JWT_SECRET and restart the server — that invalidates every issued token at once, forcing all users to re-authenticate and pick up their current roles.
Demoting the last remaining admin is refused, because it would leave nobody able to upload or verify and there is no HTTP path back. Promote a replacement first, or pass --force if leaving zero admins is genuinely what you want:
npm run db:demote-admin -- <email> --force