Data at Rest Encryption - striae-org/striae GitHub Wiki

Overview

Striae encrypts sensitive data at rest across all storage-bound workers using hybrid RSA-OAEP-AES-256-GCM encryption. This ensures that case payloads, user profiles, audit logs, and file/image binaries are never stored in plaintext in Cloudflare R2 or KV.

Scope note:

  • This guide describes worker-side encryption at rest for live system data.
  • It is separate from package-level export encryption used for export/import workflows. See Export Encryption for that topic.
  • Signing (manifests, confirmations, audit exports) is a separate concern. See Manifest and Confirmation Signing.

Design principles:

  • Fail closed. Workers reject reads when decryption keys are missing or all candidate keys fail.
  • Key rotation safe. Every encrypted record embeds its key ID. Decryption tries the record key first, then the active key, then remaining registry keys.
  • Separation of key scopes. Data-at-rest, user KV, and export encryption each use independent key material.
  • Plaintext never stored. Encrypted payloads replace plaintext in R2 and KV; encryption metadata is stored alongside.

Table of Contents

  1. Algorithm and Crypto Model
  2. Encryption Scopes
  3. Key Registry Architecture
  4. Per-Worker Implementation
  5. Envelope Structures
  6. Key Rotation and Fallback Strategy
  7. Decryption Telemetry
  8. Environment Variables and Secrets
  9. Deployment and Key Generation
  10. Operational Notes
  11. Source File Reference
  12. Related Documentation

Algorithm and Crypto Model

All data-at-rest encryption uses the same hybrid scheme:

Parameter Value
Algorithm constant RSA-OAEP-AES-256-GCM
Encryption version 1.0
RSA key format (public) SPKI PEM
RSA key format (private) PKCS8 PEM
RSA operation RSA-OAEP with SHA-256 hash
AES key 256-bit, ephemeral (generated per encryption operation)
AES operation AES-GCM
IV / nonce 96-bit (12 random bytes)
Binary encoding base64url (RFC 4648)

Encryption flow:

  1. Generate a random 256-bit AES-GCM key.
  2. Encrypt plaintext with AES-GCM using a random 12-byte IV.
  3. Wrap (encrypt) the raw AES key with the RSA-OAEP public key.
  4. Store the ciphertext alongside an envelope containing the algorithm, version, key ID, base64url-encoded IV, and base64url-encoded wrapped key.

Decryption flow:

  1. Read the envelope from storage metadata (R2 customMetadata) or the record body (KV).
  2. Unwrap the AES key using the RSA-OAEP private key.
  3. Decrypt the ciphertext with AES-GCM using the unwrapped key and the stored IV.

Encryption Scopes

Striae maintains three independent encryption scopes. Each scope uses the same algorithm but separate key material:

Scope Purpose Workers Storage Write Config Read Config
DATA_AT_REST Case data, audit logs, image/file binaries Data, Audit, Image R2 DATA_AT_REST_ENCRYPTION_PUBLIC_KEY + KEY_ID DATA_AT_REST_ENCRYPTION_PRIVATE_KEY or KEYS_JSON
USER_KV User profile records User KV USER_KV_ENCRYPTION_PUBLIC_KEY + KEY_ID USER_KV_ENCRYPTION_PRIVATE_KEY or KEYS_JSON
EXPORT Client-side export decryption Data App runtime config (public key) EXPORT_ENCRYPTION_PRIVATE_KEY or KEYS_JSON

The export scope is documented separately in Export Encryption. The remainder of this guide focuses on the DATA_AT_REST and USER_KV scopes.

Key Registry Architecture

Each encryption scope supports two configuration modes:

Legacy mode (single key)

Required variables:

  • {SCOPE}_PRIVATE_KEY — PEM-encoded PKCS8 private key
  • {SCOPE}_KEY_ID — string identifier for the key

Suitable for initial deployments or environments that do not require key rotation.

Registry mode (multiple keys with rotation)

Required variables:

  • {SCOPE}_KEYS_JSON — JSON object containing all keys
  • {SCOPE}_ACTIVE_KEY_ID — the key ID used for new encryptions (optional; can be embedded in the JSON)

Registry JSON format:

{
  "activeKeyId": "ear-key-v2",
  "keys": {
    "ear-key-v2": "-----BEGIN PRIVATE KEY-----\n...",
    "ear-key-v1": "-----BEGIN PRIVATE KEY-----\n..."
  }
}

