Secure Document Storage

Cloudflare R2-based encrypted document storage with classification levels and audit logging.


Overview

The secure document storage system provides end-to-end encryption for sensitive files using Cloudflare R2 object storage combined with AES-256-GCM encryption.

Key Features: - AES-256-GCM encryption for all non-public documents - Document classification (public, internal, confidential, restricted) - Automatic data retention based on classification level - Complete audit logging with IP tracking - User-specific key derivation - unique keys per user


Architecture

┌─────────────────┐     ┌──────────────────┐     ┌─────────────┐
│   Upload        │────▶│  AES-256-GCM     │────▶│  R2 Storage │
│   Request       │     │  Encryption      │     │  (Encrypted)│
└─────────────────┘     └──────────────────┘     └─────────────┘
         │                       │                        │
         ▼                       ▼                        ▼
┌─────────────────┐     ┌──────────────────┐     ┌─────────────┐
│  Database       │     │  Master Key      │     │  Audit Log  │
│  (Metadata)     │     │  (Worker Secret) │     │  (Supabase) │
└─────────────────┘     └──────────────────┘     └─────────────┘

Encryption Flow: 1. File uploaded via API 2. Classification determines if encryption needed 3. Random IV (12 bytes) and salt (16 bytes) generated 4. User-specific key derived from master key + user ID + salt 5. File encrypted with AES-256-GCM 6. Stored as [IV][Salt][EncryptedData] in R2 7. Metadata (IV, salt, classification) stored in database


Document Classification

Level Encryption Retention Use Case
public ❌ None No limit Public logos, avatars
internal ✅ AES-256-GCM 365 days Receipts, general docs
confidential ✅ AES-256-GCM 90 days Invoices, quotes, contracts
restricted ✅ AES-256-GCM 30 days Highly sensitive data

Key Storage: - Master key: Stored only in Cloudflare Worker secrets (ENCRYPTION_MASTER_KEY) - User keys: Derived at runtime, never stored - Database: Only stores metadata (IV, salt, classification)

⚠️ Critical: Loss of master key = permanent data loss for all encrypted documents.


Configuration

1. Create R2 Buckets

# Create buckets for each environment
wrangler r2 bucket create meisterbill-development
wrangler r2 bucket create meisterbill-staging
wrangler r2 bucket create meisterbill-production

2. Generate Encryption Master Key

# Generate a 256-bit (32-byte) key
cd apps/api
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# Output: abc123... (base64 encoded 32 bytes)

3. Configure Wrangler

wrangler.jsonc:

{
  "r2_buckets": [
    {
      "binding": "R2_BUCKET",
      "bucket_name": "meisterbill-development"
    }
  ],
  "env": {
    "staging": {
      "r2_buckets": [
        {
          "binding": "R2_BUCKET",
          "bucket_name": "meisterbill-staging"
        }
      ]
    },
    "production": {
      "r2_buckets": [
        {
          "binding": "R2_BUCKET",
          "bucket_name": "meisterbill-production"
        }
      ]
    }
  }
}

4. Set Secrets

# Set encryption master key for each environment
wrangler secret put ENCRYPTION_MASTER_KEY --env staging
wrangler secret put ENCRYPTION_MASTER_KEY --env production

5. Run Database Migration

# Migration 023 adds files and document_audit_log tables
psql $DATABASE_URL -f database/migrations/023_secure_document_storage.sql

API Reference

Upload Document

POST /v1/documents/upload
Content-Type: multipart/form-data
Authorization: Bearer {token}

Classification-Level: confidential
Entity-Type: invoice
Entity-Id: 550e8400-e29b-41d4-a716-446655440000
Expires-At: 2025-05-01T00:00:00Z

file: (binary data)

Response:

{
  "id": "550e8400-e29b-41d4-a716-446655440001",
  "filename": "original.pdf",
  "sizeBytes": 1048576,
  "classification": "confidential",
  "isEncrypted": true,
  "createdAt": "2025-02-26T12:00:00Z"
}

Download Document

