LastVet Imaging Envelope Encryption¶
Status: v1 implemented (August 2026)
Related: POSTURE.md, PQ_TLS_POSTURE.md (ingress TLS), ../lastvet/README-zfs-recovery.md (ZFS layer)
Introduction¶
This document describes LastVet's application-layer envelope encryption for veteran imaging studies stored on R940 MinIO. It is written for security engineers and compliance auditors evaluating how LastVet protects imaging PHI at rest and during API-mediated storage operations.
LastVet imaging uses a dedicated imaging_envelope table (not JSONB in documents.metadata) to hold wrapped data keys. The API encrypts plaintext on ingest and decrypts on authorized download. Presigned object URLs are retired when IMAGING_STORAGE_MODE=minio_envelope.
Threat model¶
What envelope encryption protects¶
| Risk | Mitigation |
|---|---|
| PHI at rest on the storage layer (MinIO/ZFS) | Ciphertext only; per-object DEK never stored on disk in plaintext |
| PHI in flight between API and MinIO | API writes ciphertext bytes; no plaintext object PUT |
| Database backup stolen without KEK | Wrapped DEKs are useless without the master KEK from Bitwarden |
| MinIO bucket compromise without KEK | Attacker obtains ciphertext + wrapped DEKs; still cannot decrypt without BWS KEK |
What it does NOT protect¶
| Risk | Notes |
|---|---|
| Compromise of running LastVet API process | Master KEK loaded in memory at boot |
| Compromise of Bitwarden Secrets Manager | KEK source of truth |
| Compromise of veteran or provider device | Endpoint security out of scope |
| Side-channel attacks on Node/OpenSSL | Platform dependency |
Architecture¶
Upload flow¶
sequenceDiagram
participant Client
participant API as LastVet API
participant MinIO as R940 MinIO
participant DB as Postgres
Client->>API: POST /veteran/me/documents/imaging/upload (multipart)
API->>API: Consent + RLS context
API->>API: Generate DEK + nonce
API->>API: AES-256-GCM encrypt plaintext
API->>API: AES-256-KW wrap DEK with KEK
API->>MinIO: PutObject(ciphertext)
API->>DB: INSERT documents + imaging_envelope
API->>Client: 201 documentId
Download flow¶
sequenceDiagram
participant Client
participant API as LastVet API
participant DB as Postgres
participant MinIO as R940 MinIO
Client->>API: GET /veteran/me/documents/:docId/download
API->>API: Consent check + RLS
API->>DB: SELECT imaging_envelope (via RLS join to documents)
API->>DB: INSERT consent_audit_log (imaging_download_stream_started)
API->>MinIO: GetObject(ciphertext)
API->>API: Unwrap DEK, AES-256-GCM decrypt
API->>Client: Stream plaintext (no presigned URL)
Instant consent revocation: each download re-checks consent at request time. No 5-minute presigned URL window.
Key hierarchy¶
Bitwarden (LASTVET_R940_{STAGING|PROD}_IMAGING_MASTER_KEK)
└── Master KEK (256-bit, memory-only in API process)
└── Per-image DEK (256-bit, ephemeral during encrypt/decrypt)
└── Wrapped DEK (40 bytes, RFC 3394) in imaging_envelope.wrapped_dek
└── Ciphertext in MinIO (AES-256-GCM + auth tag in DB row)
Cryptographic choices¶
| Primitive | Use | Rationale |
|---|---|---|
| AES-256-GCM | Object encryption | Authenticated encryption (NIST SP 800-38D); no separate MAC |
| AES-256-KW (RFC 3394) | DEK wrapping | Purpose-built key wrap (NIST SP 800-38F); no nonce management for wrap |
| 256-bit keys | KEK + DEK | PQ guidance: Grover halves effective strength; 256-bit symmetric → ~128-bit quantum-effective (NIST IR 8105) |
References: FIPS 197 (AES), NIST SP 800-38D (GCM), NIST SP 800-38F (Key Wrap), NIST IR 8105 (PQ guidance).
v1 format: Single AES-256-GCM blob, max 512 MiB plaintext. Segmented/chunked format deferred to v2 (envelope_format column reserved).
Key custody model¶
| Key | Location | Persistence |
|---|---|---|
| Master KEK | Bitwarden Secrets Manager | BWS only |
| Master KEK (runtime) | API process memory | Loaded once at boot via initImagingKekLoader(); never logged; never written to disk |
| DEK | API memory | Ephemeral during encrypt/decrypt only |
| Wrapped DEK | imaging_envelope.wrapped_dek |
Postgres BYTEA |
| Nonce + auth tag | imaging_envelope.data_nonce, data_auth_tag |
Postgres BYTEA |
kek_version column enables future rotation without re-encrypting MinIO ciphertext.
Rotation posture¶
Designed for KEK rotation via kek_version. Not implemented in v1.
Future procedure (TODO):
- Generate new KEK version N+1 in BWS.
- Batch re-wrap all
wrapped_dekvalues (ciphertext unchanged). - Update
kek_versioncolumn per row. - Retire old KEK after soak period.
Post-quantum posture¶
Symmetric path (AES-256-GCM, SHA-256 integrity where used) is PQ-safe per NIST IR 8105. No asymmetric crypto in the envelope path. Complements hybrid PQ TLS at ingress (separate document).
Audit surface¶
Logged¶
imaging_envelope_upload_stored(documentId, bucket, objectKey, ciphertextBytes, storageBackend)imaging_envelope_object_deletedconsent_audit_log.action = imaging_download_stream_started(replacesimaging_download_url_issuedfor envelope backend)- Document metadata fields (title, type, size)
NOT logged¶
- Master KEK, DEK, wrapped DEK values
- Plaintext imaging content
- Nonce or auth tag bytes
Failure modes¶
| Condition | Behavior |
|---|---|
KEK unavailable at boot (IMAGING_STORAGE_MODE=minio_envelope) |
Service fails to start (fail-closed) |
| DEK unwrap failure at download | HTTP 500; error logged (tampering or KEK version mismatch) |
| MinIO unreachable | HTTP 503; client may retry |
| Auth tag verification failure | HTTP 500; alert logged (definitive tampering signal) |
| Upload > 512 MiB | HTTP 413 imaging_object_too_large |
Compliance mapping¶
| Requirement | Mapping |
|---|---|
| HIPAA §164.312(a)(2)(iv) | Encryption of ePHI at rest (ciphertext on MinIO; keys separated) |
| HIPAA §164.312(e)(2)(ii) | Complements TLS for ePHI in transit |
| 42 CFR Part 2 | Applies when imaging records are SUD-related; same consent/audit path as other veteran uploads |
Testing methodology¶
| Suite | Coverage target | Scenarios |
|---|---|---|
tests/imaging-envelope/envelopeCrypto.test.ts |
Crypto module | DEK randomness, wrap/unwrap, encrypt/decrypt, tamper detection, wrong KEK, nonce non-reuse |
tests/imaging-envelope/minioEnvelopeBackend.test.ts |
Backend >90% | Upload/download cycle, 512 MiB cap, missing object, delete |
tests/imaging-envelope/imaging-envelope-routes.test.ts |
HTTP layer | Upload success, 413 over cap, 410 deprecated presigned init |
tests/imaging-envelope/imagingKekLoader.test.ts |
KEK loader | Boot load, invalid KEK, mode gating |
Test fixtures use random bytes only. No real DICOM in tests.
Version history¶
| Version | Date | Notes |
|---|---|---|
| v1 | August 2026 | envelope_format=1, 512 MiB cap, single GCM blob, KEK rotation planned |
Environment variables (R940)¶
| Runtime var | BWS secret (staging example) |
|---|---|
IMAGING_STORAGE_MODE |
LASTVET_R940_STAGING_IMAGING_STORAGE_MODE (minio_envelope opt-in) |
IMAGING_MASTER_KEK |
LASTVET_R940_STAGING_IMAGING_MASTER_KEK |
IMAGING_S3_BUCKET |
LASTVET_R940_STAGING_IMAGING_S3_BUCKET |
IMAGING_S3_ENDPOINT |
LASTVET_R940_{STAGING,PROD}_IMAGING_S3_ENDPOINT → http://lastvet-imaging.tailc3bbdc.ts.net:9000 |
IMAGING_S3_ACCESS_KEY |
LASTVET_R940_STAGING_MINIO_IMAGING_ACCESS_KEY (aliased in restore script) |
IMAGING_S3_SECRET_KEY |
LASTVET_R940_STAGING_MINIO_IMAGING_SECRET_KEY (aliased in restore script) |
Default production config remains IMAGING_STORAGE_MODE=s3_presigned until iOS cutover and soak complete.