The registry parser also accepts a flat JSON format (without the keys wrapper) for backward compatibility:

{
  "ear-key-v2": "-----BEGIN PRIVATE KEY-----\n...",
  "ear-key-v1": "-----BEGIN PRIVATE KEY-----\n..."
}

When parsing a flat object, metadata keys (activeKeyId, keys) are skipped.

Registry resolution precedence:

  1. {SCOPE}_ACTIVE_KEY_ID environment variable (explicit override)
  2. activeKeyId field inside the JSON payload
  3. Legacy {SCOPE}_KEY_ID (when no registry JSON is configured)

Per-Worker Implementation

Data Worker

Storage backend: R2 (STRIAE_DATA bucket)

Data encrypted: Case JSON payloads, annotation data

Encryption behavior:

  • Controlled by the DATA_AT_REST_ENCRYPTION_ENABLED flag. Accepted values: 1, true, yes, on (case-insensitive).
  • On PUT /data/{filename}: if enabled, encrypts JSON with encryptJsonForStorage() using the configured public key and key ID. Ciphertext is written to R2 with the encryption envelope stored in R2 customMetadata.
  • On GET /data/{filename}: reads R2 object, checks customMetadata for an encryption envelope. If present, decrypts with the key registry. If no envelope is found, returns the value as-is (supports pre-encryption data).

Key scope: DATA_AT_REST

Key registry implementation: workers/data-worker/src/registry/key-registry.ts

Encryption/decryption utilities: workers/data-worker/src/encryption-utils.ts

Key functions:

  • isDataAtRestEncryptionEnabled(env) — checks the feature flag
  • encryptJsonForStorage(plaintextJson, publicKeyPem, keyId) — returns { ciphertext, envelope }
  • decryptJsonFromStorageWithRegistry(ciphertext, envelope, env) — decrypts with key fallback
  • extractDataAtRestEnvelope(file) — reads envelope from R2 customMetadata

Image Worker

Storage backend: R2 (STRIAE_FILES bucket)

Data encrypted: Binary image and file blobs

Encryption behavior:

  • Encrypts uploaded binaries with encryptBinaryForStorage() before writing to R2.
  • Encryption envelope is stored in R2 customMetadata.
  • On read, decrypts with the key registry using envelope metadata from the R2 object.
  • Authenticated reads (via proxy bearer token) and signed-token reads both go through decryption.

Key scope: DATA_AT_REST

Encryption/decryption utilities: workers/image-worker/src/encryption-utils.ts

Key functions:

  • encryptBinaryForStorage(plaintextBytes, publicKeyPem, keyId) — returns { ciphertext, envelope }
  • decryptBinaryFromStorage(ciphertext, envelope, privateKeyPem) — returns decrypted ArrayBuffer
  • validateEnvelope(envelope) — checks algorithm and version before decryption

Audit Worker

Storage backend: R2 (STRIAE_AUDIT bucket)

Data encrypted: Audit trail JSON (daily files per user, audit-trails/{userId}/{YYYY-MM-DD}.json)

Encryption behavior:

  • Encryption is mandatory for audit data. All writes encrypt with encryptJsonForStorage().
  • On POST: reads the existing daily file (if present), decrypts, appends the new entry, re-encrypts, and writes back.
  • On GET: retrieves entries for a date range, decrypting each daily file with the key registry.
  • On DELETE: removes the daily file directly from R2.

Key scope: DATA_AT_REST

Key registry implementation: workers/audit-worker/src/crypto/data-at-rest.ts

Key functions:

  • encryptJsonForStorage(plaintextJson, publicKeyPem, keyId) — returns { ciphertext, envelope }
  • decryptAuditJsonWithRegistry(ciphertext, envelope, env) — decrypts with key fallback and telemetry
  • extractDataAtRestEnvelope(file) — reads envelope from R2 customMetadata

User Worker

Storage backend: Cloudflare KV (USER_DB namespace)

Data encrypted: User profile records (uid, email, name, company, badge ID, permissions, case associations)

Encryption behavior:

  • All user profile writes encrypt with encryptJsonForUserKv() before storing to KV.
  • On read, the stored KV value is parsed with tryParseEncryptedRecord() and validated with validateEncryptedRecord() before decryption.
  • Plaintext/legacy unencrypted KV records are rejected. The worker fails closed on unrecognized record formats.
  • The User Worker also holds DATA_AT_REST_ENCRYPTION keys for accessing case files stored in R2 (STRIAE_DATA, STRIAE_FILES) during cascading account deletion.

