Desktop App
The desktop app is built with Tauri 2, combining a Rust backend for security-critical operations with a React TypeScript frontend for the UI.
Design Principle: PDF Bytes Never Leave the Machine
The React frontend handles user interaction -- file selection, signature placement, previews. All PDF manipulation happens in the Rust backend via Tauri commands. The only data that leaves the machine is:
- Anchor ID (random, also printed in the QR code)
- Composite hash (SHA-256 of the signing record)
- Encrypted signing record
- Previous signer's anchor ID (for chaining)
Rust Backend
Module Structure
src-tauri/src/
├── lib.rs App entry, plugin registration, deep link handling
├── state.rs AppState (API base URL, HTTP client, JWT)
├── anchor.rs Relay and verify API calls (RelayRequest, OnChainAnchor)
├── payload.rs Anchor ID and key generation, signing record, AES-128-GCM, QR URL
├── commands/
│ ├── auth.rs Keychain JWT/profile storage, open_auth_browser
│ ├── library.rs Persistent signature & text snippet storage
│ ├── pdf.rs Tauri commands: sign, verify, save, extract revision
│ └── documents.rs Document listing
└── pdf/
├── sign.rs Builds one signer's layer and checks the existing history
├── embed.rs Page content isolation, signature image, text field, and QR code embedding
├── chain.rs Signing history (/SignChainMeta) read and write
├── layers.rs Finds each signer's layer by its %%EOF
└── verify.rs Judges each signer's layer against the blockchain
Key Tauri Commands
| Command | Description |
|---|---|
open_auth_browser | Opens auth provider URL in system browser |
get_stored_jwt | Reads JWT from OS keychain |
store_jwt | Saves JWT to OS keychain |
clear_stored_jwt | Removes JWT and profile from keychain |
store_profile | Saves signer profile JSON to keychain |
get_stored_profile | Reads signer profile from keychain |
load_library | Loads all saved signatures and text snippets metadata |
save_library_signature | Saves a signature PNG to app data directory |
delete_library_signature | Deletes a saved signature |
load_library_signature | Loads a signature PNG as base64 |
update_library_signature_label | Renames a saved signature |
save_text_snippet | Saves a reusable text snippet |
delete_text_snippet | Deletes a saved text snippet |
get_sync_enabled | Returns whether cloud sync is enabled |
set_sync_enabled | Persists cloud sync preference |
open_pdf_picker | Opens OS file dialog, returns selected path |
get_pdf_page_count | Returns page count for a PDF |
sign_document | Full signing pipeline (anchor ID and key, one layer with QR, hash, seal, anchor) |
save_signed_pdf | Save-as dialog for the signed PDF |
verify_document | Check every signer's layer against the blockchain; returns a VerificationReport |
extract_revision | Write the file as it was before anyone signed, or right after a given signer |
PDF Processing Pipeline
The signing pipeline uses lopdf for PDF manipulation:
- Check history -- Reads any existing
/SignChainMetahistory. Files in an earlier format are refused, and entries must link in order. - Anchor ID and key -- Generates a random 16-byte anchor ID and AES-128-GCM key, and builds the QR URL from them
- IncrementalDocument -- Opens the PDF for one incremental update (preserving previous revisions)
- Clone page objects -- The catalog and stamped pages are copied into the new layer, with their Resources resolved and copied inline so existing fonts and images keep working
- Isolate page content -- Each stamped page's existing content is wrapped in
q … Q, so a leftover transform, clip, or colour can't move or alter the stamps - Embed signature -- PNG image added as XObject, placed via content stream operations
- Embed text fields -- Helvetica text rendered at specified coordinates
- Embed QR -- QR code image added as XObject with branding text
- Write history -- The signer's entry (anchor ID, previous anchor ID, QR URL, placements) is appended to
/SignChainMeta, format version 3 - Save and hash -- The layer is saved once, and the document hash is the SHA-256 of exactly those bytes. Nothing is written to the file afterwards.
- Seal and anchor -- The signing record is built, encrypted, and sent to
POST /api/relay
Verification Pipeline
verify_document works on the file's bytes:
- Locate layers (
layers.rs) -- Every save ends in%%EOF. A signer's layer is the first save whose history has that signer's entry last. If the layers can't be found in order, another app rewrote the file, and the result isrewritten. Files in an earlier format arelegacy. - Look up anchors -- Each signer's anchor ID is looked up with
GET /api/verify/:anchorId - Judge (
verify.rs) -- For each signer, the record is decrypted with the key from the history entry's QR URL. It must hash to the on-chain composite hash, name the same anchor ID, link to the previous signer's anchor ID, and hold the SHA-256 of the file cut at that signer's layer end.
Signer details in the report come from the sealed record when it opens (detailsConfirmed). The file's own history is plain and editable, so it is only a fallback. A signer is flagged with changedBefore when another save sits between the previous signature and theirs. The document is changed-after-signing when anything other than whitespace follows the last signer's layer.
React Frontend
State Management
The app uses Zustand for state management with two stores:
useAuthStore -- Authentication state:
- JWT token and decoded user claims (email, name, trust anchor, verified)
- Loaded from OS keychain on startup via
useAuthInithook
useSigningStore -- Signing session state:
- User identity (name, email, company, position)
- Current file path, page count
- Signature and text field placements
- Signing progress (idle -> preparing -> embedding -> hashing -> anchoring -> finalising -> done)
- Signed PDF path
useLibraryStore -- Persistent reusable assets:
- Saved signatures (PNG images stored in app data directory)
- Saved text snippets (name, address, company, etc.) with label, text, and font size
- Loaded from disk on startup, persists across sessions
- Cloud sync (opt-in): when enabled, local mutations are debounced and pushed to the API. On new devices, a
CloudLibraryPromptmodal offers to download existing cloud library data
Routing
Uses React Router with MemoryRouter (no browser URL bar in Tauri):
| Route | Page |
|---|---|
/identity | Authentication (magic link / OAuth) + profile setup |
/dashboard | Document list (requires auth) |
/upload | File picker |
/sign | Signature placement + signing |
/verify | Document verification |
/document/:id | Document details |
/library | Manage saved signatures and text snippets |
A RequireAuth route guard redirects unauthenticated users to /identity. Authentication requires both a valid JWT and a completed signer profile.
PDF Rendering
PDF preview uses pdfjs-dist with a centralized worker setup (src/lib/pdfjs.ts). Two viewer modes:
usePdfPreview-- Single-page preview with navigationusePdfScrollViewer-- Scrollable multi-page view (used in signature placement)
File Associations
The app registers as a PDF handler via Tauri's fileAssociations config. When a user opens a PDF with Veylent:
- Linux/Windows: File path received as CLI argument
- macOS: File path received via
RunEvent::Opened
A chooser dialog offers "Sign with Veylent" or "Verify with Veylent".