A proof-of-concept digital signature system for trade recommendations, demonstrating secure client-side ECDSA signing with encrypted key storage.
This POC implements a complete workflow for digitally signing trade recommendations using modern cryptographic standards. The system follows a zero-knowledge security model where private keys are generated, stored, and used entirely on the client side - private keys never leave the client in unencrypted form.
- Client-Side Key Generation: ECDSA P-256 key pairs generated in the browser using Web Crypto API
- Encrypted Key Storage: Private keys encrypted with AES-GCM before being stored server-side
- Strong Key Derivation: PBKDF2 with 600,000 iterations (OWASP 2024 recommendation)
- Key Rotation: Support for rotating signing keys while maintaining historical signatures
- Cryptographic Verification: Server-side signature verification using stored public keys
- Canonical String Format: Standardized signing format for consistency
This is a monorepo containing both frontend and backend applications:
digital-signature-poc/
├── app/ # Nuxt 4 frontend (Vue 3, TypeScript, Tailwind CSS v4)
└── api/ # ASP.NET Core 10 backend (.NET 10, EF Core, SQLite)
├── Ds.Api/ # Web API layer
└── Ds.Core/ # Domain entities and persistence
Frontend:
- Nuxt 4 (Vue 3, TypeScript)
- Tailwind CSS v4
- Web Crypto API
- Composables-based architecture
Backend:
- .NET 10 / ASP.NET Core
- Entity Framework Core
- SQLite database
All cryptographic operations use the Web Crypto API for security and performance:
- Signing: ECDSA with P-256 curve (secp256r1)
- Encryption: AES-GCM with 256-bit keys
- Key Derivation: PBKDF2-SHA256 with 600,000 iterations
- Hashing: SHA-256
- User creates a passphrase (10+ characters, mixed case, digit required)
- System generates ECDSA P-256 key pair in the browser
- Passphrase is used to derive an AES-GCM encryption key via PBKDF2
- Private key is encrypted with derived key
- Encrypted private key + salt + IV + public key stored on server
- Original passphrase and unencrypted private key discarded from memory
- User requests to sign a trade recommendation
- System fetches encrypted key material from server
- User enters passphrase
- System derives decryption key from passphrase + stored salt
- Private key is decrypted in memory
- Trade metadata is hashed (SHA-256)
- Canonical string constructed:
TRADEv1|{trade_id}|{action}|{signed_at}|{metadata_hash} - Canonical string signed with ECDSA-SHA256
- Signature + metadata submitted to server
- Private key cleared from memory
- Server reconstructs canonical string from request
- Signature verified using stored public key
- Signing key validated as active
- Signature and metadata persisted
- Node.js 18+ and npm
- .NET 10 SDK
- Git
-
Clone the repository
git clone <repository-url> cd digital-signature-poc
-
Start the backend
cd api dotnet restore dotnet run --project Ds.Api/Ds.Api.csprojThe API will start at
http://localhost:5071 -
Start the frontend (in a new terminal)
cd app npm install npm run devThe app will start at
http://localhost:3000 -
Try it out
- Navigate to
http://localhost:3000 - Click "Manage Keys" to onboard your first signing key
- Create a passphrase (remember it!)
- Sign trade recommendations from the list
- Navigate to
cd app
# Install dependencies
npm install
# Development server (http://localhost:3000)
npm run dev
# Production build
npm run build
# Preview production build
npm run previewcd api
# Restore dependencies
dotnet restore
# Build solution
dotnet build
# Run API (http://localhost:5071)
dotnet run --project Ds.Api/Ds.Api.csproj
# Watch mode (auto-restart on changes)
dotnet watch --project Ds.Api/Ds.Api.csprojcd api/Ds.Core
# Create new migration
dotnet ef migrations add <MigrationName> --startup-project ../Ds.Api/Ds.Api.csproj
# Apply migrations
dotnet ef database update --startup-project ../Ds.Api/Ds.Api.csproj
# In development, delete app.db to start fresh
rm ../Ds.Api/app.dbapp/
├── app/
│ ├── composables/ # Business logic (auto-imported)
│ │ ├── useApi.ts # API client wrapper
│ │ ├── useCrypto.ts # Cryptographic operations
│ │ ├── useKeyManagement.ts # Key lifecycle management
│ │ ├── useSigning.ts # Trade signing workflow
│ │ └── useTradeProposals.ts # Trade data management
│ ├── components/ # Vue components
│ ├── pages/ # File-based routing
│ └── types/ # TypeScript types
└── nuxt.config.ts # Nuxt configuration
api/
├── Ds.Api/ # Web API layer
│ ├── Controllers/ # API endpoints
│ ├── Dto/ # Data transfer objects
│ ├── Extensions/ # C# 12 extension methods
│ └── Services/ # Business logic
└── Ds.Core/ # Domain layer
├── Entities/ # Domain entities
├── Enumerations/ # Enums
├── Migrations/ # EF Core migrations
└── Persistence/ # DbContext
The frontend uses Vue composables for all business logic:
useCrypto: Low-level cryptographic primitives (signing, encryption, hashing)useKeyManagement: High-level key operations (onboarding, rotation)useSigning: Complete signing workflow orchestrationuseTradeProposals: Trade data fetching and managementuseApi: HTTP client with error handling
All signatures are generated from a canonical string format:
TRADEv1|{trade_id}|{action}|{signed_at}|{metadata_hash}
Where:
trade_id: Integer ID of the trade proposal (e.g.,123)action: Lowercase action string - eitheracceptedorrejectedsigned_at: Unix timestamp in milliseconds (e.g.,1733140200000)metadata_hash: SHA-256 hash of the raw metadata JSON string (hex format)
Example:
TRADEv1|123|accepted|1733140200000|a7f3b2c1d4e5f6a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2
This ensures consistent signature verification between client and server.
CRITICAL: The metadata hash MUST be computed from the raw JSON string (metadataRaw), not the parsed object. This ensures hash consistency between client and server.
Users can rotate their signing keys while maintaining historical signatures:
- Old key's
SupersededAttimestamp is set - Old key remains in database for signature verification
- New key becomes the active key
- Customer's
ActiveKeyIdupdated to new key
Edit app/nuxt.config.ts:
export default defineNuxtConfig({
runtimeConfig: {
public: {
apiBaseUrl: "http://localhost:5071/api/v1",
},
},
});Edit api/Ds.Api/appsettings.json or appsettings.Development.json:
{
"ConnectionStrings": {
"DefaultConnection": "Data Source=app.db"
}
}User Key Management:
GET /api/v1/users/me/keys/active- Get user's active key materialPOST /api/v1/users/me/keys/onboarding- Onboard new signing keyPOST /api/v1/users/me/keys/rotate- Rotate to new signing key
Trade Recommendations:
GET /api/v1/trades- List trade recommendationsPOST /api/v1/trades/proposal- Create new random trade recommendation (for testing)POST /api/v1/trades/{id}/sign- Sign a trade recommendation
- Passphrase Strength: Enforced minimum requirements (10 chars, mixed case, digit)
- Key Derivation: 600,000 PBKDF2 iterations (OWASP 2024 recommendation)
- Memory Management: Private keys cleared from memory after use
- Transport Security: Use HTTPS in production
- Key Storage: Encrypted private keys only - server never sees unencrypted keys
- Signature Verification: All signatures verified server-side before acceptance
This project is licensed under the MIT License. See the LICENSE file for details.
This is a POC project. For development guidance, see CLAUDE.md.