Skip to content

Commit b8e7690

Browse files
committed
security hardening — unlock brute-force protection + permission rate limiting
1. Unlock rate limiting: 3 failed attempts → 30s cooldown, doubling each 3 attempts (60s, 120s, etc). Resets on successful unlock. 2. Permission request rate limiting: max 5 requests per origin per 60s. Prevents malicious pages from spamming permission prompts. Auto-deny with 'rate_limited' error after threshold. 3. nsec format validation already existed (confirmed during audit).
1 parent d92e183 commit b8e7690

3 files changed

Lines changed: 133 additions & 4 deletions

File tree

distros/safari/background.build.js

Lines changed: 37 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

distros/safari/background.js

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ let nostrAccessWhileLocked = false;
109109

110110
let blockCrossOriginFrames = true;
111111

112+
// Brute-force protection for unlock attempts
113+
let unlockAttempts = 0;
114+
let unlockCooldownUntil = 0;
115+
116+
// Permission request rate limiting per origin
117+
const permissionRateMap = new Map(); // host → { count, resetAt }
118+
112119
// Load persisted state on startup
113120
(async () => {
114121
log('[STARTUP] Reading persisted state...');
@@ -274,8 +281,28 @@ async function lockSession() {
274281
async function unlockSession(password) {
275282
const release = await sessionMutex.acquire();
276283
try {
284+
// Brute-force protection: cooldown after 3 failed attempts
285+
const now = Date.now();
286+
if (now < unlockCooldownUntil) {
287+
const waitSec = Math.ceil((unlockCooldownUntil - now) / 1000);
288+
return { success: false, error: `Too many attempts. Try again in ${waitSec} seconds.` };
289+
}
290+
277291
const valid = await checkPassword(password);
278-
if (!valid) return { success: false, error: 'Invalid password' };
292+
if (!valid) {
293+
unlockAttempts++;
294+
if (unlockAttempts >= 3) {
295+
// Cooldown: 30s after 3, 60s after 6, 120s after 9, etc.
296+
const cooldownMs = 30000 * Math.pow(2, Math.floor((unlockAttempts - 3) / 3));
297+
unlockCooldownUntil = Date.now() + cooldownMs;
298+
log(`[SECURITY] ${unlockAttempts} failed attempts. Cooldown: ${cooldownMs / 1000}s`);
299+
}
300+
return { success: false, error: 'Invalid password' };
301+
}
302+
303+
// Reset on successful unlock
304+
unlockAttempts = 0;
305+
unlockCooldownUntil = 0;
279306

280307
const profiles = await getProfiles();
281308
let needsSave = false;
@@ -1259,6 +1286,26 @@ async function generatePrivateKey_() {
12591286
}
12601287

12611288
async function ask(uuid, { kind, host, payload }) {
1289+
// Rate limit permission requests per origin — prevent spam from malicious pages
1290+
if (host) {
1291+
const now = Date.now();
1292+
const rateEntry = permissionRateMap.get(host) || { count: 0, resetAt: now + 60000 };
1293+
if (now > rateEntry.resetAt) {
1294+
rateEntry.count = 0;
1295+
rateEntry.resetAt = now + 60000;
1296+
}
1297+
rateEntry.count++;
1298+
permissionRateMap.set(host, rateEntry);
1299+
1300+
if (rateEntry.count > 5) {
1301+
log(`[SECURITY] Rate limited ${host}${rateEntry.count} requests in 60s`);
1302+
const sendResponse = validations[uuid];
1303+
delete validations[uuid];
1304+
sendResponse?.({ error: 'rate_limited', message: 'Too many requests. Please wait a moment.' });
1305+
return;
1306+
}
1307+
}
1308+
12621309
// Bunker profiles don't need local key decryption — skip lock check
12631310
const pi = await getProfileIndex();
12641311
const profile = await getProfile(pi);

src/background.js

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ let nostrAccessWhileLocked = false;
109109

110110
let blockCrossOriginFrames = true;
111111

112+
// Brute-force protection for unlock attempts
113+
let unlockAttempts = 0;
114+
let unlockCooldownUntil = 0;
115+
116+
// Permission request rate limiting per origin
117+
const permissionRateMap = new Map(); // host → { count, resetAt }
118+
112119
// Load persisted state on startup
113120
(async () => {
114121
log('[STARTUP] Reading persisted state...');
@@ -274,8 +281,28 @@ async function lockSession() {
274281
async function unlockSession(password) {
275282
const release = await sessionMutex.acquire();
276283
try {
284+
// Brute-force protection: cooldown after 3 failed attempts
285+
const now = Date.now();
286+
if (now < unlockCooldownUntil) {
287+
const waitSec = Math.ceil((unlockCooldownUntil - now) / 1000);
288+
return { success: false, error: `Too many attempts. Try again in ${waitSec} seconds.` };
289+
}
290+
277291
const valid = await checkPassword(password);
278-
if (!valid) return { success: false, error: 'Invalid password' };
292+
if (!valid) {
293+
unlockAttempts++;
294+
if (unlockAttempts >= 3) {
295+
// Cooldown: 30s after 3, 60s after 6, 120s after 9, etc.
296+
const cooldownMs = 30000 * Math.pow(2, Math.floor((unlockAttempts - 3) / 3));
297+
unlockCooldownUntil = Date.now() + cooldownMs;
298+
log(`[SECURITY] ${unlockAttempts} failed attempts. Cooldown: ${cooldownMs / 1000}s`);
299+
}
300+
return { success: false, error: 'Invalid password' };
301+
}
302+
303+
// Reset on successful unlock
304+
unlockAttempts = 0;
305+
unlockCooldownUntil = 0;
279306

280307
const profiles = await getProfiles();
281308
let needsSave = false;
@@ -1259,6 +1286,26 @@ async function generatePrivateKey_() {
12591286
}
12601287

12611288
async function ask(uuid, { kind, host, payload }) {
1289+
// Rate limit permission requests per origin — prevent spam from malicious pages
1290+
if (host) {
1291+
const now = Date.now();
1292+
const rateEntry = permissionRateMap.get(host) || { count: 0, resetAt: now + 60000 };
1293+
if (now > rateEntry.resetAt) {
1294+
rateEntry.count = 0;
1295+
rateEntry.resetAt = now + 60000;
1296+
}
1297+
rateEntry.count++;
1298+
permissionRateMap.set(host, rateEntry);
1299+
1300+
if (rateEntry.count > 5) {
1301+
log(`[SECURITY] Rate limited ${host}${rateEntry.count} requests in 60s`);
1302+
const sendResponse = validations[uuid];
1303+
delete validations[uuid];
1304+
sendResponse?.({ error: 'rate_limited', message: 'Too many requests. Please wait a moment.' });
1305+
return;
1306+
}
1307+
}
1308+
12621309
// Bunker profiles don't need local key decryption — skip lock check
12631310
const pi = await getProfileIndex();
12641311
const profile = await getProfile(pi);

0 commit comments

Comments
 (0)