GET /v1/documents/download/{fileId}
Authorization: Bearer {token}

Response: File stream (decrypted automatically)

Headers:

Content-Type: application/pdf
Content-Disposition: attachment; filename="original.pdf"
Cache-Control: private, no-store
X-Document-Classification: confidential

Get Audit Log

GET /v1/documents/{fileId}/audit-log
Authorization: Bearer {token}

Response:

{
  "fileId": "550e8400-e29b-41d4-a716-446655440001",
  "entries": [
    {
      "action": "upload",
      "actorEmail": "user@example.com",
      "ipAddress": "192.168.1.1",
      "createdAt": "2025-02-26T12:00:00Z"
    },
    {
      "action": "download",
      "actorEmail": "user@example.com",
      "ipAddress": "192.168.1.1",
      "createdAt": "2025-02-26T12:30:00Z"
    }
  ]
}

Security Features

Encryption Details

  • Algorithm: AES-256-GCM (authenticated encryption)
  • Key Derivation: HKDF-SHA256
  • IV Length: 12 bytes (random per document)
  • Salt Length: 16 bytes (random per document)
  • Auth Tag: 16 bytes (GCM built-in)

Storage Format

Encrypted files stored in R2 as:

[IV (12 bytes)][Salt (16 bytes)][Encrypted Data + Auth Tag]

Access Control

Classification Owner Same Org Admin Notes
public No encryption
internal Encrypted
confidential Encrypted, org access
restricted Encrypted, owner only

Audit Logging

All actions logged to document_audit_log table: - upload - Document uploaded - download - Document downloaded - delete - Document deleted - view - Metadata viewed - share - Share link created - expire - Document expired


Usage Examples

Upload Invoice Attachment

const formData = new FormData()
formData.append('file', fileInput.files[0])

const response = await fetch('/v1/documents/upload', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${token}`,
    'Classification-Level': 'confidential',
    'Entity-Type': 'invoice',
    'Entity-Id': invoiceId,
  },
  body: formData,
})

const result = await response.json()

Download with Progress

const response = await fetch(`/v1/documents/download/${fileId}`, {
  headers: { 'Authorization': `Bearer ${token}` },
})

const reader = response.body.getReader()
const contentLength = +response.headers.get('Content-Length')

let received = 0
while (true) {
  const { done, value } = await reader.read()
  if (done) break
  received += value.length
  console.log(`Progress: ${(received / contentLength * 100).toFixed(2)}%`)
}

Retention & Cleanup

Documents are automatically cleaned up based on classification:

// Daily cleanup job (maintenance window)
const cleanupExpiredDocuments = async () => {
  const expired = await db
    .select('*')
    .from('files')
    .where('expires_at', '<', new Date())
    .where('classification', '!=', 'public')

  for (const doc of expired) {
    await storageService.delete(doc.storage_key)
    await db.delete('files').where('id', doc.id)
    await auditService.logAction({ action: 'expire', fileId: doc.id, ... })
  }
}

Migration from Storj.io

See Issue #279 for migration script and procedures.

Migration Steps: 1. Deploy new R2-based storage code 2. Run dual-write mode (write to both Storj and R2) 3. Migrate existing files using background job 4. Switch read operations to R2 5. Disable Storj writes 6. Remove Storj code


Troubleshooting

"Encryption master key not initialized"

Cause: ENCRYPTION_MASTER_KEY secret not set

Fix:

wrangler secret put ENCRYPTION_MASTER_KEY --env production

"Access denied: insufficient permissions"

Cause: Trying to access restricted document without ownership

Fix: Ensure user is the document owner or has appropriate permissions

"File not found in storage"

Cause: R2 object deleted but metadata remains in database

Fix: Run cleanup job to sync database with R2 state


Cost Estimates

Component Cost
R2 Storage $0.015/GB/month
R2 Class A Operations $4.50/million
R2 Class B Operations $0.36/million
Bandwidth Free (within Cloudflare)

Example: 10GB storage, 100k uploads, 1M downloads = ~$2.50/month


*Last updated: 2026-02-26