PSAPLink Encryption Architecture
Overview
PSAPLink handles emergency dispatch data (incident descriptions, caller PII, location, company notification content) that must meet CJIS Security Policy v5.9.2 §5.10 and HIPAA §164.312(a)(2)(iv) requirements for encryption of sensitive data at rest and in transit.
This document covers the complete encryption architecture across all four layers:
- TLS / Transport Security
- Database Field-Level Encryption
- PSAP↔Cloud Channel Encryption
- Mercure SSE Payload Encryption
Algorithm
Algorithm: XSalsa20-Poly1305 (libsodium crypto_secretbox)
- Key: 256-bit (32 bytes)
- Nonce: 192-bit (24 bytes), random per encryption
- Auth tag: 128-bit (AEAD — authenticated encryption with associated data)
Note on FIPS:
sodium_crypto_secretbox(XSalsa20-Poly1305) is not in the FIPS 140-3 approved list. If your deployment must operate under a FIPS-validated module, evaluatesodium_crypto_aead_aes256gcm_encrypt(AES-256-GCM) as an alternative.
Phase 1: TLS / Transport Security
Cloud (Caddy)
cloud/docker/caddy/Caddyfile.prod enforces:
- HTTP → HTTPS redirect (port 80 → 443)
- TLS 1.2 minimum, TLS 1.3 preferred
- Explicit cipher suite list (no RC4, 3DES, CBC-mode AES)
- HSTS:
max-age=63072000; includeSubDomains(2 years) - Security headers:
X-Content-Type-Options,X-Frame-Options,CSP,Referrer-Policy,Permissions-Policy,Cross-Origin-Resource-Policy,Cross-Origin-Opener-Policy - Removes
ServerandX-Powered-Byheaders
Cloud (nginx)
cloud/nginx/default.conf and standalone.conf:
server_tokens offfastcgi_hide_header X-Powered-Byadd_header X-Content-Type-Options nosniff
PSAP
psap/docker/caddy/Caddyfile.psap — TLS termination for callback mode.
psap/src/Command/BootstrapCommand.php rejects non-HTTPS CLOUD_BASE_URL in APP_ENV=prod.
Phase 2: Database Field-Level Encryption
Ciphertext Format
v{n}:<base64(nonce[24] || ciphertext)>
v1:— encrypted with v1 master keyv2:— encrypted with v2 master key (during/after rotation)- Legacy (no prefix) — treated as v1 for backward compatibility
Per-Agency Subkey Derivation
All field encryption uses per-agency subkeys derived from the master key:
$subkey = sodium_crypto_kdf_derive_from_key(32, abs(crc32($agencyId)), 'psaplink', $masterKey);
This means:
- A database dump without the master key decrypts nothing
- A dump of agency A's rows cannot decrypt agency B's rows even with agency A's subkey
- Cross-agency or system-level data (no
agencyId) uses the master key directly
Encrypted Fields
| Table | Fields |
|---|---|
incidents | title, description, address, city, state, latitude, longitude, location_raw, close_reason, assistance_requested |
incident_communications | content |
cfs_incidents | caller_name, caller_phone, description, address, nearest_cross_streets, dispatcher_name, raw_xml |
Service Layer
EncryptionService (cloud/src/Service/EncryptionService.php):
encrypt(string $plaintext, ?string $agencyId = null): stringdecrypt(string $encoded, ?string $agencyId = null): stringencryptNullable(),decryptNullable(),encryptArray(),decryptArray()helpers
Encryption is transparent via SensitiveFieldEncryptionListener (cloud/src/EventListener/SensitiveFieldEncryptionListener.php) which handles Doctrine lifecycle events:
postLoad: decrypt fields; syncoriginalEntityDatato prevent false dirty detectiononFlush: encrypt before SQL generation; backup plaintextpostFlush: restore plaintext to in-memory entities
Environment Variables
LIBSODIUM_ENCRYPTION_KEY=<hex-encoded 32-byte key>
LIBSODIUM_ENCRYPTION_KEY_VERSION=v1 # default; set to v2 during rotation
LIBSODIUM_ENCRYPTION_KEY_V2= # empty until rotation
Data Migration
Back-fill encryption on existing plaintext rows:
# Dry run first
php bin/console psap:encrypt:migrate-fields --dry-run
# Live migration (by entity, or all at once)
php bin/console psap:encrypt:migrate-fields --entity=incidents
php bin/console psap:encrypt:migrate-fields --entity=cfs_incidents
php bin/console psap:encrypt:migrate-fields
Idempotent — skips rows already matching v\d+: prefix.
Phase 3: PSAP↔Cloud Channel Encryption
All PSAP→Cloud event POSTs and ACK callbacks use Encrypt-then-MAC:
- JSON body encrypted with
ChannelEncryptionService(XSalsa20-Poly1305) - HMAC-SHA256 computed over ciphertext (not plaintext)
X-PSAPLink-Encrypted: 1header signals encrypted body to receiver
Key Derivation
Encryption subkey derived from the raw API key using BLAKE2b (no KDF key, domain-separated):
$encKey = sodium_crypto_generichash("psap_channel\x01{$rawApiKey}", length: 32);
No new environment variable required — subkey derived from the existing CLOUD_API_KEY.
Wire Format
Content-Type: text/plain
X-PSAPLink-Encrypted: 1
X-PSAPLink-Signature: <hmac-sha256-over-ciphertext>
X-PSAPLink-Key: <raw-api-key>
Body: base64(nonce[24] || ciphertext) ← plain base64, no "enc:" prefix (that is SSE-only)
Backward Compatibility
Cloud checks X-PSAPLink-Encrypted header before decrypting. Absent header = plaintext (legacy). Deploy Cloud first, then PSAP.
Phase 4: Mercure SSE Payload Encryption
SSE payloads for sensitive topics are encrypted before publishing to Mercure.
Key Derivation
Keys rotate every 30 minutes and are derived deterministically from the master key:
// Agency topic: /agencies/{id}/incidents
$key = sodium_crypto_generichash("mercure_agency\x01{$agencyId}\x01{$windowIndex}", key: $masterKey, length: 32);
// User topic: /users/{id}/notifications
$key = sodium_crypto_generichash("mercure_user\x01{$userId}\x01{$windowIndex}", key: $masterKey, length: 32);
Where $windowIndex = floor(time() / 1800).
Wire Format
Encrypted SSE event data: enc:<base64(nonce[24] || ciphertext)>
The enc: prefix signals the frontend to decrypt before JSON-parsing.
Frontend Key Fetching
The frontend calls GET /api/v1/realtime/subscribe-key (JWT required) on login to obtain agency_key and user_key (base64-encoded). Keys are auto-refreshed 60 seconds before expiry by useSseKey hook.
Decryption uses libsodium-wrappers in the browser (same XChaCha20-Poly1305).
Data Classification
| Classification | Examples | Protection |
|---|---|---|
| PII / Restricted | caller name, caller phone, dispatcher name | DB encryption + TLS |
| Sensitive | incident description, address, raw XML, CAD narratives | DB encryption + TLS |
| Internal | incident IDs, agency IDs, action strings | TLS only |
| Public | incident type labels, priority names | None |
CJIS Compliance Mapping
| CJIS §5.10 Requirement | Implementation |
|---|---|
| §5.10.1 — Encryption for data at rest | Field-level encryption (Phase 2) |
| §5.10.1.2 — 128-bit minimum key length | 256-bit keys (XSalsa20-Poly1305) — see FIPS note above |
| §5.10.1.3 — Encryption in transit | TLS 1.2+ with strong cipher suites (Phase 1) |
| §5.10.2 — Public key certificates | Managed by Caddy (ACME/Let's Encrypt) |
HIPAA Technical Safeguard Mapping
| HIPAA §164.312 | Implementation |
|---|---|
| §164.312(a)(2)(iv) — Encryption and decryption | Phase 2 field-level encryption |
| §164.312(e)(1) — Transmission security | Phase 1 TLS + Phase 3 channel encryption |
| §164.312(e)(2)(ii) — Encryption of ePHI in transit | TLS 1.2+ + PSAP channel encryption |
Threat Model
| Threat | Mitigation |
|---|---|
| Database dump | Per-agency subkey encryption — dump without master key is useless |
| Agency cross-contamination | Per-agency subkeys — agency A's subkey cannot decrypt agency B's data |
| MITM on PSAP↔Cloud | HMAC-SHA256 over ciphertext (Encrypt-then-MAC) + TLS |
| SSE eavesdropping | SSE payload encryption (Phase 4) |
| Key loss | Key backup required — recovery impossible without backup (see runbook) |
| Partially migrated data | Migration command is idempotent; skips already-encrypted rows |
Verification Procedures
# Confirm DB columns hold ciphertext
psql -c "SELECT title FROM incidents LIMIT 1;"
# Should return: v1:AAAAAA... (not plaintext)
# Confirm API returns plaintext
curl -H "Authorization: Bearer $JWT" /api/v1/incidents/$ID | jq .title
# Should return: "Power outage at 123 Main St"
# Confirm PSAP sends encrypted body
# Check SendEventHandler debug log — body is plain base64 (nonce||ciphertext), NOT prefixed with enc:
# The enc: prefix is SSE-only. Channel bodies are base64 with X-PSAPLink-Encrypted: 1 header.
# Confirm SSE messages are encrypted
# Open browser devtools → Network → EventStream → check data: starts with enc: