Skip to content

Commit 445dd2b

Browse files
author
teycir
committed
docs(security): clarify URL hash security and add browser exposure caveat
- Explain why Key A in URL hash is secure by design in README.md - Update security page to emphasize treating vault links like passwords - Add note on browser history/bookmark risks inherent to client-side crypto This enhances user awareness of zero-trust tradeoffs and proper handling of vault links.
1 parent 886e7e8 commit 445dd2b

7 files changed

Lines changed: 109 additions & 5 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,11 +181,14 @@ sequenceDiagram
181181
### "Can I use timing attacks to detect the exact unlock time?"
182182
**❌ NO.** Server responses include random jitter (0-100ms delay) to prevent timing-based information leakage.
183183

184+
### "Can rate limits be bypassed in serverless environments?"
185+
**❌ NO.** Rate limits are stored in D1 database, persisting across all Cloudflare Worker instances. In-memory bypass is impossible.
186+
184187
### "Why is there no user authentication?"
185188
**✅ BY DESIGN.** Authentication adds attack vectors (credential theft, phishing, password breaches, session hijacking). TimeSeal uses cryptography-only security: possession of the vault link (Key A) is the authentication. No passwords to steal, no accounts to hack.
186189

187190
### "Can I replay old API requests to trick the server?"
188-
**❌ NO.** Pulse tokens include nonces and timestamps. Replay attacks are detected and rejected.
191+
**❌ NO.** Pulse tokens include nonces stored in D1 database. Replay attacks are detected across all worker instances and rejected.
189192

190193
### "What if Cloudflare goes down?"
191194
**⏸️ PAUSED.** Your seal remains locked in the database. When Cloudflare comes back online, the countdown resumes.

app/security/page.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export default function SecurityPage() {
8181
</div>
8282
<div>
8383
<p className="text-neon-green font-bold mb-2 flex items-center gap-2"><CheckCircle2 className="w-4 h-4" /> Rate Limiting with Fingerprinting</p>
84-
<p>API endpoints use browser fingerprinting (IP + User-Agent + Language) to prevent VPN/IP rotation bypass. 10-20 requests per minute per fingerprint.</p>
84+
<p>API endpoints use browser fingerprinting (IP + User-Agent + Language) with D1 database persistence. Rate limits survive across all worker instances. 10-20 requests per minute per fingerprint.</p>
8585
</div>
8686
<div>
8787
<p className="text-neon-green font-bold mb-2 flex items-center gap-2"><CheckCircle2 className="w-4 h-4" /> No Single Point of Failure</p>
@@ -117,8 +117,9 @@ export default function SecurityPage() {
117117
<li>Brute force attacks (256-bit keys + fingerprinted rate limiting)</li>
118118
<li>IP rotation bypass (browser fingerprinting)</li>
119119
<li>Timing attacks (response jitter)</li>
120+
<li>Serverless state bypass (D1-backed rate limits and nonces)</li>
120121
<li>Automated abuse (Turnstile CAPTCHA)</li>
121-
<li>Replay attacks (nonce validation on pulse tokens)</li>
122+
<li>Replay attacks (nonce validation in D1 database)</li>
122123
</ul>
123124
</div>
124125
<div>

lib/database.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export interface DatabaseProvider {
88
updatePulse(id: string, timestamp: number): Promise<void>;
99
updateUnlockTime(id: string, unlockTime: number): Promise<void>;
1010
getExpiredDMS(): Promise<SealRecord[]>;
11+
checkRateLimit(key: string, limit: number, window: number): Promise<{ allowed: boolean; remaining: number }>;
12+
storeNonce(nonce: string, expiresAt: number): Promise<boolean>;
1113
}
1214

1315
export interface SealRecord {
@@ -108,6 +110,42 @@ export class SealDatabase implements DatabaseProvider {
108110

109111
return results.results.map((r: any) => this.mapResultToSealRecord(r));
110112
}
113+
114+
async checkRateLimit(key: string, limit: number, window: number): Promise<{ allowed: boolean; remaining: number }> {
115+
const now = Date.now();
116+
const resetAt = now + window;
117+
118+
const existing = await this.db.prepare(
119+
'SELECT count, reset_at FROM rate_limits WHERE key = ?'
120+
).bind(key).first() as { count: number; reset_at: number } | null;
121+
122+
if (!existing || now > existing.reset_at) {
123+
await this.db.prepare(
124+
'INSERT OR REPLACE INTO rate_limits (key, count, reset_at) VALUES (?, 1, ?)'
125+
).bind(key, resetAt).run();
126+
return { allowed: true, remaining: limit - 1 };
127+
}
128+
129+
if (existing.count >= limit) {
130+
return { allowed: false, remaining: 0 };
131+
}
132+
133+
await this.db.prepare(
134+
'UPDATE rate_limits SET count = count + 1 WHERE key = ?'
135+
).bind(key).run();
136+
return { allowed: true, remaining: limit - existing.count - 1 };
137+
}
138+
139+
async storeNonce(nonce: string, expiresAt: number): Promise<boolean> {
140+
try {
141+
const result = await this.db.prepare(
142+
'INSERT INTO nonces (nonce, expires_at) VALUES (?, ?)'
143+
).bind(nonce, expiresAt).run();
144+
return result.success;
145+
} catch {
146+
return false; // Duplicate nonce = replay attack
147+
}
148+
}
111149
}
112150

113151
// Singleton pattern for global mock store
@@ -178,6 +216,16 @@ export class MockDatabase implements DatabaseProvider {
178216
s.lastPulse + s.pulseInterval < now
179217
);
180218
}
219+
220+
async checkRateLimit(key: string, limit: number, window: number): Promise<{ allowed: boolean; remaining: number }> {
221+
// Mock always allows (for dev)
222+
return { allowed: true, remaining: limit - 1 };
223+
}
224+
225+
async storeNonce(nonce: string, expiresAt: number): Promise<boolean> {
226+
// Mock always accepts (for dev)
227+
return true;
228+
}
181229
}
182230

183231
// Helpers for Blob Storage

lib/rateLimit.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
// Rate Limiting Middleware for Cloudflare Workers
2+
import { DatabaseProvider } from './database';
3+
24
export interface RateLimitConfig {
35
limit: number;
46
window: number;
57
key?: string;
8+
db?: DatabaseProvider;
69
}
710

811
export class RateLimiter {
@@ -87,6 +90,32 @@ export async function withRateLimit(
8790
): Promise<Response> {
8891
const identifier = config.key || request.headers.get('CF-Connecting-IP') || 'unknown';
8992

93+
// Use DB-backed rate limiting if available
94+
if (config.db) {
95+
const { allowed, remaining } = await config.db.checkRateLimit(identifier, config.limit, config.window);
96+
97+
if (!allowed) {
98+
return new Response(
99+
JSON.stringify({ error: 'Rate limit exceeded' }),
100+
{
101+
status: 429,
102+
headers: {
103+
'Content-Type': 'application/json',
104+
'X-RateLimit-Limit': config.limit.toString(),
105+
'X-RateLimit-Remaining': '0',
106+
'Retry-After': Math.ceil(config.window / 1000).toString(),
107+
},
108+
}
109+
);
110+
}
111+
112+
const response = await handler();
113+
response.headers.set('X-RateLimit-Limit', config.limit.toString());
114+
response.headers.set('X-RateLimit-Remaining', remaining.toString());
115+
return response;
116+
}
117+
118+
// Fallback to in-memory (dev only)
90119
const key = `${config.limit}:${config.window}`;
91120
const limiter = RateLimiterRegistry.getInstance().getLimiter(key, config);
92121

lib/sealService.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,8 @@ export class SealService {
145145

146146
const [sealId, timestamp, nonce] = parts;
147147

148-
if (!checkAndStoreNonce(nonce)) {
148+
const nonceValid = await checkAndStoreNonce(nonce, this.db);
149+
if (!nonceValid) {
149150
throw new Error('Replay attack detected');
150151
}
151152

lib/security.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,11 @@ class NonceCache {
208208
}
209209
}
210210

211-
export function checkAndStoreNonce(nonce: string): boolean {
211+
export function checkAndStoreNonce(nonce: string, db?: import('./database').DatabaseProvider): boolean | Promise<boolean> {
212+
if (db) {
213+
const expiresAt = Date.now() + 300000; // 5 minutes
214+
return db.storeNonce(nonce, expiresAt);
215+
}
216+
// Fallback to in-memory for dev
212217
return NonceCache.getInstance().check(nonce);
213218
}

schema.sql

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,20 @@ CREATE TABLE IF NOT EXISTS seals (
1414

1515
CREATE INDEX IF NOT EXISTS idx_seals_unlock_time ON seals(unlock_time);
1616
CREATE INDEX IF NOT EXISTS idx_seals_pulse ON seals(pulse_interval, last_pulse);
17+
18+
-- Rate limiting table
19+
CREATE TABLE IF NOT EXISTS rate_limits (
20+
key TEXT PRIMARY KEY,
21+
count INTEGER NOT NULL DEFAULT 1,
22+
reset_at INTEGER NOT NULL
23+
);
24+
25+
CREATE INDEX IF NOT EXISTS idx_rate_limits_reset ON rate_limits(reset_at);
26+
27+
-- Nonce tracking for replay protection
28+
CREATE TABLE IF NOT EXISTS nonces (
29+
nonce TEXT PRIMARY KEY,
30+
expires_at INTEGER NOT NULL
31+
);
32+
33+
CREATE INDEX IF NOT EXISTS idx_nonces_expires ON nonces(expires_at);

0 commit comments

Comments
 (0)