A secure NestJS-based cryptographic signing service for W3C Verifiable Credentials (VC Data Model 2.0) and Verifiable Presentations. The service implements multiple signature formats with enterprise-grade key management and multi-layer encryption.
- Multiple Signature Formats: JWT-VC, Data Integrity proofs (Ed25519Signature2020), and SD-JWT
- Cryptographic Algorithms: Ed25519, ES256, PS256 support
- Secure Key Management: Automatic key pair generation with PostgreSQL storage
- Multi-Layer Encryption: Keys encrypted with service + key access secrets
- W3C Standards Compliance: Full VC Data Model 2.0 and DID Core support
- Input Validation: Comprehensive security-focused validation using class-validator
- Failed Attempt Protection: Rate limiting with cooldown mechanisms
- Health Checks: Kubernetes-ready liveness and readiness probes
- RESTful API: Clean HTTP endpoints with comprehensive error handling
- TypeScript: Fully typed with extensive type definitions
- Testing: Comprehensive unit and integration test coverage
- Docker Support: Production-ready containers with PostgreSQL integration
# Install dependencies
npm install
# Run in development mode (generates docker/signing-key if missing)
npm run dev
# Run in production mode locally (same signing key setup)
npm startThe service includes comprehensive test coverage with both unit tests (using mocks) and end-to-end (E2E) tests (using a real PostgreSQL database).
# Run all tests (E2E + unit)
npm test
# Run all tests with coverage
npm run test:coverageRun unit tests with mocked dependencies (no database required):
# Run all unit tests
npm run test:unit
# Run unit tests in watch mode
npm run test:unit:watch
# Run unit tests with coverage
npm run test:unit:coverageRun E2E tests with a real PostgreSQL database. These tests validate the full application stack including database operations, API endpoints, and integrations.
# Run E2E tests (requires database)
npm run test:e2e
# Run E2E tests in watch mode
npm run test:e2e:watch
# Run E2E tests with coverage
npm run test:e2e:coverage# Start PostgreSQL test database
npm run test:db:start
# Stop and remove test database
npm run test:db:stop
# Run unit tests with database (start + test + stop)
npm run test:unit:with-dbThe test database runs PostgreSQL 17 in Docker and is automatically configured with test credentials.
- E2E Tests:
apps/app/test/jest-e2e.json - Unit Tests:
apps/app/test/jest-unit.json - Test Setup:
apps/app/test/test-setup.ts - Test Database:
docker/docker-compose.test.yml
The test suite includes:
- ✅ Key generation and storage
- ✅ Multi-secret encryption/decryption
- ✅ JWT-VC signing
- ✅ Data Integrity signing
- ✅ Input validation (comprehensive security tests)
- ✅ Failed attempt protection
- ✅ Health check endpoints
- ✅ Error handling and edge cases
This service uses PostgreSQL to store encrypted keys. You can run PostgreSQL alone or as part of the full Docker Compose stack (see Docker).
# PostgreSQL only (from project root)
docker compose -f docker/docker-compose.yml up -d postgres
# Wait for database to be ready
docker compose -f docker/docker-compose.yml logs -f postgresWhen running the app with npm run dev against this database, set TLS env vars (certificates are created by npm run docker:certs):
npm run docker:certs
docker compose -f docker/docker-compose.yml up -d postgres
export DB_SSL=true DB_SSL_MODE=verify-full
export DB_SSL_CA=./docker/certs/postgres/ca.crt
export DB_SSL_CERT=./docker/certs/postgres/client.crt
export DB_SSL_KEY=./docker/certs/postgres/client.key
npm run devnpm run docker:up generates certificates automatically and starts the full mTLS stack.
The service is configured using environment variables. Create a .env file in the project root:
# Database Configuration
DB_HOST=localhost # PostgreSQL host
DB_PORT=5432 # PostgreSQL port
DB_USERNAME=postgres # Database username
DB_PASSWORD=postgres # Database password
DB_NAME=key_service # Database name
DB_SSL=false # Enable TLS: true (Docker Compose sets true with mTLS)
# When DB_SSL=true, also set:
# DB_SSL_MODE=verify-full # verify-ca | verify-full | require | disable (default: verify-full)
# DB_SSL_CA=/path/to/ca.crt # CA bundle for server verification (required for verify-* modes)
# DB_SSL_CERT=/path/to/client.crt # Client cert (mTLS — required by Docker Compose postgres)
# DB_SSL_KEY=/path/to/client.key # Client key (mTLS)
# Application Configuration
NODE_ENV=development # Environment: development, production
PORT=3000 # Application port (default: 3000)
SIGNING_KEY_PATH=/run/secrets/signing-key # Path to signing key file# CORS Configuration
CORS_ENABLED=true # Enable/disable CORS (default: true)
CORS_ORIGINS=https://example.com,https://app.example.com # Allowed origins (comma-separated, default: all)
CORS_METHODS=GET,POST,PUT,DELETE # Allowed methods (default: GET,HEAD,PUT,PATCH,POST,DELETE)
CORS_CREDENTIALS=false # Allow credentials (default: false)
CORS_MAX_AGE=86400 # Preflight cache duration in seconds (default: 86400)
# Security Configuration
PBKDF2_ITERATIONS=100000 # PBKDF2 iterations for key derivation (default: 100000)- Set
NODE_ENV=productionin production environments - Always configure
CORS_ORIGINSwith specific trusted domains in production - Use strong database passwords
- Enable database TLS/mTLS for production (
DB_SSL=true,DB_SSL_MODE=verify-full, CA + client cert paths — see.cursor/plans/postgresql-mtls.md) - Do not use
DB_SSL_MODE=requirein production — it encrypts traffic but does not authenticate the server (see plan for details) - Provide a cryptographically random signing key (minimum 32 characters) via
SIGNING_KEY_PATH— never commit it to version control (see Signing key) - For Kubernetes deployments, use the Helm chart and mount secrets from Vault or equivalent — see
helm/README.md
If you prefer to set up PostgreSQL manually:
- Install PostgreSQL
- Create a database named
key_service - Create a user with appropriate permissions
- Update the environment variables accordingly
The application will automatically create the required tables on startup when NODE_ENV is not set to production.
The service uses TypeORM migrations for database schema management:
# Generate a new migration from entity changes
npm run migration:generate -- migrations/MigrationName
# Create a blank migration file
npm run migration:create -- migrations/MigrationName
# Run pending migrations
npm run migration:run
# Revert the last migration
npm run migration:revertMigrations are stored in the migrations/ directory and are automatically run on application startup in development mode.
The service includes production-ready Docker containers with PostgreSQL integration.
The service reads a service signing key from a file (default path: /run/secrets/signing-key, overridable via SIGNING_KEY_PATH). This key is combined with user-provided secrets to derive encryption keys for stored key material. The file is not stored in this repository.
Docker Compose mounts a host file into the container:
# docker/docker-compose.yml
volumes:
- ./signing-key:/run/secrets/signing-key:roChoose one of the following:
Generate a random key locally (creates gitignored docker/signing-key):
npm run docker:signing-keyOr let the convenience script create it on first start:
npm run docker:upIf you change or regenerate the signing key, existing encrypted keys in the local database will no longer decrypt. Reset local data with:
docker compose -f docker/docker-compose.yml down -vCreate a production-grade secret outside the repository and mount it read-only:
openssl rand -base64 64 > /secure/path/signing-key
chmod 600 /secure/path/signing-keyPoint the Compose volume at that file (edit docker/docker-compose.yml or use an override file):
volumes:
- /secure/path/signing-key:/run/secrets/signing-key:roStore and rotate this key using your organization's secret management process. For Kubernetes, prefer the Helm chart with Vault or Kubernetes secrets rather than a host file — see helm/README.md.
The easiest way to run the full stack locally:
# Start PostgreSQL + Key Service (generates a local signing key if missing)
npm run docker:up
# Or manually: create signing key first, then start
npm run docker:signing-key
docker compose -f docker/docker-compose.yml up -d
# View logs
docker compose -f docker/docker-compose.yml logs -f
# Stop all services
docker compose -f docker/docker-compose.yml down
# Stop and remove volumes (cleans database)
docker compose -f docker/docker-compose.yml down -vThe Docker Compose setup includes:
- PostgreSQL 17 database with persistent storage
- Key Service with health checks (
NODE_ENV=production) - Network isolation
- Read-only volume mount for the signing key (see Signing key)
# Build production image (--pull recommended: refreshes distroless base, including OpenSSL)
docker build --pull -t key-service:latest -f docker/Dockerfile .
# Run with environment variables and signing key mounted
docker run -p 3000:3000 \
-v /secure/path/signing-key:/run/secrets/signing-key:ro \
-e NODE_ENV=production \
-e DB_HOST=postgres \
-e DB_NAME=key_service \
-e DB_USERNAME=postgres \
-e DB_PASSWORD=postgres \
key-service:latestFor local testing, use /path/to/docker/signing-key after running npm run docker:signing-key.
The container includes health checks that verify:
- Application is running
- HTTP server is responding
- Health endpoint returns 200 OK
- Dockerfile:
docker/Dockerfile- Multi-stage production build - Compose:
docker/docker-compose.yml- Full stack with PostgreSQL - Test Compose:
docker/docker-compose.test.yml- Test database only
Sign a W3C Verifiable Credential with the specified signature format.
POST /sign/vc/:type
Parameters:
type: Signature type -jwt,data-integrity, orsd-jwt
Request Body:
{
"verifiable": {
"@context": ["https://www.w3.org/2018/credentials/v1"],
"type": ["VerifiableCredential"],
"issuer": "did:example:123",
"issuanceDate": "2023-01-01T00:00:00Z",
"credentialSubject": {
"id": "did:example:456",
"name": "John Doe"
}
},
"secrets": ["user-secret-key"],
"identifier": "key-identifier"
}Response:
Returns the signed credential in the format specified by the signature type.
Sign a W3C Verifiable Presentation with the specified signature format.
POST /sign/vp/:type
Parameters:
type: Signature type -jwt,data-integrity, orsd-jwt
Request Body:
{
"verifiable": {
"@context": ["https://www.w3.org/2018/credentials/v1"],
"type": ["VerifiablePresentation"],
"holder": "did:example:123",
"verifiableCredential": [...]
},
"secrets": ["user-secret-key"],
"identifier": "key-identifier",
"challenge": "nonce-value",
"domain": "example.com"
}Response:
Returns the signed presentation in the format specified by the signature type.
Use this for credential-request key proofs (OpenID4VCI Appendix F.1 JWT) or a Data Integrity VP without going through POST /sign/vp/jwt with OID4VCI typ.
POST /sign/pop/:type
Parameters:
type: Same values asPOST /sign/vp/:type(jwt,data-integrity,sd-jwt). For POP, usejwt(OpenID4VCI Appendix F.1 proof JWT) ordata-integrity(same asPOST /sign/vp/data-integrity).sd-jwtreturns 400.
Request body: SignRequestDto — secrets, identifier, optional verifiable, domain (required for both PoP types: Credential Issuer Identifier — OpenID4VCI F.1 JWT aud, F.2 di_vp proof domain), optional challenge (F.1 JWT nonce / F.2 proof challenge when the issuer uses c_nonce). For POST /sign/vc and POST /sign/vp, verifiable is required (validated in the service). For POST /sign/pop/jwt, verifiable is optional and ignored. For POST /sign/pop/data-integrity, the service always builds a minimal VP shell per F.2 and signs it via the same path as POST /sign/vp/data-integrity; request verifiable is ignored (use POST /sign/vp/data-integrity for a custom VP). JWT PoP uses JOSE typ openid4vci-proof+jwt and a minimal F.1 JWT body, not a VC.
Generate a new cryptographic key pair and store it encrypted in the database.
POST /generate
Request Body:
{
"secrets": ["user-secret-key"],
"identifier": "key-identifier",
"signatureType": "Ed25519",
"keyType": "JWK"
}Parameters:
secrets: Array of 1-10 secrets for multi-layer encryptionidentifier: Unique identifier for the key (alphanumeric,-_:.allowed)signatureType: Algorithm -Ed25519,ES256, orPS256keyType: Key format -JWKorVerificationKey2020
Response:
Returns success confirmation without exposing the private key.
The service provides comprehensive health check endpoints optimized for Kubernetes:
General health check endpoint that includes database connectivity check.
Liveness probe endpoint for Kubernetes. Returns 200 if the application is running.
Readiness probe endpoint for Kubernetes. Returns 200 if the application is ready to serve traffic (includes database connectivity check).
Response Format:
{
"status": "ok",
"info": {
"database": {
"status": "up"
}
},
"error": {},
"details": {
"database": {
"status": "up"
}
}
}First, generate a key pair that will be used for signing:
curl -X POST http://localhost:3000/generate \
-H "Content-Type: application/json" \
-d '{
"secrets": ["my-secret-key"],
"identifier": "my-signing-key",
"signatureType": "Ed25519",
"keyType": "JWK"
}'curl -X POST http://localhost:3000/sign/vc/jwt \
-H "Content-Type: application/json" \
-d '{
"verifiable": {
"@context": ["https://www.w3.org/2018/credentials/v1"],
"type": ["VerifiableCredential"],
"issuer": "did:example:123",
"issuanceDate": "2023-01-01T00:00:00Z",
"credentialSubject": {
"id": "did:example:456",
"name": "John Doe"
}
},
"secrets": ["my-secret-key"],
"identifier": "my-signing-key"
}'curl -X POST http://localhost:3000/sign/vc/data-integrity \
-H "Content-Type: application/json" \
-d '{
"verifiable": {
"@context": ["https://www.w3.org/2018/credentials/v1"],
"type": ["VerifiableCredential"],
"issuer": "did:example:123",
"issuanceDate": "2023-01-01T00:00:00Z",
"credentialSubject": {
"id": "did:example:456",
"name": "John Doe"
}
},
"secrets": ["my-secret-key"],
"identifier": "my-signing-key"
}'curl -X POST http://localhost:3000/sign/vp/jwt \
-H "Content-Type: application/json" \
-d '{
"verifiable": {
"@context": ["https://www.w3.org/2018/credentials/v1"],
"type": ["VerifiablePresentation"],
"holder": "did:example:123",
"verifiableCredential": []
},
"secrets": ["my-secret-key"],
"identifier": "my-signing-key",
"challenge": "random-nonce-12345",
"domain": "example.com"
}'GS1 GO Sample
eyJraWQiOiJkaWQ6d2ViOmNicHZzdmlwLXZjLmdzMXVzLm9yZyNJeXIwZndUdmRsRVJrOEVCWFZ1SXM3NjgyeW45ZGpjQng2aEdtemNhb2FzIiwiYWxnIjoiRVMyNTYifQ.eyJAY29udGV4dCI6WyJodHRwczovL3d3dy53My5vcmcvbnMvY3JlZGVudGlhbHMvdjIiLCJodHRwczovL3JlZi5nczEub3JnL2dzMS92Yy9saWNlbnNlLWNvbnRleHQiXSwiaWQiOiJodHRwczovL2NicHZzdmlwLXZjLWFwaS5nczF1cy5vcmcvY3JlZGVudGlhbHMvMDgxMDE1OTU1IiwidHlwZSI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsIkdTMUNvbXBhbnlQcmVmaXhMaWNlbnNlQ3JlZGVudGlhbCJdLCJpc3N1ZXIiOnsiaWQiOiJkaWQ6d2ViOmNicHZzdmlwLXZjLmdzMXVzLm9yZyIsIm5hbWUiOiJHUzEgVVMifSwibmFtZSI6IkdTMSBDb21wYW55IFByZWZpeCBMaWNlbnNlIiwiZGVzY3JpcHRpb24iOiJUSElTIEdTMSBESUdJVEFMIExJQ0VOU0UgQ1JFREVOVElBTCBJUyBGT1IgVEVTVElORyBQVVJQT1NFUyBPTkxZLiBBIEdTMSBDb21wYW55IFByZWZpeCBMaWNlbnNlIGlzIGlzc3VlZCBieSBhIEdTMSBNZW1iZXIgT3JnYW5pemF0aW9uIG9yIEdTMSBHbG9iYWwgT2ZmaWNlIGFuZCBhbGxvY2F0ZWQgdG8gYSB1c2VyIGNvbXBhbnkgb3IgdG8gaXRzZWxmIGZvciB0aGUgcHVycG9zZSBvZiBnZW5lcmF0aW5nIHRpZXIgMSBHUzEgaWRlbnRpZmljYXRpb24ga2V5cy4iLCJ2YWxpZEZyb20iOiIyMDI0LTAxLTI1VDEyOjMwOjAwLjAwMFoiLCJjcmVkZW50aWFsU3ViamVjdCI6eyJpZCI6ImRpZDp3ZWI6aGVhbHRoeXRvdHMubmV0Iiwib3JnYW5pemF0aW9uIjp7ImdzMTpwYXJ0eUdMTiI6IjA4MTAxNTk1NTAwMDAiLCJnczE6b3JnYW5pemF0aW9uTmFtZSI6IkhlYWx0aHkgVG90cyJ9LCJleHRlbmRzQ3JlZGVudGlhbCI6Imh0dHBzOi8vaWQuZ3MxLm9yZy92Yy9saWNlbnNlL2dzMV9wcmVmaXgvMDgiLCJsaWNlbnNlVmFsdWUiOiIwODEwMTU5NTUiLCJhbHRlcm5hdGl2ZUxpY2Vuc2VWYWx1ZSI6IjgxMDE1OTU1In0sImNyZWRlbnRpYWxTY2hlbWEiOnsiaWQiOiJodHRwczovL2lkLmdzMS5vcmcvdmMvc2NoZW1hL3YxL2NvbXBhbnlwcmVmaXgiLCJ0eXBlIjoiSnNvblNjaGVtYSJ9LCJjcmVkZW50aWFsU3RhdHVzIjp7ImlkIjoiaHR0cHM6Ly9jYnB2c3ZpcC12Yy1hcGkuZ3MxdXMub3JnL3N0YXR1cy84MDFjNmNjNi00ZmM0LTRhYTMtYTM0Ny0zYjMxYTE3NWFjMTQjMTAwMTAiLCJ0eXBlIjoiQml0c3RyaW5nU3RhdHVzTGlzdEVudHJ5Iiwic3RhdHVzUHVycG9zZSI6InJldm9jYXRpb24iLCJzdGF0dXNMaXN0SW5kZXgiOiIxMDAxMCIsInN0YXR1c0xpc3RDcmVkZW50aWFsIjoiaHR0cHM6Ly9jYnB2c3ZpcC12Yy1hcGkuZ3MxdXMub3JnL3N0YXR1cy84MDFjNmNjNi00ZmM0LTRhYTMtYTM0Ny0zYjMxYTE3NWFjMTQvIn19.qP5k8uO9yuQdIWSl9ws5FlRFmUOzxgNgtWs8VFifVEjLWTUx1Hc1m4fIet7EF0njlelNy8G5SZarW7DDaWiJDA
{
"signedCredential": {
"@context": ["https://www.w3.org/2018/credentials/v1"],
"type": ["VerifiableCredential"],
"issuer": "did:example:123",
"issuanceDate": "2023-01-01T00:00:00Z",
"credentialSubject": {
"id": "did:example:456",
"name": "John Doe"
},
"proof": {
"type": "Ed25519Signature2020",
"created": "2023-01-01T00:00:00Z",
"verificationMethod": "did:example:123#key-1",
"proofPurpose": "assertionMethod",
"proofValue": "z58DAdFfa9SkqZMVPxAQpic7ndSayn1PzZs6ZjWp1CktyGesjuTSwRdoWhAfGFCF5bppETSTojQCrfFPP2oumHKtz"
}
}
}The service implements comprehensive input validation to prevent injection attacks and buffer overflows:
- Global ValidationPipe: Enabled with security-focused configuration
- DTO Validation: All API requests validated using class-validator decorators
- Security Constraints:
- Identifier max length: 500 characters
- Secret max length: 1000 characters
- Array size limits: 1-10 secrets per request
- Pattern matching: Identifiers only allow alphanumeric characters and
-_:. - Enum validation for signature and key types
Keys are encrypted using multiple secrets to ensure security even if one secret is compromised:
- Service Secret: Application-level encryption key
- Key Access Secret: Per-key encryption secret
- User Secrets: User-provided secrets (1-10 per key)
- Rate limiting on key access attempts
- Configurable cooldown mechanisms
- Protection against brute force attacks
- Private keys never exposed in decrypted form
- Keys only decrypted in memory during signing operations
- Secure memory clearing after use
- @nestjs/core & @nestjs/common: NestJS framework
- @nestjs/typeorm: TypeORM integration for database operations
- @nestjs/terminus: Health check endpoints
- @digitalbazaar/vc: W3C Verifiable Credentials implementation
- @digitalbazaar/ed25519-signature-2020: Ed25519 signature suite for Data Integrity proofs
- @digitalbazaar/ed25519-verification-key-2020: Ed25519 key handling
- jose: JWT operations (JWT-VC signing)
- @noble/curves: Cryptographic curve operations
- jsonld-signatures: JSON-LD signature support
- class-validator: DTO validation decorators
- class-transformer: Request payload transformation
- node-cache: In-memory caching for failed attempts
- typeorm: TypeScript ORM
- pg: PostgreSQL client
key-service/
├── apps/app/src/
│ ├── config/ # Configuration (CORS, database, rate limiting)
│ ├── filters/ # Global exception handling
│ ├── health/ # Health check endpoints
│ ├── key-services/ # Key management services
│ │ ├── key.service.ts # Core key operations
│ │ ├── key-storage.service.ts
│ │ ├── secret.service.ts
│ │ └── failed-attempts-cache.service.ts
│ ├── signing-services/ # Credential signing services
│ │ ├── jwt-signing.service.ts
│ │ └── data-integrity-signing.service.ts
│ ├── types/ # TypeScript type definitions
│ ├── utils/ # Utility functions and logging
│ └── main.ts # Application entry point
├── docker/ # Docker configuration
├── migrations/ # Database migrations
├── docs/ # Documentation
└── scripts/ # Build and release scripts
Copyright 2025 European EPC Competence Center GmbH (EECC). Corresponding Author: Christian Fries christian.fries@eecc.de

All code published in this repository is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
This is a security-critical component. Please open issues to discuss any proposed changes. PRs are welcome but will be critically reviewed by the maintainers. Please first discuss changes before opening PRs.
- Docker setup — image build, Compose, signing key
- Helm chart — Kubernetes deployment and production secret management
- Security and Key Management Concept
- Changelog
- Security Report