#!/usr/bin/env node // What the panel must stop doing when the vault is not open. // // Three ways it kept going anyway, all of them silent: // // 1. The auto-lock countdown ran on a Qt Timer, and Qt schedules on // CLOCK_MONOTONIC, which Linux stops while the machine is suspended. A // fifteen-minute lock armed just before the lid closed still had fifteen // minutes left when the lid opened, so a vault left overnight came back // open. The deadline is now kept in wall-clock terms as well. // // 2. The minute count behind that countdown came out of shell.json, and // nothing validates shell.json. A non-numeric value reached QML as NaN and // landed in an `int` property as 0, which is how "never lock" is spelled; a // value past the schema's ceiling overflowed Timer.interval into a negative // number, which never fires. Both readings were a vault that never locked. // // 3. Nothing cancels a `bw` that is already running, so a `bw list items` // started a second before the lock finished afterwards and put the whole // vault -- passwords and all -- back into a panel that had just dropped it. // // node tests/lock-state.test.js const fs = require("fs") const path = require("path") const Model = {} new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8") .replace(/^\.pragma library\s*$/m, "") + ` exports.intSetting = intSetting exports.settingSchemaEntry = settingSchemaEntry exports.SETTINGS_SCHEMA = SETTINGS_SCHEMA exports.autoLockExpired = autoLockExpired exports.autoLockPollMs = autoLockPollMs exports.vaultReadIsStale = vaultReadIsStale exports.parseItems = parseItems `)(Model) let pass = 0 const failures = [] const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`) const panelSrc = fs.readFileSync(path.join(__dirname, "..", "Panel.qml"), "utf8") const bodyOf = (name) => { const start = panelSrc.indexOf(`function ${name}(`) if (start === -1) return "" let depth = 0 for (let i = panelSrc.indexOf("{", start); i < panelSrc.length; i++) { if (panelSrc[i] === "{") depth++ else if (panelSrc[i] === "}" && --depth === 0) return panelSrc.slice(start, i + 1) } return "" } // --- 1. The schema is the same on both sides of shell.json ------------------ // The settings screen clamps to SETTINGS_SCHEMA on the way out and the // marketplace shows manifest.json's min/max, so the two have to agree or the // clamp on the way back in enforces a range nobody was shown. const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "manifest.json"), "utf8")) const manifestEntries = {} for (const e of manifest.barWidget.schema) manifestEntries[e.key] = e for (const entry of Model.SETTINGS_SCHEMA) { if (entry.type !== "int") continue const m = manifestEntries[entry.key] check(`manifest declares ${entry.key}`, !!m, JSON.stringify(Object.keys(manifestEntries))) if (!m) continue check(`${entry.key} min agrees with the manifest`, m.min === entry.min, `${m.min} vs ${entry.min}`) check(`${entry.key} max agrees with the manifest`, m.max === entry.max, `${m.max} vs ${entry.max}`) check(`${entry.key} default agrees with the manifest`, m.defaultValue === entry.defaultValue, `${m.defaultValue} vs ${entry.defaultValue}`) check(`${entry.key} default agrees with the widget defaults block`, manifest.barWidget.defaults[entry.key] === entry.defaultValue, `${manifest.barWidget.defaults[entry.key]} vs ${entry.defaultValue}`) // The ceiling exists so the value can be turned into milliseconds and put in // a Timer, whose interval is a signed 32-bit int. const scale = entry.key === "autoLockMinutes" ? 60 * 1000 : 1000 check(`${entry.key} max still fits Timer.interval`, entry.max * scale <= 2147483647, `${entry.max * scale}`) } // --- 2. Reading a setting back out of shell.json ---------------------------- // `omarchy bar set` writes whatever it is handed -- a bare word becomes a JSON // string, --json stores any number at all -- and the README documents editing // the file by hand, so every one of these is reachable. for (const [key, raw, want, why] of [ ["autoLockMinutes", 15, 15, "an ordinary value is untouched"], ["autoLockMinutes", "30", 30, "the string form `omarchy bar set` writes without --json"], ["autoLockMinutes", 0, 0, "an explicit 0 still means never"], ["autoLockMinutes", "fifteen", 15, "a word falls back to the default, NOT to 0/never"], ["autoLockMinutes", undefined, 15, "an unset key falls back to the default"], ["autoLockMinutes", null, 15, "a null falls back to the default"], ["autoLockMinutes", "", 15, "an empty string falls back to the default"], ["autoLockMinutes", true, 15, "a boolean falls back to the default"], ["autoLockMinutes", 999999, 1440, "a count that would overflow Timer.interval is capped"], ["autoLockMinutes", 1e30, 1440, "so is one written in exponential notation"], // The floor of every integer setting here doubles as its "off" sentinel, so // clamping a negative up to it is the silent never-lock this clamp exists to // refuse, reached from the other side. Below the range is a bad value, not a // request for zero. ["autoLockMinutes", -5, 15, "a negative count is the default, NOT 0/never"], ["autoLockMinutes", "-1", 15, "including the string form"], ["autoLockMinutes", -Infinity, 15, "and the one that arrives as -Infinity"], ["autoLockMinutes", 15.9, 15, "a fraction truncates rather than reaching the Timer"], ["clearClipboardSec", 100000, 300, "the clipboard timeout has its own ceiling"], ["clearClipboardSec", "soon", 30, "and its own default"], ["clearClipboardSec", -1, 30, "and a negative there is not 'never clear' either"], ["autoCopyTotpSec", 999, 30, "so does the TOTP delay"], ["autoCopyTotpSec", "off", 3, "and its default is not 0 either"], ["autoCopyTotpSec", -3, 3, "and a negative is its default too"], ]) { const got = Model.intSetting(key, raw) check(`intSetting(${key}, ${JSON.stringify(raw)}) -> ${want}: ${why}`, got === want, `got ${got}`) } check("every clamped value is a finite integer", [undefined, null, "", "x", {}, [], NaN, Infinity, -Infinity, 1e400].every(v => { const n = Model.intSetting("autoLockMinutes", v) return Number.isInteger(n) && n >= 0 && n <= 1440 }), "one of the junk values escaped the clamp") check("the panel reads its int settings through the clamp", /autoLockMinutes:\s*Model\.intSetting\("autoLockMinutes"/.test(panelSrc) && /clearClipboardSec:\s*Model\.intSetting\("clearClipboardSec"/.test(panelSrc) && /autoCopyTotpSec:\s*Model\.intSetting\("autoCopyTotpSec"/.test(panelSrc), "expected Model.intSetting() on all three integer settings") // --- 3. The auto-lock deadline survives a suspend --------------------------- const t0 = 1700000000000 check("not expired before the window is up", Model.autoLockExpired(t0, 15, t0 + 14 * 60000) === false, "expired early") check("expired the moment the window is up", Model.autoLockExpired(t0, 15, t0 + 15 * 60000) === true, "did not expire") // The bug itself. The shell is frozen across a suspend, so the monotonic Timer // counts only the seconds either side of it; the wall clock counts the night. const awakeMsBeforeSuspend = 60 * 1000 const suspendMs = 12 * 60 * 60 * 1000 check("a twelve-hour suspend expires a fifteen-minute window", Model.autoLockExpired(t0, 15, t0 + awakeMsBeforeSuspend + suspendMs) === true, "the vault would have come back unlocked") check("the monotonic clock alone would not have noticed", awakeMsBeforeSuspend < 15 * 60000, "premise of the test is wrong") check("zero minutes is the user asking for no auto-lock, not an instant one", Model.autoLockExpired(t0, 0, t0 + suspendMs) === false, "locked with auto-lock off") check("an unarmed window has no deadline to have passed", Model.autoLockExpired(0, 15, t0) === false, "locked without ever being armed") check("junk cannot make it lock, or stop it locking", Model.autoLockExpired(NaN, 15, t0) === false && Model.autoLockExpired(t0, NaN, t0 + suspendMs) === false && Model.autoLockExpired(t0, 15, NaN) === false, "a NaN got through") for (const [minutes, want] of [[15, 30000], [60, 30000], [1, 30000], [0, 30000]]) { check(`autoLockPollMs(${minutes}) === ${want}`, Model.autoLockPollMs(minutes) === want, String(Model.autoLockPollMs(minutes))) } check("the poll never outlasts the window it is watching", [1, 5, 15, 1440].every(m => Model.autoLockPollMs(m) <= m * 60000), "poll longer than the window") check("and never busy-loops", [0, 1, 15, 1440].every(m => Model.autoLockPollMs(m) >= 1000), "poll under a second") check("the panel arms the window in wall-clock terms", /autoLockArmedAt\s*=\s*Date\.now\(\)/.test(bodyOf("resetAutoLockTimer")), bodyOf("resetAutoLockTimer")) const watchdog = panelSrc.slice(panelSrc.indexOf("id: autoLockWatchdog"), panelSrc.indexOf("id: autoLockWatchdog") + 900) check("the panel runs a wall-clock watchdog alongside the monotonic timer", panelSrc.includes("id: autoLockWatchdog"), "no autoLockWatchdog Timer") check("the watchdog repeats, or it is just the monotonic timer again", /repeat:\s*true/.test(watchdog), watchdog) check("the watchdog only runs on an unlocked vault", /running:\s*root\.status === "unlocked" && root\.autoLockMinutes > 0/.test(watchdog), watchdog) check("the watchdog asks the wall clock", /Model\.autoLockExpired\(root\.autoLockArmedAt, root\.autoLockMinutes, Date\.now\(\)\)/.test(watchdog), watchdog) check("and locks when it has passed", /root\.lockVault\(\)/.test(watchdog), watchdog) // --- 4. A reader that outlived the vault it was reading --------------------- check("same generation, session still there: fresh", Model.vaultReadIsStale(3, 3, true) === false, "rejected a live result") check("the vault changed hands: stale", Model.vaultReadIsStale(3, 4, true) === true, "accepted a result from a previous vault") check("no session at all: stale whatever the generation says", Model.vaultReadIsStale(3, 3, false) === true, "accepted a result into a locked vault") check("a reader that never recorded a generation is stale, not fresh", Model.vaultReadIsStale(undefined, 0, true) === true, "unstamped read treated as fresh") // Replay of the real sequence, with the panel's own logic standing in for the // panel. `bw list items` is in flight; the vault locks; the list lands. function fakePanel() { return { session: "SESSION-A", vaultEpoch: 0, readEpochs: {}, items: [], itemsLoadedAt: 0, beginVaultRead(name) { this.readEpochs[name] = this.vaultEpoch }, stale(name) { return Model.vaultReadIsStale(this.readEpochs[name], this.vaultEpoch, !!this.session) }, loadItems() { this.beginVaultRead("items") }, onListFinished(raw) { if (this.stale("items")) return this.items = Model.parseItems(raw) this.itemsLoadedAt = 1 }, lockVault() { this.session = ""; this.vaultEpoch += 1; this.items = []; this.itemsLoadedAt = 0 }, unlockAs(token) { this.session = token; this.vaultEpoch += 1 } } } const vaultA = JSON.stringify([ { id: "a1", name: "Bank", type: 1, login: { username: "me", password: "hunter2" } } ]) const vaultB = JSON.stringify([ { id: "b1", name: "Work", type: 1, login: { username: "work", password: "correct-horse" } } ]) let p = fakePanel() p.loadItems() p.lockVault() p.onListFinished(vaultA) check("a list that lands after the lock does not refill the vault", p.items.length === 0, JSON.stringify(p.items)) p = fakePanel() p.loadItems() p.lockVault() p.unlockAs("SESSION-B") // logged out and back in as somebody else p.onListFinished(vaultA) check("nor after a re-login, where it would have been drawn as the new account's", p.items.length === 0, JSON.stringify(p.items)) p.loadItems() p.onListFinished(vaultB) check("the new account's own list is accepted", p.items.length === 1 && p.items[0].id === "b1", JSON.stringify(p.items)) check("and the cache is not marked fresh off a discarded answer", fakePanel().itemsLoadedAt === 0, "premise") // Every reader the panel has, wired at both ends. for (const [name, starter, handler] of [ ["items", "loadItems", "onListFinished"], ["organizations", "loadOrganizations", "onListOrgsFinished"], ["folders", "loadFolders", "onListFoldersFinished"], ["collections", "loadOrgCollections", "onOrgCollectionsLoaded"], ["detail", "openDetail", "onDetailFinished"], ["totp", "startTotpFetch", "onTotpFinished"], ["sends", "loadSends", "onSendsLoaded"], ]) { check(`${starter}() records the vault generation`, new RegExp(`beginVaultRead\\("${name}"\\)`).test(bodyOf(starter)), bodyOf(starter)) check(`${handler}() refuses an answer from a vault that has closed`, new RegExp(`if \\(vaultReadIsStale\\("${name}"\\)\\) return`).test(bodyOf(handler)), bodyOf(handler)) } // Writers and generated values can outlive a lock too. Their server-side // effect may already have happened, but no completion may repopulate the // locked panel, copy a newly created Send URL, or navigate back to main. for (const [name, starter, handler] of [ ["sendCreate", "submitCreateSend", "onSendCreated"], ["sendDelete", "deleteSend", "onSendDeleted"], ["generator", "regenerate", "onGenerated"], ["folderCreate", "submitNewFolder", "onFolderCreated"], ["sync", "syncVault", "onSyncFinished"], ["itemSave", "saveItemForm", "onSaveItemFinished"], ["itemDelete", "deleteCurrentItem", "onDeleteItemFinished"], ["attachment", "pumpAttachmentQueue", "onAttachmentDownloaded"], ]) { check(`${starter}() stamps the vault operation`, new RegExp(`beginVaultRead\\("${name}"\\)`).test(bodyOf(starter)), bodyOf(starter)) check(`${handler}() drops a completion from a vault that has closed`, new RegExp(`if \\(vaultReadIsStale\\("${name}"\\)\\)[\\s\\S]{0,140}return`).test(bodyOf(handler)), bodyOf(handler)) } const droppedState = bodyOf("dropVaultState") check("a queued TOTP request is pinned to the vault generation that queued it", /totpQueuedEpoch\s*=\s*vaultEpoch/.test(bodyOf("fetchTotp")) && /queuedEpoch\s*===\s*root\.vaultEpoch/.test(bodyOf("continueTotpQueue")), bodyOf("fetchTotp") + "\n" + bodyOf("continueTotpQueue")) check("every status request records the current vault generation", /beginEpochOperation\("status"\)/.test(bodyOf("runStatusCheck")), bodyOf("runStatusCheck")) check("status completion refuses a result from an earlier vault generation", /if \(epochOperationIsStale\("status"\)\) return/.test(bodyOf("onStatusFinished")), bodyOf("onStatusFinished")) check("status requests use the generation-stamped launcher", (panelSrc.match(/statusProc\.running\s*=\s*true/g) || []).length === 1 && /statusProc\.running\s*=\s*true/.test(bodyOf("runStatusCheck")), `direct starts: ${(panelSrc.match(/statusProc\.running\s*=\s*true/g) || []).length}`) check("session handoff reads record and verify their vault generation", /beginEpochOperation\("sessionHandoff"\)/.test(bodyOf("refreshStatus")) && /if \(epochOperationIsStale\("sessionHandoff"\)\) return/.test(bodyOf("onSessionHandoff")), bodyOf("refreshStatus") + "\n" + bodyOf("onSessionHandoff")) check("remembered-session lookups record and verify their vault generation", /beginEpochOperation\("keyringLookup"\)/.test(bodyOf("onSessionHandoff")) && /if \(epochOperationIsStale\("keyringLookup"\)\) return/.test(bodyOf("onKeyringLookupFinished")), bodyOf("onSessionHandoff") + "\n" + bodyOf("onKeyringLookupFinished")) check("logout closes any terminal handoff acceptance window", /terminalLoginStartedAt\s*=\s*0/.test(bodyOf("logoutAccount")), bodyOf("logoutAccount")) const abandonedAuth = bodyOf("abandonAuthSecrets") check("abandoning authentication clears every typed or staged auth secret", ["masterPassword", "loginPassword", "loginClientId", "loginClientSecret", "login2faCode", "pendingUnlockPassword", "authPasswordWriteValue", "pinEntry"].every(prop => new RegExp(`\\b${prop}\\s*=\\s*""`).test(abandonedAuth)), abandonedAuth) check("handoff, external unlock, and panel hide purge abandoned auth secrets", /cancelAuthPrewarm\(\)[\s\S]{0,100}abandonAuthSecrets\(\)/.test(bodyOf("onSessionHandoff")) && /if\s*\(st\.unlocked\)[\s\S]{0,120}abandonAuthSecrets\(\)/.test(bodyOf("onStatusFinished")) && /onOpenedChanged:[\s\S]{0,180}else[\s\S]{0,120}abandonAuthSecrets\(\)/.test(panelSrc), bodyOf("onSessionHandoff") + "\n" + bodyOf("onStatusFinished")) check("closing the panel invalidates PIN and fingerprint unlock completions", /abandonAuthSecrets\(\)/.test(bodyOf("close")) && /pinUnlockSubmitted\s*=\s*false/.test(abandonedAuth) && /cancelFingerprintUnlock\(\)/.test(bodyOf("close")), bodyOf("close")) check("closing the panel cancels authentication-method setup writes", /abandonPinSetup\(\)/.test(bodyOf("close")) && /abandonFingerprintSetup\(\)/.test(bodyOf("close")), bodyOf("close")) check("leaving either authentication setup form cancels its in-flight write", /currentScreen\s*!==\s*"pin"[\s\S]*abandonPinSetup\(\)/.test(panelSrc) && /currentScreen\s*!==\s*"fingerprint"[\s\S]*abandonFingerprintSetup\(\)/.test(panelSrc) && /invalidateEpochOperation\("pinStore"\)/.test(bodyOf("abandonPinSetup")) && /invalidateEpochOperation\("masterStore"\)/.test(bodyOf("abandonFingerprintSetup")), bodyOf("abandonPinSetup") + "\n" + bodyOf("abandonFingerprintSetup")) check("PIN completion requires a still-active submitted unlock", /pinUnlockSubmitted\s*&&\s*sshAuthSurfaceActive\s*&&\s*status\s*===\s*"locked"/.test(bodyOf("onPinUnlockResult")), bodyOf("onPinUnlockResult")) check("fingerprint password retrieval requires a live verified attempt", /fingerprintAuthorized/.test(bodyOf("onFingerprintPasswordRetrieved")) && /sshAuthSurfaceActive/.test(bodyOf("onFingerprintPasswordRetrieved")) && /status\s*!==\s*"locked"/.test(bodyOf("onFingerprintPasswordRetrieved")), bodyOf("onFingerprintPasswordRetrieved")) check("remembered-session stores are generation-stamped and stale stores are cleared", /beginEpochOperation\("sessionStore"\)/.test(bodyOf("storeCurrentSession")) && /epochOperationIsStale\("sessionStore"\)/.test(bodyOf("onSessionStored")) && /requestSessionCredentialClear\(\)/.test(bodyOf("onSessionStored")), bodyOf("storeCurrentSession") + "\n" + bodyOf("onSessionStored")) check("a newer session waits for an old store and its cleanup before being remembered", /keyringStoreProc\.running\s*\|\|\s*keyringClearProc\.running/.test(bodyOf("storeCurrentSession")) && /sessionStorePending\s*=\s*true/.test(bodyOf("storeCurrentSession")) && /sessionStorePending\s*=\s*rememberSession\s*&&\s*status\s*===\s*"unlocked"\s*&&\s*!!session/.test(bodyOf("onSessionStored")) && /sessionStorePending[\s\S]{0,100}storeCurrentSession/.test(panelSrc.slice( panelSrc.indexOf("id: keyringClearProc"), panelSrc.indexOf("id: listFoldersProc"))), bodyOf("storeCurrentSession") + "\n" + bodyOf("onSessionStored")) check("PIN stores cannot recreate a credential after the vault generation changes", /beginEpochOperation\("pinStore"\)/.test(bodyOf("submitPinSetup")) && /epochOperationIsStale\("pinStore"\)/.test(bodyOf("onPinStored")) && /requestPinCredentialClear\(\)/.test(bodyOf("onPinStored")), bodyOf("submitPinSetup") + "\n" + bodyOf("onPinStored")) check("master-password stores cannot recreate a credential after lock or logout", /beginEpochOperation\("masterStore"\)/.test(bodyOf("submitFingerprintSetup")) && /epochOperationIsStale\("masterStore"\)/.test(bodyOf("onMasterPasswordStored")) && /requestMasterCredentialClear\(\)/.test(bodyOf("onMasterPasswordStored")), bodyOf("submitFingerprintSetup") + "\n" + bodyOf("onMasterPasswordStored")) check("a learned-association read cannot repopulate account metadata after logout", /associationsReadEpoch\s*=\s*associationsEpoch/.test(bodyOf("loadAssociations")) && /associationsReadEpoch\s*!==\s*associationsEpoch/.test(bodyOf("onAssociationsLoaded")) && /associationsEpoch\s*\+=\s*1/.test(bodyOf("forgetStoredCredentials")), bodyOf("loadAssociations") + "\n" + bodyOf("onAssociationsLoaded") + "\n" + bodyOf("forgetStoredCredentials")) check("credential clears requested during another clear are repeated afterward", /sessionClearPending\s*=\s*true/.test(bodyOf("requestSessionCredentialClear")) && /pinClearPending\s*=\s*true/.test(bodyOf("requestPinCredentialClear")) && /masterClearPending\s*=\s*true/.test(bodyOf("requestMasterCredentialClear")) && /allCredentialsClearPending\s*=\s*true/.test(bodyOf("requestAllCredentialClear")), "one or more keyring clear paths cannot queue a repeat") check("locking uses the centralized local vault purge", /dropVaultState\(\)/.test(bodyOf("lockVault")), bodyOf("lockVault")) check("unreadable, locked, and logged-out status results purge local vault state", (bodyOf("onStatusFinished").match(/dropVaultState\(\)/g) || []).length >= 3, bodyOf("onStatusFinished")) for (const [prop, empty] of [ ["session", '""'], ["items", "[]"], ["filteredItems", "[]"], ["organizations", "[]"], ["folders", "[]"], ["detailItem", "null"], ["formCollections", "[]"], ["formCollectionIds", "[]"], ["formUsername", '""'], ["formUri", '""'], ["formNotes", '""'], ]) { check(`the local vault purge clears ${prop}`, droppedState.includes(`${prop} = ${empty}`), droppedState) } check("the local vault purge cancels and clears attachment work", /cancelAttachmentDownloads\(\)/.test(droppedState) && /attachmentQueue\s*=\s*\[\]/.test(bodyOf("cancelAttachmentDownloads")) && /attachmentBusyId\s*=\s*""/.test(bodyOf("cancelAttachmentDownloads")), droppedState + "\n" + bodyOf("cancelAttachmentDownloads")) check("the local vault purge clears secret fields and collector buffers", /dropVaultSecrets\(\)/.test(droppedState), droppedState) for (const fn of ["onUnlockSuccess", "onSessionHandoff", "onKeyringLookupFinished"]) { check(`${fn}() moves the vault generation on`, /vaultEpoch \+= 1/.test(bodyOf(fn)), bodyOf(fn)) } console.log(`${pass} passed, ${failures.length} failed`) if (failures.length) { console.error("\nFAILURES:\n " + failures.join("\n ")); process.exit(1) }