Skip to main content

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:

  1. TLS / Transport Security
  2. Database Field-Level Encryption
  3. PSAP↔Cloud Channel Encryption
  4. 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, evaluate sodium_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 Server and X-Powered-By headers

Cloud (nginx)

cloud/nginx/default.conf and standalone.conf:

  • server_tokens off
  • fastcgi_hide_header X-Powered-By
  • add_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 key
  • v2: — 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

TableFields
incidentstitle, description, address, city, state, latitude, longitude, location_raw, close_reason, assistance_requested
incident_communicationscontent
cfs_incidentscaller_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): string
  • decrypt(string $encoded, ?string $agencyId = null): string
  • encryptNullable(), decryptNullable(), encryptArray(), decryptArray() helpers

Encryption is transparent via SensitiveFieldEncryptionListener (cloud/src/EventListener/SensitiveFieldEncryptionListener.php) which handles Doctrine lifecycle events:

  • postLoad: decrypt fields; sync originalEntityData to prevent false dirty detection
  • onFlush: encrypt before SQL generation; backup plaintext
  • postFlush: 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:

  1. JSON body encrypted with ChannelEncryptionService (XSalsa20-Poly1305)
  2. HMAC-SHA256 computed over ciphertext (not plaintext)
  3. X-PSAPLink-Encrypted: 1 header 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

ClassificationExamplesProtection
PII / Restrictedcaller name, caller phone, dispatcher nameDB encryption + TLS
Sensitiveincident description, address, raw XML, CAD narrativesDB encryption + TLS
Internalincident IDs, agency IDs, action stringsTLS only
Publicincident type labels, priority namesNone

CJIS Compliance Mapping

CJIS §5.10 RequirementImplementation
§5.10.1 — Encryption for data at restField-level encryption (Phase 2)
§5.10.1.2 — 128-bit minimum key length256-bit keys (XSalsa20-Poly1305) — see FIPS note above
§5.10.1.3 — Encryption in transitTLS 1.2+ with strong cipher suites (Phase 1)
§5.10.2 — Public key certificatesManaged by Caddy (ACME/Let's Encrypt)

HIPAA Technical Safeguard Mapping

HIPAA §164.312Implementation
§164.312(a)(2)(iv) — Encryption and decryptionPhase 2 field-level encryption
§164.312(e)(1) — Transmission securityPhase 1 TLS + Phase 3 channel encryption
§164.312(e)(2)(ii) — Encryption of ePHI in transitTLS 1.2+ + PSAP channel encryption

Threat Model

ThreatMitigation
Database dumpPer-agency subkey encryption — dump without master key is useless
Agency cross-contaminationPer-agency subkeys — agency A's subkey cannot decrypt agency B's data
MITM on PSAP↔CloudHMAC-SHA256 over ciphertext (Encrypt-then-MAC) + TLS
SSE eavesdroppingSSE payload encryption (Phase 4)
Key lossKey backup required — recovery impossible without backup (see runbook)
Partially migrated dataMigration 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: