The Constraint Set
VKT VeilMate covers your browser: one click and every tab in every window goes dark behind a curtain, and unlocking puts every page back exactly where it was. It is sold as a privacy curtain for the person who steps away for coffee — not an OS screen lock. That distinction is the whole engineering brief, because an extension gets none of the primitives a real lock screen assumes. What you get is a service worker Chrome recycles after roughly 30 seconds of quiet, a content script running inside the very page it is supposed to hide, and a sync channel capped at 8 KB per item.
Three properties I refused to negotiate:
- State is durable — closing the browser does not unlock; the lock is still there next start until the password is typed.
- Keys are local — PBKDF2-SHA256 at 310,000 iterations, only the derived value on disk, the password never leaves the device and never enters sync.
- The throttle is reliable — the escalating cooldown survives worker death and gates every path that can answer "is this password right?".
Here is how each works, with the real source. Repo comments are Chinese; I translated the ones I quote. Where the code is weaker than the marketing, I say so.
1. The Lock Is State Plus a Projection, Not a Page
The naive design is a lock page: navigate every tab to lock.html, navigate back on unlock. Simpler, and wrong — navigation destroys exactly what the user wants kept: a half-written email, a video at 14:32, a SPA with four screens of unsaved state. So the lock is a boolean in storage.local, and what you see is a projection of it:
// background.js:112-119 — single writer
async function setState(next) {
stateCache = next;
try {
await chrome.storage.local.set({ [STATE_KEY]: next });
} catch {
/* ignore */
}
}
For http/https tabs the projection is a shadow-DOM overlay injected in place — no reload, nothing to restore. It has to survive a hostile page, so critical host properties are written with !important and a MutationObserver repairs them:
// content/veil.js:533-542 (translated, trimmed)
observer = new MutationObserver(() => {
if (!locked || !host) return;
if (!host.isConnected) {
(document.body || document.documentElement).appendChild(host);
assertHostStyles(host);
return;
}
if (hostHidden(host)) assertHostStyles(host);
});
observer.observe(document.documentElement, { childList: true, subtree: true });
Both halves matter: watching only documentElement's direct children means a page that deletes the host never triggers a repair, and a site rule like #veilmate-veil-root{display:none!important} outranks a plain inline declaration — so "still connected" is not "still visible."
Overlays exist only where a content script can run. For chrome://, file://, local PDFs and other extensions' pages, the fallback genuinely is "swap the page" — and that ledger hid my best bug of the year.
2. PBKDF2-SHA256 at 310,000 Iterations
The entire cryptography is fifteen lines, and it is deliberately the slowest thing on the unlock path:
// background.js:57-76 — the whole verifier
async function derive(secret, saltHex, iter = ITER) {
const key = await crypto.subtle.importKey('raw', enc.encode(secret), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt: fromHex(saltHex), iterations: iter, hash: 'SHA-256' },
key,
256
);
return toHex(bits);
}
async function makeHash(secret) {
const salt = toHex(crypto.getRandomValues(new Uint8Array(16)));
return { salt, iter: ITER, hash: await derive(secret, salt) };
}
async function verifySecret(secret, record) {
if (!record || !record.salt) return false;
const h = await derive(secret, record.salt, record.iter || ITER);
return timingSafeEqual(h, record.hash);
}
Parameters. PBKDF2 instead of Argon2id is a platform decision, not taste: the only memory-hard KDF available in an extension is one you ship as WASM, and I am not putting a 100 KB blob between a user and their own browser. SHA-256 because WebCrypto hands it to us; 16 fresh salt bytes per password; extractable: false with deriveBits as the only usage, so the material can never be re-read as a key. The record carries its own iter, which turns raising the cost later into a migration instead of a forced password reset. The 310,000 itself is not benchmarked onto a curve — it is where derivation costs a visible fraction of a second on a laptop and stays trivial for someone who types a password once an hour. What matters is that a guesser pays it every try.
Comparison. timingSafeEqual (background.js:50-55, XOR-accumulating two hex strings) is honest theatre: the practical risk against a 310,000-iteration KDF is nil, and I kept it because === would read as a smell in the next audit. That is a documentation-grade reason, and I am flagging it as one.
// common/store.js:19-22 — everything that is on disk
/**
* PBKDF2: { salt, hash, iter } — only the derived value is stored, never the
* plaintext. No recovery code, no reset entry: forget the password and you
* uninstall the extension.
*/
password: null,
Missing feature. No recovery path, deliberately: a recovery code is a second key, and a second key is a backdoor with better UX. The only exit from a forgotten password is chrome://extensions → Remove, which wipes everything, and the lock screen says so instead of hiding it. That decision is more load-bearing than the iteration count — and it generates the angriest email.
3. Surviving the Restart: Ordering, and a Ledger That Corrupted the Flag
"It survives a browser restart" sounds like a storage choice. It is mostly a message-ordering problem: in a new session the worker boots, tabs restore, and content scripts start asking questions — and the worker can be killed and reborn mid-answer. The session hook repaints the world from disk:
// background.js:578-588 (translated, trimmed)
// A new session does not inherit the password-free window: after a browser
// restart, the first lock still costs a password.
const st0 = await getState();
if (st0.graceUntil) await setState({ ...st0, graceUntil: 0 }).catch(() => {});
const st = await getState();
if (st.locked) {
// A lock left behind is not cleared by closing the browser: the first act of
// the new session is to cover every tab again.
setBadge(true);
updateContextMenus(true);
await paintTabs();
return;
}
A second hook is subtler: the file's top level runs on every worker wake, not just at browser start, so badge, menus, idle interval and schedule alarm are re-derived from disk each time. Chrome replays a one-shot alarm that came due while the browser was shut — if state lived only in memory, the badge would lie.
Now the bug. The page-swap ledger originally lived in the same object as locked, so recording "tab 412's original URL is this" wrote the entire state blob back:
// background.js:121-127 (translated)
/**
* The "page-swap" ledger has its own key now. When it lived inside veilState,
* writing the ledger wrote the whole state back — and in the milliseconds where
* lock and unlock interleave, one such write could flip locked:false to true:
* screen already cleared, badge still lit, new tabs still blocked. Split apart,
* only lock() and unlock() ever write locked.
*/
const REPLACED_KEY = 'veilReplaced';
The rule I took from it: re-read before every write that follows an await. paintTabs() iterates tabs, awaits a scope check per tab, then records and swaps — during which the user may have typed their password:
// background.js:256-263 (translated)
/** Re-read before writing: the lock may have been released during the awaits
* inside this loop. */
const recordAndSwap = async (tabId, url) => {
const cur = await getState();
if (!cur.locked) return false;
await rememberReplaced(tabId, url).catch(() => {});
chrome.tabs.update(tabId, { url: page }).catch(() => {});
return true;
};
One more timing detail: a window opened by Ctrl+N or session restore can exist for a few hundred milliseconds with no tab in it, so guardWindow() runs twice, at 300 ms and 1200 ms, and creates a lock tab if it is still empty.
4. A Throttle That Does Not Count Ticks
Five wrong passwords, then wait. The trap: the natural implementation is a worker-side setTimeout that re-enables the input — which dies whenever Chrome recycles the worker, exactly while a guessing loop would be running. So nothing about the cooldown is a timer. It is arithmetic over persisted timestamps:
// background.js:33-37 and 365-383 — a log, not a counter
const MAX_FAILS = 5;
/** After the 5th failure: 30s -> 2m -> 10m -> 30m -> 1h, capped at 1h */
const COOLDOWN_STEPS = [30000, 2 * 60000, 10 * 60000, 30 * 60000, 60 * 60000];
function failStreak(list) {
let n = 0;
for (const a of list || []) {
if (a.ok) break;
n += 1;
}
return n;
}
function cooldownLeftMs(s) {
const list = s.attempts || [];
const fails = failStreak(list);
if (fails < MAX_FAILS) return 0;
const step = Math.min(fails - MAX_FAILS, COOLDOWN_STEPS.length - 1);
const wait = COOLDOWN_STEPS[step];
const last = Number(list[0]?.at) || 0;
return Math.max(0, wait - (Date.now() - last));
}
Three properties, in order. The deadline comes from the wall clock, so the worker can die and wake an hour later and still report the truth — no ticks, no drift. The "counter" is a log capped at 50 entries where any success breaks the streak, and that same log is the Pro activity history: one dataset serving a security mechanism and a paid feature. And the ladder is a single array, so no UI gets to invent its own numbers.
Enforcement is where the real bug was. Two call sites could answer "is this password correct?" — the unlock form and the change-password form — and the second called verifySecret() directly, making it an unthrottled oracle that also skipped the log:
// background.js:392-408 (translated) — the only password door
/**
* THE single password-verification entry point: cooldown, check, logging and
* unlock all live here. Everything that can learn whether a password is right
* must go through it. The change-password path bypassed both the cooldown and
* the log — an unthrottled oracle that also defeated the "5 failures, wait 30
* seconds" claim I was selling.
*/
async function attemptPassword(secret, method = 'password') {
const cd = await checkCooldown();
if (cd.blocked) return { ok: false, error: 'cooldown', retryIn: cd.retryIn };
const s = await getSettings();
if (!s.hasPassword) return { ok: false, error: 'no_password' };
const ok = await verifySecret(String(secret ?? ''), s.password);
await pushAttempt({ at: Date.now(), ok, method });
return ok ? { ok: true } : { ok: false, error: 'wrong_password' };
}
The countdown on the lock screen is cosmetic (lock/lock.js:109-131 just decrements a number for the human). "May I submit yet?" is always answered by cooldownLeftMs() in the worker, so a faked-out UI buys an attacker nothing.
5. Auto-Lock: the 30-Second Floor and a Daylight-Saving Bug
Idle auto-lock is free and costs almost no code: chrome.idle.setDetectionInterval(minutes * 60) plus an onStateChanged handler that locks on 'idle' when the toggle is on, 10 minutes by default. The same callback hands me the OS screen-lock hook for free — chrome.idle reports 'locked' when the machine's own session locks, the one case where this extension is genuinely downstream of the operating system. Pro ties that to its own toggle.
Focus-loss lock (Pro) is where MV3 bites. Chrome 120+ floors one-shot alarms at 30 seconds, so a 10-second delay cannot be an alarm — and a long one cannot be a setTimeout either:
// background.js:423-453 (translated, trimmed)
/**
* Short delays use setTimeout (after a focus event the worker has at least 30
* seconds of life, which is enough). Anything 30s or longer must be an alarm,
* or MV3 recycling deletes the pending timer and the focus-loss lock never
* fires.
*/
const ALARM_ONE_SHOT_FLOOR_MS = 30000;
function scheduleBlurLock(sec) {
cancelBlurLock();
const ms = Math.max(1, sec) * 1000;
if (ms < ALARM_ONE_SHOT_FLOOR_MS) {
blurTimer = setTimeout(() => {
blurTimer = null;
lock('blur').catch(() => {});
}, ms);
} else {
chrome.alarms.create(BLUR_ALARM, { when: Date.now() + ms });
}
}
Scheduled lock nearly shipped as periodInMinutes: 1440. Wrong twice: a 24-hour interval slides across a daylight-saving change, and a periodic alarm cannot be re-armed against the wall clock. So it is a one-shot computed from a local-time Date, re-armed after each fire, with one mercy clause:
// background.js:512-526 (translated, trimmed)
chrome.alarms.onAlarm.addListener(async (alarm) => {
// The worker may have restarted: trust only what is on disk.
const s = await getSettings();
if (alarm.name === SCHEDULE_ALARM) {
const cfg = s.autoLock.schedule || {};
// An alarm missed while the browser was closed is replayed on launch —
// being locked at 09:00 because you missed 18:00 yesterday is nonsense.
// More than 5 minutes late: re-arm only, do not lock.
const late = alarm.scheduledTime ? Date.now() - alarm.scheduledTime : 0;
if (cfg.enabled && late < 5 * 60000 && (await isPro())) {
const st = await getState();
if (!st.locked) await lock('schedule').catch(() => {});
}
await applySchedule(s);
}
});
alarm.scheduledTime against Date.now() is the same discipline as the cooldown: compare deadlines, never count ticks.
6. Quick-Unlock: Selling Back the Convenience I Locked Away
Pro's quick-unlock window says: for N minutes after a real unlock, locking again can be undone without typing. It is the most commercially sensible and least secure feature in the product, so it gets three clamps:
// background.js:304-311 (translated)
/** Only a verified unlock (password / biometric) starts the window; the
* no-password button itself does not, or it would extend itself forever. */
function graceUntilFor(s, st, reason) {
const verified = reason === 'password' || reason === 'biometric';
if (!verified) return Number(st.graceUntil) || 0;
if (!s.quickUnlock?.enabled) return 0;
const mins = Math.min(60, Math.max(1, Number(s.quickUnlock.minutes) || 5));
return Date.now() + mins * 60000;
}
It never extends itself, it is capped at 60 minutes, and it is zeroed on onStartup: reopening the browser always costs a password, because a grace period that survives a restart is just a lock you forgot about. Changing the password invalidates it, and every no-password unlock writes a quick entry to the activity log, so the audit trail has no holes shaped like convenience.
The framing on the settings page: this turns "nobody at your desk can read your tabs" into "nobody at your desk can read your tabs for about five minutes." Fair for lock-then-grab-the-printer; wrong on a shared machine, and the product should say that instead of letting the toggle look free.
7. WebAuthn Is in the Product, and It Is Not a Security Boundary
I checked the source before writing this, because the tempting story is "no biometrics here." There are: Pro registers a platform authenticator — Windows Hello, Touch ID, an Android fingerprint — in common/webauthn.js, with authenticatorAttachment: 'platform' and userVerification: 'required'. The awkward part is the relying-party ID, since the "site" is an extension origin:
// common/webauthn.js:61-71 (translated, trimmed)
// Declare rp.id as the extension origin's hostname first; a few environments
// reject that origin, so fall back to the browser default.
for (const publicKey of [{ ...base, rp: { id: location.hostname, name: 'VKT VeilMate' } }, { ...base, rp: { name: 'VKT VeilMate' } }]) {
try {
const cred = await navigator.credentials.create({ publicKey });
if (cred?.rawId) return b64urlEncode(cred.rawId);
} catch {
/* try the other rp declaration */
}
}
The unlock side looks like a proper challenge-response, and against replay it is one: the worker mints a nonce with a 60-second TTL, consumed exactly once, and the credential never syncs because it is bound to one device's authenticator:
// background.js:1065-1077 (translated)
case 'veil:biometric-ok':
if (!(await isPro())) return { ok: false, error: 'pro_required' };
// Must carry a valid, unconsumed nonce — this closes the "any message opens
// the lock" back door.
if (!consumeBioChallenge(m.nonce)) return { ok: false, error: 'bad_nonce' };
// Same throttle for both unlock doors: biometrics used to skip the attempt log
// and the cooldown entirely, so five wrong passwords were still a five-second
// unlock for anyone holding a fingerprint-shaped message.
{
const cd = await checkCooldown();
if (cd.blocked) return { ok: false, error: 'cooldown', retryIn: cd.retryIn };
}
await pushAttempt({ at: Date.now(), ok: true, method: 'biometric' });
await unlock('biometric');
return { ok: true };
What the nonce does not do is prove a finger was scanned. The assertion is verified in the lock page; the worker checks the nonce, not a signature. Anyone who can open DevTools on an extension page can request a nonce and post veil:biometric-ok without touching a sensor — the same class of attacker as "can disable the extension," namely someone who already controls the machine. Real enforcement means storing the credential's public key and running crypto.subtle.verify over authenticatorData + clientDataJSON + signature in the worker. I shipped the honest label first and owe the signature: the README and store listing call biometrics "less typing, not more security," and the password remains the boundary. A claim I cannot enforce is a claim I will not print.
8. 8 KB per Item Decides What Travels
Cross-device sync (Pro) rides the browser's own account sync, so there is no "my sync backend is down" outage for me to own. The per-item quota is what shapes the feature list:
// common/store.js:188-219 (translated, trimmed)
/**
* Deliberately excluded:
* 1. password — a shared hash turns "forgot it on one machine" into
* "every device locked forever". Per-device passwords are strictly safer.
* 2. biometric — a WebAuthn credential is bound to one device's authenticator.
* 3. attempts — unlock history is this device's forensic data; it must not roam.
*/
const SYNC_FIELDS = ['clock', 'autoLock', 'quickUnlock', 'hideDetails'];
/**
* sync's cap is 8KB per item, 100KB total. An uploaded wallpaper is 200KB-1.6MB
* even after compression, so custom images (data:image/...) never enter the
* channel: only built-in gradients (preset:xxx) travel.
*/
export function pickSyncable(s) {
const src = s || {};
const out = { v: SYNC_VERSION };
for (const k of SYNC_FIELDS) out[k] = src[k] === undefined ? null : src[k];
const wp = typeof src.appearance?.wallpaper === 'string' ? src.appearance.wallpaper : '';
out.appearance = {
style: normalizeStyle(src.appearance?.style),
wallpaper: wp.startsWith('preset:') ? wp : '',
message: String(src.appearance?.message || '').slice(0, 60)
};
// The domain list is core config and must follow the user; slicing it keeps
// one item clear of the 8KB quota.
const scope = normalizeScope(src.scope);
out.scope = { mode: scope.mode, list: scope.list.slice(0, SCOPE_SYNC_LIMIT) };
return out;
}
So: four preference fields, a stripped appearance object (style, preset-only wallpaper, message cut to 60 characters), and the allowlist truncated at 200 domains. The password is the interesting exclusion — the one field where syncing would be a genuine usability win, still refused, because its failure mode is total. The push also measures its own payload, because a quota exception on a 9 KB object is otherwise silent: background.js:872-873 returns too_large above 8000 bytes.
The pull is timestamp- and licence-gated, because "follow the cloud" and "Pro expired" interact badly: sync.enabled is a leftover of a paid period, and applying remote fields blindly lands focus-loss and scheduled lock as shown on, never executed. The remote write is dropped when isPro() is false, and every push sits behind a 1500 ms debounce so the two directions cannot ping-pong.
9. Permissions, and Why There Is No Keyboard Shortcut
// manifest.json:35-42
"permissions": [
"storage",
"idle",
"tabs",
"windows",
"alarms",
"contextMenus"
],
How I justify each, in the order reviewers ask: storage holds the PBKDF2 record, the durable lock state and Pro's sync payload; idle is the idle auto-lock and nothing else — I subscribe to state transitions, I never read activity times; tabs covers tabs and catches tabs created or navigated while locked; windows is the focus-loss trigger; alarms is the timer that survives worker recycling; contextMenus is the right-click lock and scope actions. The one outbound host is the licence endpoint. There is no webNavigation: the pushState gap is cheaper than the permission, and I wrote that limitation into the "what it cannot do" table instead of leaving it an undocumented hole.
No commands entry exists either. A registered shortcut competes with the page's own bindings — those keys belong to the user's editor, mail composer and video player — and it only earns that conflict if you lock twenty times a day. The cost is real: the fastest "someone is walking over" reaction is one click slower than a hotkey. I took that deal.
10. What I Print on the Settings Page
- It hides your tabs from people — the shoulder glance, the colleague clicking around, new tabs, new windows, typed URLs.
- It cannot stop itself being disabled. An extension cannot prevent its own removal and store policy forbids trying. The escape hatch is deliberate: while locked,
chrome://extensionsis the one built-in page let through — bare, or with this extension's own?id=— and other extensions' detail pages stay covered, because a curtain with no exit is a hostage situation. - It does not defend the person who controls the machine. Another profile, another OS user, a kill from the task manager, DevTools on an extension page — all outside what an extension can reach, my biometric path included.
- It is not encryption. Nothing at rest is encrypted beyond a password hash; the overlay covers the browser, not the monitor, and it does not survive a screenshot tool.
The threat model is the coworker, not the adversary. Saying so costs conversions and buys trust in equal measure, and for a one-person product the second is the durable asset.
Checklist
- Model a lock as persisted state plus a projection; one boolean, one key — a ledger sharing that blob will corrupt the flag under interleaving.
- PBKDF2-SHA256, fresh 16-byte salt,
deriveBitsonly,extractable: false, and keepiterbeside the hash so cost can rise without a reset. - Every path that can answer "is this password right?" goes through one function: cooldown, verify, log. An oracle outside it defeats the throttle you advertise.
- Compute cooldowns from stored timestamps against
Date.now(). Never count ticks in a worker that can die. - Short delays on
setTimeout, 30 s and up onchrome.alarms; wall-clock times need a one-shot re-armed each fire, with a grace clause for late replays. - Re-read state after every await before writing, and bail if the user already unlocked.
- Let the 8 KB per-item quota make your sync decisions, and never sync the password.
- Label convenience as convenience: quick-unlock and biometrics save typing and weaken the curtain.
- Publish what the extension cannot do beside what it can, in the same table.
References
- chrome.alarms API — Chrome Docs
- chrome.idle API — Chrome Docs
- chrome.storage API — Chrome Docs
- Service Worker Lifecycle — Chrome Docs
- PBKDF2 — MDN Web Docs
- OWASP Password Storage Cheat Sheet