Skip to content

23 Encryption and Private Storage

Anubha Parashar edited this page Oct 10, 2026 · 1 revision

HealthBook AI (internal code name: BabyCare) encrypts sensitive data in the application before it reaches the database or the object store. One Fernet key (AES-128-CBC with HMAC-SHA256) protects clinical text columns, OCR transcriptions, original documents, stool photos, camera credentials, access tickets and migration snapshots. The blob store, whether a local directory or a private Supabase bucket, only ever holds ciphertext. Backups add a second, separate key.

Responsibilities

  • Encrypt clinical free text transparently through the EncryptedText SQLAlchemy type.
  • Encrypt every uploaded original before storage, and decrypt it server-side only for an authenticated, authorized request.
  • Refuse to start with a missing key when encrypted data already exists, instead of generating a replacement key.
  • Keep the object store private: no public bucket, no public URLs, and access only from the backend.
  • Produce backups encrypted with their own key, and restore only into empty destinations.

Inputs and outputs

Data Where it is encrypted Where it is stored
Names, titles, instructions, doses, lab values, notes, JSON snapshots EncryptedText.process_bind_param writes enc:<fernet token> Database TEXT columns
OCR or AI transcription encrypt(text), stored in documents.extracted_cipher Database
Uploaded PDF, PNG, JPEG or HEIC encrypt(raw) in the upload handler storage.write_document(<48 hex>.enc)
Stool photos and intake images encrypt(raw) in care_api Same blob store
Signed file ticket encrypt(json) with a 120 s TTL Returned to the client only
Pre-migration SQLite snapshot encrypt(snapshot.serialize()) DATA_DIR/before-migration-*.sqlite.enc
Backups Fernet(BABYCARE_BACKUP_KEY) Operator-chosen path

Key handling (api/crypto.py)

