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