Skip to content

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):

  1. Generate new KEK version N+1 in BWS.
  2. Batch re-wrap all wrapped_dek values (ciphertext unchanged).
  3. Update kek_version column per row.
  4. 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_deleted
  • consent_audit_log.action = imaging_download_stream_started (replaces imaging_download_url_issued for 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_ENDPOINThttp://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.