crypto() resolves the application key in this order:

  1. If BABYCARE_FERNET_KEY is set, it is used. Production requires it: api/config.py raises at import time when BABYCARE_ENV=production and the variable is missing.
  2. Otherwise the key comes from DATA_DIR/file_crypt.key, created with O_CREAT | O_EXCL and mode 0600.
  3. If that file is missing, the function first checks for existing encrypted data: any encrypted_files/*.enc, any before-migration-*.enc, or (on SQLite) any patients.name starting with enc:. If any exists, it raises RuntimeError('Existing encrypted records have no application key. Restore the original key; no replacement key was generated.'). A replacement key would leave the old records permanently unreadable alongside new ones.

EncryptedText.process_result_value decrypts values that carry the enc: prefix and passes legacy plaintext through unchanged. Migration 0002 re-encrypts those legacy rows in place. The type's docstring states the limit: dates, ids, statuses and enum columns are not encrypted and depend on disk or database encryption. Database and Data Model lists which columns are encrypted.

Storage backends (api/storage.py)

Backend Selected by Behaviour
LocalStorage BABYCARE_STORAGE=local (default) DATA_DIR/encrypted_files, directory mode 0700. Writes use O_EXCL and 0600, so an existing original is never overwritten.
SupabaseStorage BABYCARE_STORAGE=supabase Supabase Storage REST API at https://<ref>.supabase.co/storage/v1, using the service-role key, which stays on the server. Objects live under documents/ in the bucket (default medical-documents). Writes use x-upsert: false. Transport errors are retried three times.

Shared rules:

  • Object names must be under 70 characters with no /, \ or leading dot.
  • The module never decrypts, never logs object names next to patient identifiers, and never builds public URLs.
  • ensure_bucket() creates the bucket with public: false and a 13 MB size limit. If an existing bucket is public, it raises StorageError, and on startup prepare_storage() fails closed.
  • In production, local storage is refused unless BABYCARE_ALLOW_LOCAL_STORAGE=true, because free hosts have ephemeral disks.
  • MissingObject maps to 404 and StorageError to 503 through handlers that reveal no internals.

Upload and download flow

flowchart TD
    U["Authenticated upload"] --> V["own_patient check"]
    V --> S["Sniff magic bytes<br/>PDF PNG JPEG HEIC"]
    S --> L["Limits<br/>12 MB, 35 pages, 25 MP"]
    L --> HSH["SHA-256 duplicate check<br/>409 if already uploaded"]
    HSH --> E["Fernet encrypt raw bytes"]
    E --> W["write_document<br/>random name dot enc"]
    W -->|"local"| LD[("encrypted_files dir")]
    W -->|"supabase"| SB[("Private bucket<br/>service-role only")]
    W --> ROW["documents row<br/>encrypted title and name"]
    ROW -->|"DB failure"| DEL["delete_document rollback"]
    R["Download, page, thumbnail<br/>or signed ticket"] --> OWN["own_resource check"]
    OWN --> RD["read_document"]
    RD --> DEC["Fernet decrypt on server"]
    DEC --> REN["Render page PNG or<br/>thumbnail if requested"]
    REN --> OUT["Response with no-store<br/>and nosniff"]
Loading

If storage fails during an upload, the handler returns 503 "Encrypted document store unavailable; nothing was saved". If the database commit fails afterwards, the blob it just wrote is deleted.

Server-side rendering and short-lived access

  • GET /api/documents/{did}/pages/{page} decrypts the original and renders one page to PNG (PyMuPDF for PDFs, Pillow for images, at most 1800 px).
  • GET /api/documents/{did}/thumbnail?w= returns a PNG or JPEG preview between 80 and 800 px wide.
  • Clients never receive a storage URL or a key; they only receive the rendered bytes.
  • POST /api/documents/{did}/signed-access returns /api/files/{ticket}. The ticket is a Fernet token over {document_id, user_id, session_hash, expires}, valid for 120 seconds (decrypt(..., ttl=120)). GET /api/files/{ticket} checks that the session still exists and is unexpired and re-runs own_resource. Signing out or revoking caregiver access therefore invalidates outstanding tickets immediately.
  • Public doctor-share documents are decrypted per request only after the token, expiry, revocation and PIN checks pass. See Share With Doctor Architecture.

Backups

Tool Scope Key handling
python -m api.backup create PATH SQLite only: database snapshot, encrypted_files/*.enc, and the application key The whole archive is encrypted with BABYCARE_BACKUP_KEY, which is required and must be stored apart from the backups. Refuses PostgreSQL.
api.backup restore SRC --destination DIR Restores into a directory that must not already exist Rejects path traversal and entries over 1 GB, and writes with xb
scripts/cloud_backup.py create/verify/restore Any database and any store: every table as JSON (datasync), every stored object, and a manifest with row counts and SHA-256 hashes BABYCARE_BACKUP_KEY encrypts the archive. The application key is included only with --include-app-key. Without that key a stolen backup stays unreadable.

datasync copies enc: values verbatim, so it never needs or decrypts the application key. verify checks row counts, object hashes, and that every documents.storage_name has a stored object. restore writes only into an empty database and never overwrites an existing object. Account deletion removes rows and blobs, but offline backups must be expired by the operator's own retention policy.

Migration snapshots

Before Alembic upgrades a populated SQLite database that is not at head, api/migrate.py copies it with the SQLite backup API into memory, serializes it, encrypts it with the application key, and writes before-migration-<timestamp>.sqlite.enc. These files also count as "existing encrypted data" for the no-replacement-key rule.

Security boundaries

  • Ciphertext at rest in both the database and the object store. A leaked bucket or database dump without the key reveals no clinical text.
  • The key is held by the server process only; it never reaches the web or mobile clients.
  • Rotating the key is not implemented. Changing BABYCARE_FERNET_KEY makes existing records unreadable.

Implementation

api/crypto.py, api/storage.py, api/cloud.py (prepare_storage, install_handlers), api/main.py (upload, download), api/workflows.py (document_page, signed_access, signed_file), api/history_api.py (document_thumbnail), api/migrate.py, api/backup.py, api/datasync.py, scripts/cloud_backup.py

Related pages

Security and Privacy Model · Medical Document Ingestion · Database and Data Model · Deployment Architecture · Stool Photo Pipeline

Clone this wiki locally