Key scope: USER_KV (for KV records), DATA_AT_REST (for R2 case file access)

Envelope storage: Unlike R2-based workers, the full encrypted record (including ciphertext) is stored as a single JSON value in KV:

{
  "algorithm": "RSA-OAEP-AES-256-GCM",
  "encryptionVersion": "1.0",
  "keyId": "user-kv-key-v1",
  "dataIv": "base64url-encoded-iv",
  "wrappedKey": "base64url-encoded-wrapped-key",
  "ciphertext": "base64url-encoded-ciphertext"
}

Key registry implementation: workers/user-worker/src/registry/user-kv.ts

Encryption/decryption utilities: workers/user-worker/src/encryption-utils.ts

Key functions:

  • encryptJsonForUserKv(plaintextJson, publicKeyPem, keyId) — returns serialized UserKvEncryptedRecord JSON string
  • decryptJsonFromUserKv(record, privateKeyPem) — decrypts from a parsed record object
  • tryParseEncryptedRecord(serializedValue) — type-safe parsing of stored KV value
  • validateEncryptedRecord(record) — checks algorithm and version
  • parseUserKvPrivateKeyRegistry(env) — builds key registry from User KV secrets
  • decryptUserKvRecord(encryptedRecord, registry) — decrypts with key fallback and telemetry

PDF Worker

The PDF Worker does not persist data. It accepts JSON payloads, renders HTML reports, and returns PDF responses in-memory via Cloudflare Browser Rendering. No encryption is involved.

Keys Worker

The Keys Worker has been removed from the current architecture. No dedicated key-broker worker exists. Public encryption material is distributed through app runtime config; private material stays in worker secrets.

Envelope Structures

R2-based envelope (Data, Image, Audit Workers)

Stored in R2 object customMetadata:

Field Type Description
algorithm string RSA-OAEP-AES-256-GCM
encryptionVersion string 1.0
keyId string Identifier of the key used for encryption
dataIv string base64url-encoded 12-byte IV
wrappedKey string base64url-encoded RSA-OAEP-wrapped AES key

The R2 object body contains the raw ciphertext bytes.

KV-based envelope (User Worker)

Stored as the complete KV value (JSON string):

Field Type Description
algorithm string RSA-OAEP-AES-256-GCM
encryptionVersion string 1.0
keyId string Identifier of the key used for encryption
dataIv string base64url-encoded 12-byte IV
wrappedKey string base64url-encoded RSA-OAEP-wrapped AES key
ciphertext string base64url-encoded encrypted data

The KV envelope includes ciphertext inline because KV does not support separate metadata like R2's customMetadata.

Key Rotation and Fallback Strategy

When a record needs to be decrypted, the registry builds an ordered candidate list:

  1. Record key — the keyId embedded in the encrypted envelope or record.
  2. Active key — the currently configured active key ID.
  3. All remaining keys — every other key in the registry, in insertion order.

Decryption iterates through candidates in order. The first successful unwrap + decrypt wins. If all candidates fail, the operation throws with a summary of attempt count and the last error.

This strategy allows seamless key rotation:

  • Deploy a new key pair and add it to the registry JSON.
  • Set the new key as the active key ID.
  • New writes use the new key. Old data remains decryptable using the previous key in the registry.
  • No bulk re-encryption migration step is required.

Fail-closed behavior:

  • If no key registry is configured, the worker throws at registry parse time.
  • If the registry contains no usable keys, the worker throws.
  • If the configured active key ID is not present in the registry, the worker throws.
  • If all candidates fail decryption, the worker throws (data is not returned in a partially decrypted or plaintext state).

Decryption Telemetry

Each worker logs structured telemetry after every decryption attempt:

{
  "scope": "data-at-rest | user-kv | audit-at-rest | export-data | export-image",
  "recordKeyId": "key-id-from-record",
  "selectedKeyId": "key-id-that-succeeded",
  "attemptCount": 1,
  "fallbackUsed": false,
  "outcome": "primary-hit | fallback-hit | all-failed",
  "reason": null
}

Outcomes:

  • primary-hit — the first candidate (record key or active key) succeeded.
  • fallback-hit — a non-primary key succeeded, indicating the record was encrypted with a rotated-out key.
  • all-failed — no candidate could decrypt. Logged at warn level with the failure reason.

Monitoring fallback-hit frequency helps track whether old keys can eventually be retired from the registry.

Environment Variables and Secrets

