Building Cryptographically Enforced Time-Locked Vaults on Cloudflare’s Edge
Most "send a message to the future" apps have a fundamental problem: they rely on trust. The service promises not to peek at your content early, but there’s no cryptographic enforcement. I built TimeSeal to solve this using split-key cryptography and edge computing.
The Core Problem
Traditional time-lock systems have three failure modes:
- Trust-based: Server promises not to decrypt early (no enforcement)
- Client-side: JavaScript countdown timers (trivially bypassed)
- Blockchain: High cost, complexity, and still requires oracle trust
I wanted something different: mathematically impossible to decrypt early, even with full server access.
The S…
Building Cryptographically Enforced Time-Locked Vaults on Cloudflare’s Edge
Most "send a message to the future" apps have a fundamental problem: they rely on trust. The service promises not to peek at your content early, but there’s no cryptographic enforcement. I built TimeSeal to solve this using split-key cryptography and edge computing.
The Core Problem
Traditional time-lock systems have three failure modes:
- Trust-based: Server promises not to decrypt early (no enforcement)
- Client-side: JavaScript countdown timers (trivially bypassed)
- Blockchain: High cost, complexity, and still requires oracle trust
I wanted something different: mathematically impossible to decrypt early, even with full server access.
The Solution: Split-Key Architecture
TimeSeal uses a two-key system where no single party can decrypt the content:
// Client-side: Generate two random keys
const keyA = crypto.getRandomValues(new Uint8Array(32)); // Stays in browser
const keyB = crypto.getRandomValues(new Uint8Array(32)); // Goes to server
// Combine keys for encryption
const combinedKey = await deriveKey(keyA, keyB);
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
combinedKey,
plaintext
);
- Key A: Stored in the URL hash (
#keyA), never transmitted to server - Key B: Sent to server, encrypted with master key, stored in database
The server refuses to release Key B until
Date.now() >= unlockTime. Without both keys, decryption is cryptographically impossible.
Architecture Deep Dive
Layer 1: Client-Side Encryption
// crypto-utils.ts
export async function encryptContent(
content: string,
keyA: Uint8Array,
keyB: Uint8Array
): Promise<EncryptedBlob> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const combinedKey = await importKey(combineKeys(keyA, keyB));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
combinedKey,
new TextEncoder().encode(content)
);
return {
blob: arrayBufferToBase64(ciphertext),
iv: arrayBufferToBase64(iv),
keyB: arrayBufferToBase64(keyB)
};
}
Why AES-GCM?
- Authenticated encryption (prevents tampering)
- Native browser support (
Web Crypto API) - Fast and secure (256-bit keys)
Layer 2: Server-Side Key Protection
Key B is encrypted before database storage:
// api/seal/route.ts
async function encryptKeyB(keyB: string, sealId: string): Promise<string> {
const masterKey = await deriveMasterKey(
env.MASTER_ENCRYPTION_KEY,
sealId
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
masterKey,
base64ToArrayBuffer(keyB)
);
return `${arrayBufferToBase64(iv)}:${arrayBufferToBase64(encrypted)}`;
}
Defense in depth:
- Master key stored as environment secret (not in database)
- Per-seal key derivation using
HKDF - Even with database access, attacker needs
master key + Key A
Layer 3: Time-Lock Enforcement
// api/unlock/route.ts
export async function GET(request: Request) {
const { sealId } = parseRequest(request);
const seal = await db.query('SELECT * FROM seals WHERE id = ?', sealId);
// Server-side time check (client clock irrelevant)
if (Date.now() < seal.unlock_time) {
return Response.json(
{ status: 'LOCKED', countdown: seal.unlock_time - Date.now() },
{ status: 403 }
);
}
// Decrypt Key B and release
const keyB = await decryptKeyB(seal.encrypted_key_b, sealId);
return Response.json({
status: 'UNLOCKED',
keyB,
blob: seal.encrypted_blob,
iv: seal.iv
});
}
Why Cloudflare Workers?
- NTP-synchronized time across global network
- No root access (can’t manipulate system time)
- Edge-native (low latency worldwide)
- Scales automatically
Dead Man’s Switch Implementation
The most interesting feature is the Dead Man’s Switch: content auto-unlocks if you stop checking in.
// Pulse token structure
interface PulseToken {
sealId: string;
nonce: string; // Prevents replay attacks
signature: string; // HMAC-SHA256
}
// api/pulse/route.ts
export async function POST(request: Request) {
const { token } = await request.json();
const { sealId, nonce, signature } = parseToken(token);
// Check nonce FIRST (atomic operation)
const nonceExists = await db.query(
'SELECT 1 FROM used_nonces WHERE nonce = ?',
nonce
);
if (nonceExists) {
throw new Error('Replay attack detected');
}
// Verify signature
const valid = await verifyHMAC(signature, sealId + nonce);
if (!valid) {
throw new Error('Invalid signature');
}
// Atomic update: mark nonce + extend deadline
await db.transaction(async (tx) => {
await tx.query('INSERT INTO used_nonces (nonce) VALUES (?)', nonce);
await tx.query(
'UPDATE seals SET unlock_time = ? WHERE id = ?',
Date.now() + pulseInterval,
sealId
);
});
}
Security considerations:
- Nonce-first validation prevents concurrent replay attacks
HMACsignature prevents token forgery- Atomic transactions prevent race conditions
- Database-backed nonce storage (persists across worker instances)
Ephemeral Seals: Self-Destructing Messages
Ephemeral seals auto-delete after N views:
// api/unlock/route.ts (ephemeral mode)
async function handleEphemeralUnlock(seal: Seal) {
// Atomic view increment + check
const result = await db.query(`
UPDATE seals
SET view_count = view_count + 1
WHERE id = ? AND view_count < max_views
RETURNING view_count, max_views
`, seal.id);
if (!result) {
throw new Error('Max views exceeded');
}
// Auto-delete if exhausted
if (result.view_count >= result.max_views) {
await db.query('DELETE FROM seals WHERE id = ?', seal.id);
}
return { keyB: seal.key_b, viewsRemaining: result.max_views - result.view_count };
}
Use cases:
- One-time passwords (
maxViews=1) - Confidential documents (
maxViews=5) - Shared secrets (
maxViews=10)
Security Hardening
Rate Limiting with Browser Fingerprinting
// lib/rate-limit.ts
async function getRateLimitKey(request: Request): Promise<string> {
const ip = request.headers.get('CF-Connecting-IP');
const ua = request.headers.get('User-Agent');
const lang = request.headers.get('Accept-Language');
// SHA-256 hash for privacy
const fingerprint = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(`${ip}:${ua}:${lang}`)
);
return arrayBufferToBase64(fingerprint);
}
Why fingerprinting?
- IP rotation doesn’t bypass limits
- No PII stored (hashed fingerprints)
- Collision-resistant (
SHA-256)
Replay Attack Prevention
// Timing attack mitigation
async function addRandomJitter() {
const jitter = Math.random() * 100; // 0-100ms
await new Promise(resolve => setTimeout(resolve, jitter));
}
// All responses include jitter
export async function GET(request: Request) {
await addRandomJitter();
// ... handle request
}
Input Validation
// lib/validation.ts
export function validateSealInput(data: unknown): SealInput {
const schema = z.object({
blob: z.string().max(750_000), // 750KB limit
keyB: z.string().length(44), // Base64 32-byte key
unlockTime: z.number().int().positive(),
mode: z.enum(['TIMED', 'DEADMAN', 'EPHEMERAL'])
});
return schema.parse(data);
}
Database Schema
CREATE TABLE seals (
id TEXT PRIMARY KEY,
encrypted_blob TEXT NOT NULL,
encrypted_key_b TEXT NOT NULL,
iv TEXT NOT NULL,
unlock_time INTEGER NOT NULL,
mode TEXT NOT NULL,
view_count INTEGER DEFAULT 0,
max_views INTEGER,
pulse_interval INTEGER,
created_at INTEGER NOT NULL,
unlocked_at INTEGER,
access_count INTEGER DEFAULT 0
);
CREATE INDEX idx_unlock_time ON seals(unlock_time);
CREATE INDEX idx_mode ON seals(mode);
CREATE TABLE used_nonces (
nonce TEXT PRIMARY KEY,
created_at INTEGER NOT NULL
);
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
seal_id TEXT NOT NULL,
action TEXT NOT NULL,
fingerprint TEXT NOT NULL,
timestamp INTEGER NOT NULL,
metadata TEXT
);
Auto-Cleanup System
Seals auto-delete 30 days after unlock:
// api/cron/cleanup/route.ts
export async function GET(request: Request) {
const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000);
const expiredSeals = await db.query(`
SELECT id FROM seals
WHERE unlocked_at IS NOT NULL
AND unlocked_at < ?
`, thirtyDaysAgo);
for (const seal of expiredSeals) {
await db.query('DELETE FROM seals WHERE id = ?', seal.id);
}
return Response.json({ deleted: expiredSeals.length });
}
Cloudflare Cron Trigger:
# wrangler.toml
[triggers]
crons = ["0 2 * * *"] # Daily at 2 AM UTC
Frontend: Next.js 14 App Router
// app/create/page.tsx
'use client';
export default function CreateSeal() {
const [content, setContent] = useState('');
const [unlockTime, setUnlockTime] = useState<Date>();
async function handleCreate() {
// Generate keys in browser
const keyA = crypto.getRandomValues(new Uint8Array(32));
const keyB = crypto.getRandomValues(new Uint8Array(32));
// Encrypt content
const encrypted = await encryptContent(content, keyA, keyB);
// Send to server (Key A never transmitted)
const response = await fetch('/api/seal', {
method: 'POST',
body: JSON.stringify({
blob: encrypted.blob,
keyB: encrypted.keyB,
iv: encrypted.iv,
unlockTime: unlockTime.getTime()
})
});
const { sealId } = await response.json();
// Build vault link with Key A in hash
const vaultLink = `${window.location.origin}/vault/${sealId}#${arrayBufferToBase64(keyA)}`;
// Show link to user
setVaultLink(vaultLink);
}
return (
<form onSubmit={handleCreate}>
<textarea value={content} onChange={e => setContent(e.target.value)} />
<input type="datetime-local" onChange={e => setUnlockTime(new Date(e.target.value))} />
<button type="submit">Create Time-Seal</button>
</form>
);
}
Deployment
# Install Wrangler CLI
npm install -g wrangler
# Create D1 database
wrangler d1 create timeseal-db
# Run migrations
wrangler d1 migrations apply timeseal-db
# Set secrets
openssl rand -base64 32 | wrangler secret put MASTER_ENCRYPTION_KEY
# Deploy to Cloudflare Workers
npm run deploy
wrangler.toml:
name = "timeseal"
main = "src/index.ts"
compatibility_date = "2024-01-01"
[[d1_databases]]
binding = "DB"
database_name = "timeseal-db"
database_id = "your-database-id"
[vars]
MAX_DURATION_DAYS = 30
RATE_LIMIT_WINDOW = 3600
RATE_LIMIT_MAX = 100
Attack Scenarios & Defenses
"Can I change my computer’s clock?"
No. Time checks happen server-side using Cloudflare’s NTP-synchronized infrastructure. Your local clock is irrelevant.
"What if I steal the database?"
You get encrypted blobs. Without the master encryption key (environment secret) and Key A (URL hash), decryption is impossible.
"Can I replay pulse tokens?"
No. Nonces are checked atomically before processing. Concurrent requests are detected and rejected.
"Can I brute-force the seal ID?"
Extremely unlikely. Seal IDs are 32 hex characters (16 bytes) =
2^128combinations. Even if you guess it, you still need Key A to decrypt.
Performance Metrics
- Seal creation:
~200ms(includes encryption + DB write) - Unlock check:
~50ms(DB query + time check) - Decryption:
~10ms(client-side, Web Crypto API) - Global latency:
<100ms(Cloudflare edge network)
Lessons Learned
- Split-key architecture eliminates trust: No single party can decrypt early
- Edge computing enables global time enforcement: Cloudflare Workers provide consistent time across regions
- Atomic operations prevent race conditions: Database transactions are critical for Dead Man’s Switch
- Browser fingerprinting beats IP-based rate limiting: SHA-256 hashing preserves privacy
- URL hash is perfect for client-side secrets: Never transmitted to server, HTTPS-protected
Open Source & Self-Hosting
TimeSeal is open source under the Business Source License (converts to Apache 2.0 after 4 years):
- GitHub: https://github.com/teycir/timeseal
- Live Demo: https://timeseal.online
- Docs: Complete API reference, security audit, self-hosting guide
Self-hosting lets you:
- Eliminate trust in third-party infrastructure
- Customize retention policies
- Deploy on private networks
- Audit the entire stack
Future Roadmap
- Progressive disclosure: Chain multiple seals for staged reveals
- Multi-party seals: Require N-of-M keys to unlock
- Hardware security modules: Store master key in Cloudflare’s HSM
- Blockchain anchoring: Publish seal hashes for tamper-evidence
Conclusion
Building cryptographically enforced time locks requires careful architecture:
- Split keys between client and server
- Encrypt everything (client-side + server-side + master key)
- Enforce time server-side (never trust the client)
- Use edge computing for global consistency
- Prevent replay attacks with nonces and signatures
- Rate limit aggressively with fingerprinting
- Audit everything for transparency
The result is a system where early decryption is mathematically impossible, even with full server access. No trust required—just cryptography and edge computing.
Built with 💚 and 🔒 by Teycir Ben Soltane