DEV Community

Cover image for Building Cryptographically Enforced Time-Locked Vaults on Cloudflare's Edge
Teycir Ben Soltane
Teycir Ben Soltane

Posted on • Originally published at github.com

Building Cryptographically Enforced Time-Locked Vaults on Cloudflare's Edge

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:

  1. Trust-based: Server promises not to decrypt early (no enforcement)
  2. Client-side: JavaScript countdown timers (trivially bypassed)
  3. 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
);
Enter fullscreen mode Exit fullscreen mode
  • 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)
  };
}
Enter fullscreen mode Exit fullscreen mode

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)}`;
}
Enter fullscreen mode Exit fullscreen mode

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
  });
}
Enter fullscreen mode Exit fullscreen mode

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
    );
  });
}
Enter fullscreen mode Exit fullscreen mode

Security considerations:

  • Nonce-first validation prevents concurrent replay attacks
  • HMAC signature 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 };
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

Cloudflare Cron Trigger:

# wrangler.toml
[triggers]
crons = ["0 2 * * *"]  # Daily at 2 AM UTC
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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^128 combinations. 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

  1. Split-key architecture eliminates trust: No single party can decrypt early
  2. Edge computing enables global time enforcement: Cloudflare Workers provide consistent time across regions
  3. Atomic operations prevent race conditions: Database transactions are critical for Dead Man's Switch
  4. Browser fingerprinting beats IP-based rate limiting: SHA-256 hashing preserves privacy
  5. 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):

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:

  1. Split keys between client and server
  2. Encrypt everything (client-side + server-side + master key)
  3. Enforce time server-side (never trust the client)
  4. Use edge computing for global consistency
  5. Prevent replay attacks with nonces and signatures
  6. Rate limit aggressively with fingerprinting
  7. 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.


Try it yourself: https://timeseal.online

Source code: https://github.com/teycir/timeseal

Questions? Drop a comment below or open a GitHub issue.

Built with 💚 and 🔒 by Teycir Ben Soltane

Top comments (0)