Skip to content

iOS Envelope Encryption Integration Plan

Historical document. Implementation shipped on main (PR #8, 2026-08-18). Upload uses API-mediated envelope (POST /veteran/me/documents/imaging/upload); prod/staging API uses R940 MinIO (IMAGING_STORAGE_MODE=minio_envelope). Kept for design archaeology only. Current behavior: docs/ARCHITECTURE.md.

Session: 1 (design + scoping only)
Status: Completed — superseded by docs/ARCHITECTURE.md
Date: 2026-08-17
Backend reference: lastvet-gates/docs/security/IMAGING_ENVELOPE_ENCRYPTION.md (canonical), implemented in last.vet-api PR #32
Blocking milestone: Production IMAGING_STORAGE_MODE=minio_envelope cutover requires this iOS work; 10 external TestFlight veterans waiting


Executive summary

LastVet imaging moves from client-direct S3 presigned multipart to API-mediated envelope encryption (Pattern B). The iOS app sends plaintext over TLS; the API encrypts with AES-256-GCM and stores wrapped DEKs in Postgres. iOS never sees KEK or DEK.

Recommendation: Clean cutover to envelope upload. Keep legacy presigned download handling only for existing documents with storage_backend: s3 until those rows are migrated or aged out. Remove presigned upload code paths after envelope ships.

Download is mostly ready today. downloadDocument already handles inline binary streams when the response is not JSON. Envelope downloads use that path.

Upload is the main gap. iOS must replace init → presigned part PUTs → complete with a single POST /veteran/me/documents/imaging/upload multipart request.

Confidence: Medium (see Risks and open questions).


1. Current state analysis

1.1 Networking — LastVetAPIClient.swift

Area Current behavior
Imaging upload uploadImagingDocument() calls POST .../imaging/upload/init, uploads each part via presigned S3 URLs (ImagingMultipartUpload), then POST .../imaging/upload/:documentId/complete
Non-imaging upload uploadDocument() uses postMultipart to POST /veteran/me/documentsthis is the pattern envelope upload should mirror
Imaging download GET /veteran/me/documents/:id/download with Accept: application/json, application/octet-stream. If JSON + downloadUrl, fetches presigned URL. Otherwise treats body as inline binary
Pending upload abandon DELETE .../imaging/upload/:documentId
Auth Bearer JWT via attachAuth; withAuthRetry on API calls
Retry performWithRetry (3 attempts, exponential backoff) on API requests; separate per-part retry in ImagingMultipartUpload (3× per part)
413 handling Maps all 413 to LastVetAPIError.storageCapExceeded (does not distinguish imaging_object_too_large)
410 / 503 Fall through to generic serverError(statusCode)

Upload flow (presigned, today):

Wi‑Fi guard (>50 MB)
  → POST /imaging/upload/init (JSON)
  → for each part: PUT presigned URL (ImagingMultipartUpload, 3× retry)
  → POST /imaging/upload/:id/complete (JSON)
  → return UploadedDocument

Download flow (today, already dual-mode):

GET /documents/:id/download
  → if Content-Type JSON + downloadUrl → GET presigned URL → Data
  → else → inline Data from response body

1.2 ImagingMultipartUpload.swift

Purpose-built for presigned S3 multipart:

  • 8 MiB default part size (S3 5 MiB minimum except last part)
  • Per-part retry with backoff (1s / 2s / 4s)
  • LastVetAPIError.imagingPartUploadFailed(partNumber:reason:)
  • Test hook uploadPartHook for unit tests

Not needed for envelope v1 (single POST, no client-side parts).

1.3 UI — imaging trigger and display

File Role
UploadDocumentView.swift Imaging picker (Photos + Files: PDF, ZIP, DICOM). Calls uploadImagingDocument when documentType == .imaging. Progress bar driven by multipart part fractions (0.05–1.0)
RecordView.swift interruptedImagingUploadBanner for pending uploads ("Upload didn't finish")
HealthRecordViewModel.swift Loads pendingImagingUploads from documents list; abandonPendingImagingUpload
DocumentViewerView (in UploadDocumentView.swift) Downloads on open; renders PDF via PDFKit or raster via UIImage; DICOM/ZIP show "Unsupported document format" (pre-existing gap)

1.4 Connectivity guard — UploadConnectivity.swift

  • NWPathMonitor tracks Wi‑Fi or Ethernet
  • Files > 50 MB blocked on cellular (LastVetAPIError.wifiRequiredForLargeUpload)
  • Applies before upload in uploadImagingDocumentkeep for envelope

1.5 Models / DTOs

Public:

  • UploadedDocument — no metadata or storage_backend field decoded today
  • PendingImagingUpload — id, title, document_type, file_size, created_at
  • DownloadedDocument — data, mimeType, fileName

Private (presigned-only):

  • ImagingUploadInitRequest/Response, ImagingUploadCompleteRequest/Response, ImagingDownloadURLResponse

1.6 Tests

File Coverage
ImagingMultipartUploadTests.swift Part slicing, per-part retry exhaustion — presigned only
No tests LastVetAPIClient.uploadImagingDocument, downloadDocument, envelope paths

1.7 Info.plist and entitlements

Item Value Envelope impact
ITSAppUsesNonExemptEncryption false Still correct — iOS performs no non-exempt crypto; server encrypts
LastVet.entitlements APNs, associated domains No change expected
Deployment target iOS 17.0 (project.yml) No iOS 18+ APIs required for v1
Background modes remote-notification only No background upload entitlement today

1.8 Memory and progress characteristics (important)

Both upload and download load entire file into Data in memory:

  • Upload: selectedFileDatamultipartBody (duplicate buffer) → URLSession.data
  • Download: URLSession.shared.data(for:) loads full response

This existed for presigned uploads but envelope increases peak memory on upload (single multipart body ≈ file size + overhead). At the 512 MiB cap, peak RAM can exceed 1 GB on device. Worth explicit device testing.

No URLSessionUploadTask / delegate progress today for multipart POST — progress UI will need rework (indeterminate spinner or new upload-task delegate).


2. Backend contract summary

Canonical spec: lastvet-gates/docs/security/IMAGING_ENVELOPE_ENCRYPTION.md
Implementation: last.vet-api/src/routes/imaging-uploads.ts, src/lib/imagingDownload.ts, migration 061_imaging_envelope.sql

2.1 Storage mode switch

Variable Values Scope
IMAGING_STORAGE_MODE s3_presigned (legacy default) | minio_envelope Server-wide env var — not per-request

When minio_envelope:

  • Envelope upload route enabled
  • Presigned init, complete, DELETE .../imaging/upload/:docId return 410 Gone (imaging_presigned_upload_retired)
  • Envelope upload route returns 503 when mode is NOT minio_envelope (imaging_envelope_upload_not_enabled)

iOS does not receive mode in a dedicated config endpoint. Mode is inferred from API responses (410 on init = envelope era) or known at deploy time.

2.2 Envelope upload

Endpoint: POST /api/veteran/me/documents/imaging/upload
Auth: Bearer JWT (veteran session)
Content-Type: multipart/form-data

Field Required Notes
file Yes Binary payload
title Yes 1–255 chars
description No max 2000
documentType Yes imaging_dicom | imaging_pdf
documentDate No ISO date string

MIME rules: Must match document type (application/pdf for PDF; DICOM/ZIP/octet-stream for dicom type per backend IMAGING_MIME_BY_TYPE).

Success: 201 Created

{
  "documentId": "uuid",
  "document": { /* UploadedDocument row */ },
  "storageBackend": "minio_envelope",
  "ciphertextBytes": 12345
}

Limits:

  • 512 MiB max per file (IMAGING_ENVELOPE_MAX_BYTES)
  • Veteran storage cap enforced server-side (storage_cap_exceeded, 413)

Error responses (selected):

HTTP error Notes
400 No file uploaded, validation errors
401 Missing veteran identity
413 imaging_object_too_large Includes message, maxBytes
413 storage_cap_exceeded Veteran cap
503 imaging_envelope_upload_not_enabled Wrong mode
503 imaging_upload_not_configured MinIO/KEK not configured
500 Failed to upload imaging study

2.3 Envelope download

Endpoint: GET /api/veteran/me/documents/:docId/download (unchanged path)
Auth: Bearer JWT
Routing: Server inspects document metadata:

  • minio_envelope → stream decrypted plaintext (no presigned URL)
  • s3 (legacy) → JSON { downloadUrl, expiresInSeconds, documentId }
  • Other → local filesystem bytes

Envelope success headers:

  • Content-Type: document mime (e.g. application/pdf)
  • Content-Disposition: inline; filename="..."
  • Cache-Control: no-store, private
  • Body: decrypted plaintext stream

Envelope errors:

HTTP error
404 imaging_envelope_not_found
403 consent_denied (incl. mid-stream revocation for provider path)
500 Failed to decrypt imaging study
503 imaging_download_not_configured

2.4 imaging_envelope Postgres table

Server-only crypto metadata. iOS does not read or write this table. Relevant columns: wrapped_dek, data_nonce, data_auth_tag, object_key, ciphertext_bytes, envelope_format. Document row carries metadata.storage_backend = minio_envelope and upload_status = completed after envelope upload.

2.5 Rate limits

No imaging-specific rate limit surfaced in route handlers beyond general API middleware. 512 MiB cap is the hard size limit documented in spec and Muster memory.

2.6 Legacy presigned endpoints (retired in envelope mode)

Endpoint Envelope mode
POST .../imaging/upload/init 410
POST .../imaging/upload/:id/complete 410
DELETE .../imaging/upload/:id 410

GET /veteran/me/documents still returns pendingImagingUploads for rows stuck in pending state from the presigned era.


3. Gap analysis

3.1 API contract changes

Current iOS call Envelope era Gap
POST .../imaging/upload/init 410 Gone Replace with envelope upload POST
Presigned S3 PUT parts N/A Remove
POST .../imaging/upload/:id/complete 410 Gone Remove
DELETE .../imaging/upload/:id 410 Gone Abandon flow broken for cleanup; need client-side dismiss strategy
GET .../:id/download Works for envelope (inline stream) Minor: error mapping, large-file memory
GET .../documents Unchanged Pending banner may show stale presigned-era rows

3.2 New endpoints iOS must hit

  • POST /veteran/me/documents/imaging/upload — only new upload endpoint

No new download endpoint.

3.3 Data structure changes

Change Detail
Add ImagingEnvelopeUploadResponse Decode documentId, document, storageBackend, ciphertextBytes
Remove presigned private DTOs After cutover: init/complete/part structs
Optional UploadedDocument extension Decode metadata if we need client-side storage_backend for telemetry — not required for v1 download (server routes by DB)
Error body parsing Distinguish imaging_object_too_large vs storage_cap_exceeded vs imaging_presigned_upload_retired

3.4 New error scenarios

Scenario iOS today Needed handling
410 on init (envelope mode, old app) Generic server error N/A post-cutover; TestFlight must ship before prod flip
503 envelope not enabled (staging mismatch) Generic server error User-friendly "imaging upload unavailable"
413 imaging_object_too_large Shown as storage cap message Wrong copy — use server message (512 MB limit)
404 imaging_envelope_not_found on download Generic not found "Document unavailable"
500 decrypt failure Generic server error "Could not load this document" (no crypto detail)
Upload timeout mid-POST Partial state: no pending row (atomic) User retries full upload — simpler than presigned
Stale pendingImagingUploads Retry opens upload sheet; abandon calls DELETE Abandon returns 410 — banner stuck unless client dismisses locally

3.5 UX considerations

Topic Impact
Upload progress Single long request replaces per-part progress; use indeterminate progress or URLSessionUploadTask delegate
Upload duration Server encrypts + writes MinIO before 201; large studies may sit at ~95% longer
Download duration Server decrypts while streaming; first byte may be slower than presigned redirect
Pending upload banner Obsolete for new uploads; may linger for migration — simplify or auto-dismiss on 410 abandon
Wi‑Fi guard Unchanged (>50 MB)
DICOM viewing Pre-existing gap — out of envelope scope unless Ryan wants parity

4. Design proposal

Layer Decision
Upload Envelope only — delete presigned init/parts/complete path
Download Dual path — keep presigned JSON branch for legacy s3 documents only; envelope uses existing inline binary branch
Feature flag Not required if TestFlight validates before prod IMAGING_STORAGE_MODE flip; optional compile-time guard only for emergency rollback builds
iOS version Stay on 17.0

Rationale: AWS S3 retired; presigned upload against prod is dead end once backend flips. Legacy download branch is low cost and protects veterans with old imaging rows.

4.2 File-by-file changes (Session 2)

LastVet/Core/Networking/LastVetAPIClient.swift

  1. Rewrite uploadImagingDocument:
  2. Keep Wi‑Fi guard and ImagingDocumentKind mime resolution
  3. Build multipart body (same fields as backend envelopeUploadMetaSchema + file)
  4. postMultipart to /veteran/me/documents/imaging/upload
  5. Decode ImagingEnvelopeUploadResponse; return document
  6. Progress: start 0.1 → optional upload-task fraction → 1.0 on 201

  7. Extend validateHTTPResponse (or imaging-specific validator):

  8. 410 + imaging_presigned_upload_retired → dedicated error (diagnostics)
  9. 413 + imaging_object_too_large → new error with server message
  10. 503 + imaging_envelope_upload_not_enabled / imaging_upload_not_configured → configuration error

  11. Download (downloadDocument):

  12. Keep presigned JSON branch for legacy docs
  13. Ensure envelope binary path handles large responses (consider downloadTask to temp file in v1.1 — optional stretch)
  14. Map 404/500 imaging errors to viewer-friendly strings

  15. Remove private presigned DTOs and init/complete logic after cutover

  16. abandonPendingImagingUpload:

  17. On 410, treat as success (local dismiss) — pending rows are orphaned server-side in envelope mode

LastVet/Core/Networking/ImagingMultipartUpload.swift

  • Delete (or move to Legacy/ only if Ryan wants a rollback branch artifact)

LastVet/Core/Networking/UploadConnectivity.swift

  • No functional change
  • Consider documenting 512 MiB server cap in UI copy (separate from 50 MiB Wi‑Fi threshold)

LastVet/Features/HealthRecord/UploadDocumentView.swift

  • Update error catches: remove imagingPartUploadFailed; add imagingObjectTooLarge, configuration errors
  • Progress UI: indeterminate ProgressView during envelope upload OR wire upload-task progress
  • Optional copy tweak: "Large files need Wi‑Fi · Max 512 MB"

LastVet/Features/HealthRecord/RecordView.swift + HealthRecordViewModel.swift

  • Pending banner: hide when list empty; on abandon 410, remove locally without surfacing error
  • Long-term: remove pending banner entirely once prod has no pending rows (follow-up cleanup)

LastVetTests/NetworkingTests/ImagingMultipartUploadTests.swift

  • Replace with ImagingEnvelopeUploadTests.swift:
  • Mock URLProtocol for 201 success
  • 413 imaging_object_too_large message propagation
  • 503 not enabled
  • Wi‑Fi guard still enforced (unit test UploadConnectivity with injected path)

Optional new file: LastVetTests/NetworkingTests/ImagingDownloadTests.swift

  • Mock inline PDF stream vs JSON presigned branch

LastVet/Resources/Info.plist

  • No change to ITSAppUsesNonExemptEncryption (remain false)

project.yml

  • No deployment target bump

4.3 Refactoring opportunities (same PR, bounded scope)

  1. Extract shared buildImagingMultipartBody(title:description:documentType:documentDate:fileName:mimeType:data:) used by envelope upload
  2. Consolidate imaging error parsing into ImagingAPIError enum mapped from APIErrorBody
  3. Add URLSession injection to LastVetAPIClient for testability (if not already present)

Do not expand into DICOM viewer, background uploads, or chunked client upload (v2 backend).

4.4 Backward compatibility

Scenario Strategy
Prod backend flips before iOS ships Broken imaging upload (410 on init) — must not happen
iOS ships before backend flip Envelope POST returns 503 — veterans cannot upload imaging until backend enables mode
Legacy S3 documents in DB Download presigned JSON path retained
Pending presigned uploads Stale banner; abandon 410 → local dismiss; optional backend cleanup script (Ryan/ops)

Recommend: Staging validates iOS against minio_envelope first, then TestFlight, then prod backend flip.


5. Effort estimate

Realistic hours for one engineer familiar with the codebase, including typical friction:

Workstream Hours
Upload rewrite + error mapping + abandon 410 handling 10–14
Download hardening + error copy 2–4
Remove presigned multipart code + DTO cleanup 2–3
UI progress + error surfacing 3–5
New unit tests (URLProtocol mocks) 8–12
Manual device testing (small PDF, ~100 MB, Wi‑Fi/cellular guard, download round-trip) 8–12
TestFlight iteration (external veteran feedback, large DICOM/ZIP edge cases) 8–16
Total 41–66 hours (~1–1.5 weeks calendar)

Central estimate for planning: ~50 hours.

Backward-compat presigned download branch: included above (2–4h). No iOS presigned upload fallback recommended.


6. Rollout plan

6.1 Branch strategy

Phase Branch Notes
Session 2 implementation feature/ios-imaging-envelope off main Single focused PR
This document docs/envelope-encryption-plan Review only; merge doc after Ryan approval

6.2 Environment sequencing

sequenceDiagram
  participant iOS as iOS TestFlight
  participant Stg as Staging API (minio_envelope)
  participant TF as External veterans
  participant Prod as Prod API

  iOS->>Stg: E2E upload/download QA
  iOS->>TF: Build N+1 (envelope client)
  TF->>Stg: Soak against staging envelope
  Note over Prod: Do NOT flip until TF validated
  Prod->>Prod: IMAGING_STORAGE_MODE=minio_envelope
  TF->>Prod: Cohort upload/download verify
  1. Enable minio_envelope on staging (if not already)
  2. Merge iOS feature branch → TestFlight build (increment build number)
  3. Internal + 10 external veterans test against staging API (or prod after flip — staging strongly preferred)
  4. After soak (recommend ≥48h, ≥5 successful imaging uploads including one >50 MB on Wi‑Fi): flip production IMAGING_STORAGE_MODE
  5. Monitor API logs: imaging_envelope_upload_stored, decrypt failures, 413 rate

6.3 TestFlight approach

  • Replace current TestFlight build with envelope-capable build (no separate flag required)
  • Bump CURRENT_PROJECT_VERSION in project.yml
  • Release notes: "Imaging upload security upgrade — re-test imaging upload if you use that feature"

6.4 Pre-TestFlight gate (local + Xcode)

  • [ ] All unit tests pass (ImagingEnvelopeUploadTests, existing suite)
  • [ ] Manual: upload PDF imaging <5 MB → appears in record → download → PDF renders
  • [ ] Manual: upload >50 MB on cellular → Wi‑Fi message
  • [ ] Manual: upload >50 MB on Wi‑Fi → success (staging)
  • [ ] Manual: download legacy S3 doc still works (if test row exists)
  • [ ] Manual: abandon/dismiss stale pending banner without crash

6.5 Rollback plan

Failure mode Rollback
Envelope upload broken in prod Backend: set IMAGING_STORAGE_MODE=s3_presigned — but S3 is retired, so this is not viable for prod
iOS envelope client broken Ship previous TestFlight build + keep backend on s3_presigned until fix — only if prod not yet flipped
Decrypt failures server-side Backend/crypto ops issue — iOS rollback does not help

Critical: Prod backend flip is one-way given AWS retirement. iOS must be validated on staging before prod flip. Backend s3_presigned mode is a config rollback lever only if MinIO path fails before cutover — not a long-term option.

6.6 Cutover coordination

Event Owner When
Staging minio_envelope verified Ryan/ops Before iOS Session 2 merge
iOS TestFlight with envelope upload iOS Before prod flip
External cohort soak complete Ryan Gate for prod flip
Prod IMAGING_STORAGE_MODE=minio_envelope Ryan/ops After TestFlight validation
Optional: cleanup pending presigned rows Backend/ops Post-cutover

7. Risks and open questions

7.1 Cryptographic / compliance

Item Assessment
Client-side crypto None in v1 — ITSAppUsesNonExemptEncryption=false remains valid
PHI in transit TLS to API; plaintext in API memory during upload (Pattern B by design)
Export compliance No change expected; confirm in App Store Connect on next submission

7.2 iOS-specific edge cases

Risk Severity Mitigation
Peak memory ~2× file size on upload High for 400+ MB studies Device test near cap; document in known limitation; v2 streaming upload if needed
Full Data download of large studies High Same as today; consider temp-file download follow-up
Upload timeout (nginx/ALB idle) Medium Confirm ingress timeout ≥ largest expected upload duration on R940 path
No upload resume Low Envelope is atomic — acceptable vs presigned multipart resume
DICOM/ZIP not viewable in app Low (pre-existing) Out of scope unless Ryan prioritizes

7.3 Backend contract questions for Ryan

  1. Is staging already on IMAGING_STORAGE_MODE=minio_envelope? Session 2 needs a live target for E2E.
  2. Pending presigned rows: Run a one-time DB cleanup on cutover, or rely on iOS local dismiss only?
  3. Ingress timeout: What is the current Caddy/ALB read_timeout for api.last.vet relative to 512 MiB uploads on typical veteran uplink?
  4. Provider iOS app: Does last.vet-provider have imaging download paths that also need envelope work? (Out of scope for this doc unless shared client exists.)

7.4 Test coverage gaps

  • No integration test against real staging MinIO from iOS CI (expected — manual + TestFlight)
  • No load test for concurrent veteran imaging uploads
  • Legacy S3 download path may lack test fixtures once AWS is gone

7.5 Decisions required before Session 2

# Question Options Recommendation
1 Drop pending-upload banner entirely in envelope build? Remove vs keep with 410 dismiss Keep briefly with 410-local-dismiss; remove in follow-up
2 Upload progress UX Indeterminate vs URLSessionUploadTask progress Upload task progress if ≤4h extra; else indeterminate
3 Prod flip gate TestFlight soak duration ≥48h + 5 successful uploads
4 Legacy S3 download support duration Time-box removal Keep until no storage_backend:s3 imaging rows in prod query
5 DICOM in-app viewing Defer vs scope in Defer (pre-existing gap)

Appendix A — Reference code anchors (current iOS)

Presigned upload entry point:

```161:225:LastVet/Core/Networking/LastVetAPIClient.swift func uploadImagingDocument( _ input: UploadDocumentInput, fileData: Data, ... let initResponse: ImagingUploadInitResponse = try await post( "/veteran/me/documents/imaging/upload/init", body: initBody ) ... let completeResponse: ImagingUploadCompleteResponse = try await post( "/veteran/me/documents/imaging/upload/(initResponse.documentId)/complete", body: completeBody )

Download dual-path (envelope-compatible inline branch):

```227:262:LastVet/Core/Networking/LastVetAPIClient.swift
    func downloadDocument(id: String, preferredFileName: String? = nil) async throws -> DownloadedDocument {
        ...
            if contentType.contains("application/json"),
               let presigned = try? JSONDecoder().decode(ImagingDownloadURLResponse.self, from: data),
               !presigned.downloadUrl.isEmpty {
                ...
            }
            ...
            return DownloadedDocument(data: data, mimeType: mimeType, fileName: fileName)

Existing multipart upload pattern to mirror:

128:158:LastVet/Core/Networking/LastVetAPIClient.swift func uploadDocument(...) async throws -> UploadedDocument { let boundary = "Boundary-\(UUID().uuidString)" let body = multipartBody(boundary: boundary) { form in ... form.addFileField(name: "file", fileName: fileName, mimeType: mimeType, data: fileData) } let response: DocumentUploadResponseDTO = try await postMultipart( "/veteran/me/documents", body: body, boundary: boundary )


Appendix B — Session 2 definition of done

  • [ ] Imaging upload uses envelope POST only
  • [ ] Presigned upload code removed
  • [ ] Download works for envelope + legacy S3
  • [ ] Error messages correct for 413/503/410
  • [ ] Unit tests cover upload + error paths
  • [ ] Staging E2E verified on device
  • [ ] TestFlight build published
  • [ ] Ryan sign-off on prod IMAGING_STORAGE_MODE flip timing

End of Session 1 design document.