Data-at-rest scope (Data, Image, Audit Workers)

Variable Required Purpose
DATA_AT_REST_ENCRYPTION_ENABLED Data Worker only Feature flag. Accepted: 1, true, yes, on
DATA_AT_REST_ENCRYPTION_PUBLIC_KEY Yes (write path) SPKI PEM public key for encryption
DATA_AT_REST_ENCRYPTION_KEY_ID Yes (write path) Key identifier embedded in envelopes
DATA_AT_REST_ENCRYPTION_PRIVATE_KEY Legacy mode PKCS8 PEM private key for decryption
DATA_AT_REST_ENCRYPTION_KEYS_JSON Registry mode JSON registry of all private keys
DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID Registry mode (optional) Override for the active key

User KV scope (User Worker)

Variable Required Purpose
USER_KV_ENCRYPTION_PUBLIC_KEY Yes SPKI PEM public key for encryption
USER_KV_ENCRYPTION_KEY_ID Yes Key identifier embedded in records
USER_KV_ENCRYPTION_PRIVATE_KEY Legacy mode PKCS8 PEM private key for decryption
USER_KV_ENCRYPTION_KEYS_JSON Registry mode JSON registry of all private keys
USER_KV_ENCRYPTION_ACTIVE_KEY_ID Registry mode (optional) Override for the active key

Export scope (Data Worker)

Documented in Export Encryption — Key Management.

Deployment and Key Generation

scripts/deploy-config.sh automates key material generation and deployment:

  • Generates RSA key pairs for each scope (data-at-rest, user KV, export, manifest signing).
  • Writes public keys and key IDs into app/config/config.json (for export encryption).
  • Maintains {SCOPE}_KEYS_JSON in normalized nested format (activeKeyId + keys).
  • Updates {SCOPE}_ACTIVE_KEY_ID to reference the current key.

Deployment commands:

# Generate/update keys and configuration
npm run deploy-config

# Deploy secrets to all workers
npm run deploy-workers:secrets

# Full deployment (app + workers + secrets)
npm run deploy-all

Key rotation via deploy-config:

# Interactive update (prompts for each scope)
bash ./scripts/deploy-config.sh --update-env

# Force-regenerate all keys
bash ./scripts/deploy-config.sh --force-rotate-keys

# Validate current configuration only
bash ./scripts/deploy-config.sh --validate-only

After rotation, the previous key remains in the registry JSON. New writes use the new active key. Existing data decrypts using the previous key via the fallback strategy.

Operational Notes

  • No plaintext fallback. Workers that encrypt at rest do not serve plaintext responses when decryption fails. Operations fail closed.
  • Encryption is not signing. At-rest encryption protects data confidentiality. Signing (handled by the Data Worker with separate MANIFEST_SIGNING keys) protects export integrity and authenticity.
  • Private keys never leave worker secrets. The browser app only accesses non-secret public keys through runtime config. Private decryption and data-at-rest keys remain in worker secret bindings.
  • User Worker has dual key scopes. It uses USER_KV keys for its own KV records and DATA_AT_REST keys for accessing R2 case files during account deletion cascades.
  • Audit append-cycle. Because audit entries are appended to daily files, the Audit Worker performs a read-decrypt-modify-re-encrypt cycle on each POST. The re-encrypted file uses the current active key.
  • PEM normalization. All workers normalize PEM values on import: trimming whitespace, stripping surrounding quotes, and converting escaped \n to real newlines.

Source File Reference

Component Path
Data Worker encryption utilities workers/data-worker/src/encryption-utils.ts
Data Worker key registry workers/data-worker/src/registry/key-registry.ts
Data Worker config constants workers/data-worker/src/config.ts
Data Worker types workers/data-worker/src/types.ts
Data Worker storage handlers workers/data-worker/src/handlers/storage-routes.ts
Image Worker encryption utilities workers/image-worker/src/encryption-utils.ts
Audit Worker encryption and registry workers/audit-worker/src/crypto/data-at-rest.ts
Audit Worker types workers/audit-worker/src/types.ts
Audit Worker storage workers/audit-worker/src/storage/audit-storage.ts
User Worker encryption utilities workers/user-worker/src/encryption-utils.ts
User Worker key registry workers/user-worker/src/registry/user-kv.ts
User Worker types workers/user-worker/src/types.ts
User Worker storage workers/user-worker/src/storage/user-records.ts
Deployment script scripts/deploy-config.sh
App config example app/config-example/config.json

Related Documentation