Hardening guide and security reference for the OpenSSL Encrypt Server.
- Security Architecture Overview
- Authentication
- Token Security
- Secret Management
- Rate Limiting
- Input Validation
- Network Security
- Database Security
- Information Disclosure Prevention
- Cryptographic Security
- Audit Logging
- Deadman Switch
- Container Security
- Operational Security
The server implements defense-in-depth with multiple security layers:
Internet
|
[Firewall]
|
[Reverse Proxy] ← TLS termination, mTLS for private modules
|
[Docker Network] ← Isolated 172.28.0.0/16 subnet
/ \
[API Server] [PostgreSQL]
172.28.0.3 172.28.0.2
Public modules (Keyserver, Telemetry):
- JWT Bearer token authentication
- Per-module secret isolation
- Rate limiting per endpoint
Private modules (Pepper, Integrity):
- mTLS client certificate authentication
- Trusted proxy validation
- TOTP 2FA (Pepper module)
Used by: Keyserver, Telemetry modules
Token structure:
| Claim | Purpose |
|---|---|
sub |
Client ID (32 hex chars) |
iss |
Module issuer (prevents cross-module use) |
exp |
Expiration timestamp (UTC) |
iat |
Issued-at timestamp (UTC) |
jti |
Unique token ID (replay prevention) |
type |
Token type (access or refresh) |
Verification chain:
- Signature verification (HMAC-SHA256 with module-specific secret)
- Expiration check
- Issuer validation (must match expected module)
- JTI revocation check
- Token type validation (access vs refresh)
Used by: Pepper, Integrity modules (when auth_mode=mtls)
Direct mTLS mode:
- Server terminates TLS on dedicated port (8444/8445)
- Client must present certificate signed by configured CA
- Certificate fingerprint (SHA-256) used as client identifier
- No passwords involved
Proxy mTLS mode:
- Reverse proxy (Nginx) terminates mTLS
- Proxy forwards certificate data in HTTP headers
- Server validates request came from trusted proxy IP
Used by: Pepper, Integrity modules (when auth_mode=proxy)
Headers consumed:
| Header | Purpose | Strategy |
|---|---|---|
X-Client-Cert |
Full PEM certificate (URL-encoded) | Preferred: server computes fingerprint |
X-Client-Cert-Fingerprint |
Pre-computed SHA-256 fingerprint | Fallback if raw cert unavailable |
X-Client-Cert-DN |
Client Distinguished Name | Identity metadata |
X-Client-Cert-Verify |
Verification status (SUCCESS) |
Proxy's verification result |
Trusted proxy enforcement:
- Only configured proxy IPs can forward certificate headers
- Default: localhost only (
127.0.0.1,::1) - CIDR notation supported (e.g.,
192.168.1.0/24) - Networks larger than /24 are rejected (prevents overly broad trust)
- Untrusted proxy attempts are logged as security events
Fingerprint normalization:
- SHA-256, lowercase hex, 64 characters
- Colons, spaces, and hyphens stripped
- Hex-only content validated
- Prevents bypass via formatting variations
Optional pre-shared secret for controlling who can register:
REGISTRATION_SECRET=your-secret-hereWhen set:
X-Registration-Secretheader must match exactly- Applies to both Keyserver and Telemetry register endpoints
- Returns generic 403 on mismatch (no secret disclosure)
Each module has its own:
| Module | Issuer Claim | Secret Variable |
|---|---|---|
| Keyserver | openssl_encrypt_keyserver |
KEYSERVER_TOKEN_SECRET |
| Telemetry | openssl_encrypt_telemetry |
TELEMETRY_TOKEN_SECRET |
Effects:
- Keyserver token rejected by Telemetry endpoints (and vice versa)
- Compromising one secret does not affect the other module
- Secrets must differ (startup validation enforces this)
| Token Type | Lifetime | Use |
|---|---|---|
| Access token | 60 minutes | Bearer header for API calls |
| Refresh token | 7 days | POST body to obtain new token pair |
- Refresh token sent in POST body (not query parameters or headers)
- Query parameter usage is explicitly blocked and returns 401
- Each refresh returns a new token pair (both access and refresh)
- Old refresh token is revoked immediately (single-use)
- Token type claim (
type: refresh) validated (prevents access token as refresh)
- In-memory JTI revocation set (thread-safe with lock)
- Refresh tokens revoked on use (prevents replay)
- Revocation persists for server lifetime
- Server restart clears revocation set (tokens expire naturally via
expclaim)
| Secret | Min Length | Module | Purpose |
|---|---|---|---|
KEYSERVER_TOKEN_SECRET |
32 chars | Keyserver | JWT signing |
TELEMETRY_TOKEN_SECRET |
32 chars | Telemetry | JWT signing |
PEPPER_TOTP_SECRET_KEY |
44 chars (Fernet) | Pepper | TOTP secret encryption |
POSTGRES_PASSWORD |
Non-empty | Database | Database authentication |
# JWT secrets (min 32 chars)
python -c "import secrets; print(secrets.token_urlsafe(48))"
# Fernet key (exactly 44 chars)
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Registration secret (optional)
python -c "import secrets; print(secrets.token_urlsafe(32))"At startup, the server validates:
- Minimum length: Token secrets must be >= 32 characters
- Uniqueness: Keyserver and Telemetry secrets must differ
- No insecure markers: Rejects secrets containing:
change-me,changeme,change_me,change_this,secret-change,not-for-production - Fernet format: TOTP secret key must be exactly 44 characters
ALLOW_INSECURE_DEFAULTS controls startup validation strictness:
| Value | Effect |
|---|---|
false (default) |
Strict: rejects empty passwords, insecure markers. Server refuses to start. |
true |
Permissive: allows insecure values with warnings. Development only. |
This is intentionally separate from DEBUG to prevent accidentally disabling security when enabling documentation endpoints.
All rate limits are per-client-IP using the slowapi library.
| Endpoint | Limit | Rationale |
|---|---|---|
POST /register |
10/hour | Prevent mass registration |
POST /login |
5/minute | Prevent brute force credential attacks |
POST /register/email |
5/hour | Prevent email spam |
GET /confirm/{token} |
20/hour | Reasonable for email link clicks |
GET /register/status/{id} |
60/hour | Allow polling without overload |
POST / (upload) |
60/minute | Normal key uploads |
GET /search |
100/minute | Public key lookups |
POST /{fp}/revoke |
60/minute | Key revocation |
POST /refresh |
60/hour | Periodic token refresh |
| Endpoint | Limit |
|---|---|
POST /register |
10/hour |
POST /refresh |
60/hour |
POST /events |
1000/hour |
GET /stats |
100/minute |
| Endpoint | Limit |
|---|---|
| All endpoints | 60/minute |
Separate from HTTP rate limiting — protects against TOTP brute force:
| Setting | Value |
|---|---|
| Max failed attempts | 5 per 5-minute window |
| Lockout duration | 15 minutes |
| Backend | In-memory (default) or database-backed |
After lockout, the client receives 429 Too Many Requests for all TOTP-protected operations. Lockout events are logged as TOTP_LOCKOUT security events.
All request bodies are validated by Pydantic schemas:
- Type enforcement: Strict type checking (str, int, bool, etc.)
- Length constraints:
min_length,max_lengthon string fields - Email validation:
EmailStrtype for email fields - Required vs optional: Clearly defined per schema
Key upload bundles are validated against post-quantum algorithm whitelists:
Encryption (KEM):
ML-KEM-512(NIST Category 1)ML-KEM-768(NIST Category 3)ML-KEM-1024(NIST Category 5)
Signing (DSA):
ML-DSA-44(NIST Category 2)ML-DSA-65(NIST Category 3)ML-DSA-87(NIST Category 5)
Any other algorithm values are rejected with 400 Bad Request.
Before storing any key bundle, the server performs:
-
Self-signature verification: The bundle's
self_signatureis verified against thesigning_public_keyusing liboqs (ML-DSA). The signed message is a deterministic JSON serialization of all bundle fields (sorted keys, compact separators). -
Fingerprint verification: The fingerprint is recalculated as
SHA-256(encryption_public_key || signing_public_key)and compared against the provided fingerprint. -
Algorithm validation: Both algorithms checked against whitelist.
Bundles failing any check are rejected with 400 Bad Request.
Default: Disabled (empty CORS_ORIGINS). This is the most secure configuration.
Recommendations:
- Use specific origins, never
*in production - Enable only the HTTP methods your frontend needs
- Restrict allowed headers to those actually used
- Keep
CORS_ALLOW_CREDENTIALS=falseunless specifically needed
When using proxy authentication mode (Pepper/Integrity):
- Default trust: Localhost only (
127.0.0.1,::1) - CIDR support: e.g.,
192.168.1.0/24 - Maximum prefix: Networks larger than /24 are rejected (except localhost ranges)
- Validation: Every request checked against trusted proxy list
- Logging: Untrusted proxy attempts logged as
UNTRUSTED_PROXYsecurity events
Why /24 maximum? Trusting large network ranges (e.g., /16) drastically increases attack surface. If any host in the trusted range is compromised, certificate headers can be forged. Keep trust ranges as narrow as possible.
- Fixed subnet:
172.28.0.0/16 - Internal only: Database not exposed to host (no port mapping)
- API exposure: Controlled via
docker-compose.standalone.ymloverride - No port exposure by default: Designed for reverse proxy architecture
- API server: Does not terminate TLS (designed behind reverse proxy)
- mTLS ports: Terminate TLS directly (Pepper: 8444, Integrity: 8445)
- SMTP: STARTTLS by default, optional certificate verification bypass for internal servers
- Reverse proxy: Configure TLS in Nginx/Caddy/Traefik (see docs/MTLS_SETUP.md)
- Async driver: asyncpg with SQLAlchemy (parameterized queries, no SQL injection)
- Connection pool: Bounded (default: 20 + 10 overflow) to prevent resource exhaustion
- Query timeout: 30 seconds default prevents long-running query DoS
- Password required: Non-empty password enforced in production mode
Module-prefixed tables prevent name collisions:
| Module | Prefix | Example Tables |
|---|---|---|
| Keyserver | ks_ |
ks_clients, ks_keys, ks_pending_registrations, ks_access_log |
| Telemetry | tm_ |
tm_clients, tm_events |
| Pepper | pp_ |
pp_clients, pp_peppers, pp_panic_log |
| Integrity | in_ |
in_clients, in_hashes |
| Data | Protection | Storage |
|---|---|---|
| TOTP secrets | Fernet encryption (AES-128-CBC) | pp_clients.totp_secret |
| Backup codes | Argon2id hashing (irreversible) | pp_totp_backup_codes.code_hash |
| JWT secrets | Environment variable only | Never stored in database |
| Peppers | Stored as-is (client-encrypted) | pp_peppers.value |
Peppers are encrypted client-side before transmission. The server stores encrypted blobs and never sees plaintext pepper values.
All error responses use generic messages that prevent enumeration:
| Endpoint | Error Condition | Response Message |
|---|---|---|
/login |
Invalid client_id | "Invalid credentials" |
/confirm/{token} |
Invalid token | "Invalid confirmation token" |
/register/status/{id} |
Not found | "Registration not found" |
/register |
Bad secret | "Invalid or missing registration secret" |
| Authenticated endpoints | Bad token | "Invalid or malformed token" |
| Proxy auth | Untrusted source | "Forbidden" (generic 403) |
DEBUG=trueonly exposes/docsand/redoc(OpenAPI documentation)- Security validation is controlled separately by
ALLOW_INSECURE_DEFAULTS - Enabling debug mode does not weaken security checks
- Detailed error information logged server-side only (not in HTTP responses)
- Client IDs truncated in logs (first 8 characters)
- Hash values masked (first 16 characters) in integrity mismatch logs
- Security logger handles sensitive data masking automatically
| Operation | Implementation | Library |
|---|---|---|
| JWT signature verification | jwt.decode() |
PyJWT |
| Client ID lookup (login) | hmac.compare_digest() |
Python stdlib |
| TOTP code verification | pyotp.TOTP.verify() |
pyotp |
| Backup code verification | argon2.PasswordHasher.verify() |
argon2-cffi |
| Integrity hash comparison | hmac.compare_digest() |
Python stdlib |
The keyserver verifies post-quantum signatures using liboqs:
| Algorithm | liboqs Name | NIST Level | Use |
|---|---|---|---|
| ML-KEM-512 | Kyber512 | 1 | Key encapsulation |
| ML-KEM-768 | Kyber768 | 3 | Key encapsulation |
| ML-KEM-1024 | Kyber1024 | 5 | Key encapsulation |
| ML-DSA-44 | Dilithium2 | 2 | Digital signatures |
| ML-DSA-65 | Dilithium3 | 3 | Digital signatures |
| ML-DSA-87 | Dilithium5 | 5 | Digital signatures |
If liboqs is unavailable, signature verification fails (safe default — bundles are rejected, not accepted unverified).
TOTP (Time-based One-Time Password):
- 6-digit codes, 30-second window
- Secrets encrypted with Fernet (AES-128-CBC + HMAC-SHA256)
- Rate limited: 5 attempts per 5 minutes, 15-minute lockout
- Used for pepper update and delete operations
Backup codes:
- 10 codes generated per TOTP setup
- 8 characters each (base32 alphabet, no confusing characters)
- Hashed with Argon2id (irreversible)
- Single-use (marked
used_aton verification) - Plaintext shown once during setup, then discarded
Peppers stored on the server are encrypted client-side:
- Client encrypts pepper value before upload
- Server stores encrypted blob in
pp_peppers.value - Server never sees or handles plaintext pepper data
- Decryption happens exclusively on the client
| Event | Severity | Trigger |
|---|---|---|
AUTH_SUCCESS |
INFO | Successful authentication |
AUTH_FAILURE |
WARNING | Failed authentication attempt |
TOTP_FAILURE |
WARNING | Failed TOTP verification |
TOTP_LOCKOUT |
WARNING | TOTP rate limit exceeded |
RATE_LIMIT_EXCEEDED |
WARNING | HTTP rate limit hit |
INTEGRITY_MISMATCH |
WARNING | File integrity check failed |
INTEGRITY_CHECK_FAILED |
ERROR | Integrity verification error |
PANIC_TRIGGERED |
CRITICAL | Manual panic wipe initiated |
PANIC_ACTIVATED |
CRITICAL | Deadman switch auto-wipe |
KEY_REVOKED |
INFO | Public key revoked |
CERT_VERIFICATION_FAILED |
WARNING | mTLS certificate verification failure |
SUSPICIOUS_ACTIVITY |
WARNING | Unusual behavior detected |
UNTRUSTED_PROXY |
WARNING | Request from untrusted proxy IP |
Security events are logged in JSON format:
{
"timestamp": "2026-03-26T10:30:00.000Z",
"event": "AUTH_FAILURE",
"severity": "WARNING",
"client_id": "cd94f345",
"details": {
"reason": "Token expired",
"ip": "192.168.1.100",
"endpoint": "/api/v1/keys"
}
}| Log | Path | Fallback |
|---|---|---|
| Security log | /var/log/openssl-encrypt/security.log |
/tmp/openssl-encrypt/security.log |
| Application log | Console (stdout/stderr) | Configurable via LOG_LEVEL |
The security logger automatically masks sensitive data:
- Client IDs: First 8 characters only
- Hash values: First 16 characters only
- Tokens: Never logged
- Passwords: Never logged
- Peppers: Never logged
The pepper module includes an automatic wipe mechanism for inactive clients.
- Client configures deadman switch with check-in interval and grace period
- Client must call the check-in endpoint before the deadline
- Background watcher runs every hour (configurable)
- If deadline + grace period passed without check-in:
- All client peppers are permanently deleted
- Event logged as
PANIC_ACTIVATED(CRITICAL) - Wipe recorded in
pp_panic_log
| Setting | Default | Minimum | Description |
|---|---|---|---|
| Check-in interval | 7 days | 1 hour | How often the client must check in |
| Grace period | 24 hours | 1 hour | Extra time before wipe after missed deadline |
| Watcher frequency | 1 hour | - | How often the background task checks |
- Protects against: Device compromise, coercion, extended unavailability
- Irreversible: Wipe cannot be undone — peppers are permanently deleted
- Client responsibility: Client must maintain regular check-ins
- Failure mode: If server is unreachable, deadline continues ticking
- Opt-in: Deadman switch is disabled per-client until configured
The Docker container runs as a non-root user:
RUN useradd -m -u 1000 appuser
USER appuser- UID 1000 (standard non-privileged user)
- No sudo or privilege escalation
- Application files owned by appuser
The Dockerfile uses multi-stage builds:
- Builder stage: Compiles liboqs from source (requires build tools)
- Runtime stage: Minimal Python image with only runtime dependencies
- Build tools, source code, and intermediate artifacts are not in the final image
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30sBefore deploying:
- Generate unique token secrets (min 32 chars each)
- Set a strong
POSTGRES_PASSWORD - Verify
ALLOW_INSECURE_DEFAULTS=false(or not set) - Configure
CORS_ORIGINSwith specific origins (not*) - Set
DEBUG=false - Configure reverse proxy with TLS termination
- Set up log rotation for security logs
- Configure firewall rules (only expose reverse proxy port)
- Set
KEYSERVER_BASE_URLto public URL (if using email registration) - Test SMTP configuration
- Run database migrations
- Verify health endpoint:
curl https://your-server/health
For mTLS modules (Pepper/Integrity):
- Generate CA certificate and client certificates
- Configure Nginx with mTLS (see docs/MTLS_SETUP.md)
- Set trusted proxy IPs (as narrow as possible)
- Generate Fernet key for TOTP encryption (Pepper)
- Test mTLS authentication end-to-end
Endpoints to monitor:
| Endpoint | Expected | Meaning |
|---|---|---|
GET /health |
{"status": "healthy"} |
Server is running |
GET /ready |
{"status": "ready"} |
Server can accept requests |
GET /info |
Module list | Shows enabled modules |
Metrics to watch:
- Security log volume (spikes may indicate attack)
- TOTP lockout events (brute force attempts)
- Rate limit hits (429 responses)
- Authentication failures (401 responses)
- Database connection pool utilization
- Response latency (may indicate resource exhaustion)
Token compromise:
- Rotate the affected module's token secret
- Restart the server (invalidates all existing tokens)
- All clients must re-register or re-login
Database compromise:
- Rotate
POSTGRES_PASSWORD - Rotate all token secrets
- Rotate
PEPPER_TOTP_SECRET_KEY(if Pepper module used) - Invalidate all TOTP secrets (clients must re-setup)
- Review access logs for unauthorized operations
Certificate compromise (mTLS):
- Revoke the compromised client certificate
- Re-generate CA if CA key compromised
- Distribute new certificates to legitimate clients
- Review security logs for the compromised fingerprint
What to back up:
- PostgreSQL database (contains all client data, keys, peppers)
.envfile (contains all secrets)- Certificate files (CA cert, server certs, client certs)
- Security logs (audit trail)
What NOT to back up to shared storage:
- Token secrets (keep in secure vault or
.envonly) - TOTP encryption key (keep in secure vault or
.envonly) - Client private keys (clients manage their own)
Recovery:
- Restore PostgreSQL from backup
- Restore
.envwith same secrets (or rotate and have clients re-authenticate) - Restore certificates
- Start server and verify health endpoint
- If secrets were rotated, notify clients to re-register/re-login