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
- Algorithm and Crypto Model
- Encryption Scopes
- Key Registry Architecture
- Per-Worker Implementation
- Envelope Structures
- Key Rotation and Fallback Strategy
- Decryption Telemetry
- Environment Variables and Secrets
- Deployment and Key Generation
- Operational Notes
- Source File Reference
- 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:
- Generate a random 256-bit AES-GCM key.
- Encrypt plaintext with AES-GCM using a random 12-byte IV.
- Wrap (encrypt) the raw AES key with the RSA-OAEP public key.
- Store the ciphertext alongside an envelope containing the algorithm, version, key ID, base64url-encoded IV, and base64url-encoded wrapped key.
Decryption flow:
- Read the envelope from storage metadata (R2
customMetadata) or the record body (KV). - Unwrap the AES key using the RSA-OAEP private key.
- 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:
{SCOPE}_ACTIVE_KEY_IDenvironment variable (explicit override)activeKeyIdfield inside the JSON payload- 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_ENABLEDflag. Accepted values:1,true,yes,on(case-insensitive). - On
PUT /data/{filename}: if enabled, encrypts JSON withencryptJsonForStorage()using the configured public key and key ID. Ciphertext is written to R2 with the encryption envelope stored in R2customMetadata. - On
GET /data/{filename}: reads R2 object, checkscustomMetadatafor 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 flagencryptJsonForStorage(plaintextJson, publicKeyPem, keyId)— returns{ ciphertext, envelope }decryptJsonFromStorageWithRegistry(ciphertext, envelope, env)— decrypts with key fallbackextractDataAtRestEnvelope(file)— reads envelope from R2customMetadata
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 decryptedArrayBuffervalidateEnvelope(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 telemetryextractDataAtRestEnvelope(file)— reads envelope from R2customMetadata
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 withvalidateEncryptedRecord()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_ENCRYPTIONkeys 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 serializedUserKvEncryptedRecordJSON stringdecryptJsonFromUserKv(record, privateKeyPem)— decrypts from a parsed record objecttryParseEncryptedRecord(serializedValue)— type-safe parsing of stored KV valuevalidateEncryptedRecord(record)— checks algorithm and versionparseUserKvPrivateKeyRegistry(env)— builds key registry from User KV secretsdecryptUserKvRecord(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:
- Record key — the
keyIdembedded in the encrypted envelope or record. - Active key — the currently configured active key ID.
- 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 atwarnlevel 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_JSONin normalized nested format (activeKeyId+keys). - Updates
{SCOPE}_ACTIVE_KEY_IDto 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_SIGNINGkeys) 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
\nto 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
- Export Encryption — package-level export encryption and import-time decryption
- Security Guide — security boundaries, auth flow, and data protection overview
- Manifest and Confirmation Signing — signature payload rules and verification
- API Reference — worker endpoint contracts
- Architecture Guide — system architecture and worker boundaries
- Environment Variables Setup — full secret and binding reference
- Audit Trail System — audit event model and storage patterns