Files
asepharyana 1cdb82a76f Sync config from arch
- hypr/apps.lua
- hypr/autostart.lua
- hypr/envs.lua
- hypr/hyprland.lua
- hypr/hyprsunset.conf
- hypr/input.lua
- hypr/looknfeel.lua
- hypr/omasettings.lua
- hypr/xdph.conf
- omarchy/branding/about.txt
- omarchy/branding/screensaver.txt
- omarchy/extensions/omarchy-menu.jsonc
- omarchy/hooks/battery-low.d/play-warning-sound.sample
- omarchy/hooks/font-set.d/show-font-notification.sample
- omarchy/hooks/post-boot.d/weather.sample
- omarchy/hooks/post-update.d/install-voxtype.hook
- omarchy/hooks/post-update.d/setup-agent.hook
- omarchy/hooks/post-update.d/setup-fingerprint.hook
- omarchy/hooks/post-update.d/show-update-notification.sample
- omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample
- omarchy/hooks/theme-set.d/show-theme-notification.sample
- omarchy/shell.json
- omarchy/shell.toml
- omarchy/theme.name
- omarchy/themes/azure-glow/README.md
- omarchy/themes/azure-glow/alacritty.toml
- omarchy/themes/azure-glow/btop.theme
- omarchy/themes/azure-glow/hyprland.conf
- omarchy/themes/azure-glow/hyprlock.conf
- omarchy/themes/azure-glow/icons.theme
- … 269 more
2026-09-23 15:19:12 +07:00

1196 lines
69 KiB
JavaScript

#!/usr/bin/env node
// Tests for the commands that unlock the vault: unlock, email login, API key
// login. The property under test is the one that matters most here -- none of
// them may put a credential in an argv, because /proc/<pid>/cmdline is
// world-readable on a default Linux install and these are the credentials that
// open everything else.
//
// node tests/auth.test.js
const fs = require("fs")
const os = require("os")
const path = require("path")
const { execFileSync, spawnSync } = require("child_process")
const Model = {}
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
.replace(/^\.pragma library\s*$/m, "") + `
exports.unlockPrewarmCommand = unlockPrewarmCommand
exports.emailLoginPrewarmCommand = emailLoginPrewarmCommand
exports.apiKeyLoginCommand = apiKeyLoginCommand
exports.loginServerUrlFor = typeof loginServerUrlFor === "function" ? loginServerUrlFor : null
exports.passwordEnvVar = passwordEnvVar
exports.clientIdEnvVar = clientIdEnvVar
exports.clientSecretEnvVar = clientSecretEnvVar
exports.twoFactorCodeEnvVar = twoFactorCodeEnvVar
exports.loginNeedsSecondFactor = typeof loginNeedsSecondFactor === "function" ? loginNeedsSecondFactor : null
exports.loginNeedsDeviceVerification = typeof loginNeedsDeviceVerification === "function" ? loginNeedsDeviceVerification : null
exports.loginNeedsMethodChoice = typeof loginNeedsMethodChoice === "function" ? loginNeedsMethodChoice : null
exports.deviceVerificationLoginCommand = typeof deviceVerificationLoginCommand === "function" ? deviceVerificationLoginCommand : null
exports.loginPromptRanOutOfInput = typeof loginPromptRanOutOfInput === "function" ? loginPromptRanOutOfInput : null
exports.sanitizeInteractiveStderr = typeof sanitizeInteractiveStderr === "function" ? sanitizeInteractiveStderr : null
exports.deviceCodeEnvVar = typeof deviceCodeEnvVar === "function" ? deviceCodeEnvVar : null
exports.secondFactorWindowOpen = typeof secondFactorWindowOpen === "function" ? secondFactorWindowOpen : null
exports.loginDiagnostic = typeof loginDiagnostic === "function" ? loginDiagnostic : null
exports.loginHasNoUsableProvider = typeof loginHasNoUsableProvider === "function" ? loginHasNoUsableProvider : null
exports.twoFactorMethods = typeof twoFactorMethods === "function" ? twoFactorMethods : null
exports.isTwoFactorMethod = typeof isTwoFactorMethod === "function" ? isTwoFactorMethod : null
exports.twoFactorMethodLabel = typeof twoFactorMethodLabel === "function" ? twoFactorMethodLabel : null
exports.rememberedTwoFactorMethodFor = typeof rememberedTwoFactorMethodFor === "function" ? rememberedTwoFactorMethodFor : null
exports.rememberTwoFactorMethodIn = typeof rememberTwoFactorMethodIn === "function" ? rememberTwoFactorMethodIn : null
exports.forgetTwoFactorMethodIn = typeof forgetTwoFactorMethodIn === "function" ? forgetTwoFactorMethodIn : null
exports.settingWriteCommand = typeof settingWriteCommand === "function" ? settingWriteCommand : null
exports.noInteractionEnvVar = noInteractionEnvVar
exports.sessionEnvVar = sessionEnvVar
exports.extractSessionToken = extractSessionToken
exports.isSessionToken = isSessionToken
exports.keyringClearAllCommand = keyringClearAllCommand
exports.keyringStoreMasterPasswordCommand = keyringStoreMasterPasswordCommand
exports.keyringLookupMasterPasswordCommand = keyringLookupMasterPasswordCommand
exports.pinStoreCommand = pinStoreCommand
exports.keyringSecretEnvVar = keyringSecretEnvVar
`)(Model)
let pass = 0
const failures = []
const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`)
// Distinctive enough that a substring search cannot miss them.
const MASTER = "correct-horse-battery-staple"
const CLIENT_ID = "user.11111111-2222-3333-4444-555555555555"
const CLIENT_SECRET = "sEcReTcLiEnTsTrInG"
const CODE = "249213"
const SERVER = "https://vault.example.com"
// Fixes #6. Cloud regions are distinct Bitwarden environments, while a
// self-hosted installation still needs to retain the free-form server path.
check("the login server selector maps US, EU and custom without conflating them",
Model.loginServerUrlFor
&& Model.loginServerUrlFor("us", SERVER) === "https://vault.bitwarden.com"
&& Model.loginServerUrlFor("eu", SERVER) === "https://vault.bitwarden.eu"
&& Model.loginServerUrlFor("custom", ` ${SERVER} `) === SERVER,
"US and EU must use their official vault URLs, and custom the entered URL")
check("an invalid login server selection falls back to the safe US default",
Model.loginServerUrlFor
&& Model.loginServerUrlFor("", SERVER) === "https://vault.bitwarden.com"
&& Model.loginServerUrlFor("other", SERVER) === "https://vault.bitwarden.com",
"unknown region values must not turn a stale custom URL into a destination")
check("only explicit Bitwarden second-factor challenges reveal the follow-up prompt",
Model.loginNeedsSecondFactor
&& Model.loginNeedsSecondFactor("", "Two factor required.")
&& Model.loginNeedsSecondFactor("", "Two-step token is invalid. Try again.")
&& Model.loginNeedsSecondFactor("", "Verification code required")
&& !Model.loginNeedsSecondFactor("", "Response status code does not indicate success: 401")
&& !Model.loginNeedsSecondFactor("", "invalid_grant"),
"generic status codes or invalid_grant must not be treated as MFA")
check("Bitwarden CLI 2026.2.0's standalone required-code error reveals the follow-up prompt",
Model.loginNeedsSecondFactor && Model.loginNeedsSecondFactor("", "Code is required."),
"Code is required. must be treated as a login verification challenge")
// Fixes #4. bw says "Code is required." to two different challenges. One of
// them, new-device verification, has no --code flag and no non-interactive
// answer at all, so treating it as a rejected two-step code asks the user for
// the same code forever. The attempt that already carried one is what tells
// them apart.
check("a required-code challenge answering an attempt that carried a code is device verification",
Model.loginNeedsDeviceVerification
&& Model.loginNeedsDeviceVerification("", "Code is required.", true),
"a second Code is required. after --code cannot be a rejected two-step code")
check("the same message on the first attempt is still an ordinary second-factor prompt",
Model.loginNeedsDeviceVerification
&& !Model.loginNeedsDeviceVerification("", "Code is required.", false)
&& Model.loginNeedsSecondFactor("", "Code is required."),
"the two challenges are indistinguishable until a code has been sent")
check("a genuinely rejected two-step code is not mistaken for device verification",
Model.loginNeedsDeviceVerification
&& !Model.loginNeedsDeviceVerification("", "Two-step token is invalid. Try again.", true)
&& !Model.loginNeedsDeviceVerification("", "Login failed. No provider selected.", true)
&& !Model.loginNeedsDeviceVerification("", "Username or password is incorrect. Try again.", true),
"only the bare required-code sentence means the code was never read")
// --- the two-step method question -------------------------------------------
//
// bw picks the provider itself when an account has exactly one, and asks when
// it has more. It asks by failing, because the menu it would otherwise draw
// needs a terminal. Answering by guessing is what produces a failed login, so
// the panel treats the message as the question it is.
check("bw's provider question is recognised as a question, not a credential failure",
Model.loginNeedsMethodChoice
&& Model.loginNeedsMethodChoice("", "Login failed. No provider selected.")
&& !Model.loginNeedsMethodChoice("", "Code is required.")
&& !Model.loginNeedsMethodChoice("", "Username or password is incorrect. Try again."),
"only No provider selected. means bw wants --method")
check("an account whose methods this client cannot perform is a separate dead end",
Model.loginHasNoUsableProvider
&& Model.loginHasNoUsableProvider("", "Login failed. No providers available for this client.")
&& !Model.loginHasNoUsableProvider("", "Login failed. No provider selected.")
&& !Model.loginNeedsMethodChoice("", "Login failed. No providers available for this client."),
"the two provider messages must not be confused for one another")
// getSupportedProviders() gates Duo and Organization Duo behind supportsDuo()
// and WebAuthn behind supportsWebAuthn(), both of which the CLI's platform
// layer hardcodes to false. So these three are not a shortlist -- they are
// every provider bw can act on, which is what makes a fixed picker complete.
check("the method picker offers every provider bw can use and nothing it cannot",
Model.twoFactorMethods
&& Model.twoFactorMethods().map((m) => m.method).join(",") === "0,3,1"
&& Model.twoFactorMethods().every((m) => m.label && m.hint),
JSON.stringify(Model.twoFactorMethods && Model.twoFactorMethods()))
check("the picker's table is copied, so a caller cannot edit the set of methods",
(() => {
const first = Model.twoFactorMethods()
first[0].method = 99
return Model.twoFactorMethods()[0].method === 0
})(),
"twoFactorMethods() must not hand out its own entries")
check("only a listed method may reach the command line",
Model.isTwoFactorMethod(0) && Model.isTwoFactorMethod(1) && Model.isTwoFactorMethod(3)
&& !Model.isTwoFactorMethod(2) && !Model.isTwoFactorMethod(7)
&& !Model.isTwoFactorMethod(-1) && !Model.isTwoFactorMethod("0")
&& !Model.isTwoFactorMethod(null) && !Model.isTwoFactorMethod(undefined),
"membership of the table, not shape, is the test")
// shell.json is not validated by whatever writes it, and this value goes
// straight back into an argv.
const R = Model.rememberedTwoFactorMethodFor
const STORE = { "a@example.com": 0, "b@example.com": 1 }
check("a remembered method is validated on the way back out of shell.json",
R && R(STORE, "a@example.com") === 0
&& R({ "a@example.com": "3" }, "a@example.com") === 3
&& R({ "a@example.com": 2 }, "a@example.com") === -1
&& R({ "a@example.com": 9 }, "a@example.com") === -1
&& R({ "a@example.com": "; rm -rf /" }, "a@example.com") === -1
&& R({ "a@example.com": null }, "a@example.com") === -1
// Number() reads all of these as 0, which is Authenticator. An unset entry
// must not come back as a confident answer.
&& R({ "a@example.com": "" }, "a@example.com") === -1
&& R({ "a@example.com": false }, "a@example.com") === -1
&& R({ "a@example.com": true }, "a@example.com") === -1
&& R({ "a@example.com": [1] }, "a@example.com") === -1
&& R({ "a@example.com": {} }, "a@example.com") === -1,
"anything not in the table reads as not remembered")
// The bug this replaced: one method for the whole machine, so a second vault
// was sent the first vault's method and had to be talked out of it.
check("each account keeps its own method",
R(STORE, "a@example.com") === 0 && R(STORE, "b@example.com") === 1
&& R(STORE, "c@example.com") === -1,
JSON.stringify(STORE))
check("the login address is matched the way Bitwarden treats it",
R(STORE, " A@Example.COM ") === 0
&& Model.rememberTwoFactorMethodIn({}, " A@Example.COM ", 1)["a@example.com"] === 1,
"case and whitespace must not make an account remember itself twice")
check("no account, no memory",
R(STORE, "") === -1 && R(STORE, null) === -1 && R(null, "a@example.com") === -1
&& R("not an object", "a@example.com") === -1,
"an absent or unreadable store is not an answer")
// Rebuilt rather than mutated, so a hand-edit cannot survive into what is
// written back.
const grown = Model.rememberTwoFactorMethodIn(
{ "a@example.com": 0, "junk@example.com": 99, "b@example.com": 1 }, "c@example.com", 3)
check("remembering one account leaves the others alone and drops what it cannot read",
grown["a@example.com"] === 0 && grown["b@example.com"] === 1
&& grown["c@example.com"] === 3 && !("junk@example.com" in grown),
JSON.stringify(grown))
check("an existing account is updated rather than duplicated",
(() => {
const out = Model.rememberTwoFactorMethodIn(STORE, "a@example.com", 1)
return out["a@example.com"] === 1 && Object.keys(out).length === 2
})(),
"re-answering must replace, not append")
check("the store is bounded, because a config file is not a history",
(() => {
let store = {}
for (let i = 0; i < 30; i++) {
store = Model.rememberTwoFactorMethodIn(store, `u${i}@example.com`, 0)
}
return Object.keys(store).length <= 10 && store["u29@example.com"] === 0
})(),
"the newest answer must always survive the cap")
check("forgetting one account forgets only that one",
(() => {
const out = Model.forgetTwoFactorMethodIn(STORE, "a@example.com")
return !("a@example.com" in out) && out["b@example.com"] === 1
})(),
"a stale method for one vault must not clear another's")
// `flat` is declared further down; this block runs before it.
const jsonWrite = Model.settingWriteCommand("twoFactorMethods", { "a@example.com": 1 }, "json").join(" ")
check("a per-account map reaches shell.json as JSON, not as a number",
jsonWrite.includes('{"a@example.com":1}') && jsonWrite.includes("--json"), jsonWrite)
check("an integer setting is still written as an integer",
Model.settingWriteCommand("autoLockMinutes", 15, "int").join(" ").includes("'15'"),
Model.settingWriteCommand("autoLockMinutes", 15, "int").join(" "))
// Everything a builder could conceivably interpolate, flattened to one string.
const flat = (cmd) => cmd.join(" ")
// --- no credential may reach any argv ---------------------------------------
const unlock = Model.unlockPrewarmCommand()
check("unlock takes no password argument at all",
Model.unlockPrewarmCommand.length === 0, `arity ${Model.unlockPrewarmCommand.length}`)
check("unlock reads the submitted password from its private FIFO",
flat(unlock).includes("--passwordfile") && !flat(unlock).includes("--passwordenv"), flat(unlock))
check("unlock caps output and diagnostic stderr on the producer side",
flat(unlock).includes("head -c") && flat(unlock).includes("exec 2>"), flat(unlock))
// The builders are called the way Panel.qml calls them: with what shapes the
// command, never with the secret itself.
const emailPlain = Model.emailLoginPrewarmCommand("john@example.com", false, "")
const emailFull = Model.emailLoginPrewarmCommand("john@example.com", true, SERVER)
const apiKey = Model.apiKeyLoginCommand("")
const apiKeyServer = Model.apiKeyLoginCommand(SERVER)
const everyCommand = [
["unlock", unlock],
["email login", emailPlain],
["email login with a 2FA code and a custom server", emailFull],
["api key login", apiKey],
["api key login with a custom server", apiKeyServer]
]
for (const [label, cmd] of everyCommand) {
const text = flat(cmd)
check(`${label} carries no master password in argv`, !text.includes(MASTER), text)
check(`${label} carries no client secret in argv`, !text.includes(CLIENT_SECRET), text)
check(`${label} carries no client id in argv`, !text.includes(CLIENT_ID), text)
// An inline `VAR=value bw ...` prefix is exactly how the secrets used to
// leak: the assignment lands in the wrapping shell's own command line.
check(`${label} assigns no credential inline in the script`,
!/\b(BW_PASSWORD|BW_CLIENTID|BW_CLIENTSECRET)=/.test(text), text)
}
// The builders cannot leak what they are never given, so also assert they no
// longer accept a secret -- a caller passing one would be silently ignored.
check("emailLoginPrewarmCommand takes (email, hasCode, serverUrl, method), not a password",
Model.emailLoginPrewarmCommand.length === 4, `arity ${Model.emailLoginPrewarmCommand.length}`)
check("apiKeyLoginCommand takes only a server URL",
Model.apiKeyLoginCommand.length === 1, `arity ${Model.apiKeyLoginCommand.length}`)
// A stray password argument must not find its way into the command anyway.
const emailWithStrayArgs = Model.emailLoginPrewarmCommand("john@example.com", MASTER, SERVER)
check("a password passed where hasCode belongs is never interpolated",
!flat(emailWithStrayArgs).includes(MASTER), flat(emailWithStrayArgs))
// --- --method, the one argument that is a bare integer ----------------------
//
// bw wants a number here, so it is neither quoted nor carried in the
// environment like everything else. What keeps that safe is that the only
// values which reach it are the ones already in the table.
const emailMethod = Model.emailLoginPrewarmCommand("john@example.com", true, "", 0)
const emailNoMethod = Model.emailLoginPrewarmCommand("john@example.com", true, "", -1)
check("a chosen method is passed to bw as a bare integer",
/ --method 0 /.test(flat(emailMethod) + " "), flat(emailMethod))
check("the method is sent before the code, as bw's own option order has it",
flat(emailMethod).indexOf("--method") < flat(emailMethod).indexOf("--code"),
flat(emailMethod))
check("no method at all is sent when none was chosen, so bw picks for itself",
!flat(emailNoMethod).includes("--method")
&& !flat(Model.emailLoginPrewarmCommand("john@example.com", true, "")).includes("--method"),
flat(emailNoMethod))
check("a method outside the table never reaches the command line",
[2, 7, 99, -5, "0", "0; rm -rf /", null, undefined, {}, [0]].every(
(m) => !flat(Model.emailLoginPrewarmCommand("john@example.com", true, "", m)).includes("--method")),
"only table members may be interpolated")
check("the email login stage that carries a method carries no code with it",
!flat(Model.emailLoginPrewarmCommand("john@example.com", false, "", 1)).includes("--code")
&& flat(Model.emailLoginPrewarmCommand("john@example.com", false, "", 1)).includes("--method 1"),
"choosing Email must be able to ask bw to send the mail")
// --- the one login that runs with bw's prompts enabled ----------------------
//
// New-device verification is the only challenge bw accepts from no flag: the
// token comes from an inquirer prompt on stdin. So this command answers it on
// stdin, and everything below is about that being safe rather than merely
// working.
const deviceCmd = Model.deviceVerificationLoginCommand("john@example.com", "", 0)
const deviceFlat = flat(deviceCmd)
check("the device code is piped to bw's stdin, read from the environment",
Model.deviceCodeEnvVar() === "QSBW_DEVICE_CODE"
&& deviceFlat.includes('printf \'%s\\n\' "$' + Model.deviceCodeEnvVar() + '" |'),
deviceFlat)
check("the device code reaches no argv at all, not even bw's",
!deviceFlat.includes("--code") && !/--\w+ \d{6}/.test(deviceFlat), deviceFlat)
check("the master password still travels by FIFO on this path too",
deviceFlat.includes('--passwordfile "$__auth_fifo"')
&& !deviceFlat.includes("--passwordenv"), deviceFlat)
check("the interactive login is bounded, so a prompt that never comes cannot hold it",
/timeout \d+s bw login/.test(deviceFlat), deviceFlat)
check("a chosen two-step method still travels with it",
deviceFlat.includes("--method 0")
&& !flat(Model.deviceVerificationLoginCommand("john@example.com", "", -1)).includes("--method"),
deviceFlat)
check("this command does not disable interaction, which is the whole point",
!deviceFlat.includes("BW_NOINTERACTION"), deviceFlat)
// The flag being dropped was there so bw fails fast instead of blocking on a
// prompt nobody can see. A pipe keeps that: inquirer 8.2.6 throws
// ERR_USE_AFTER_CLOSE at EOF rather than waiting, which is what the fallback
// to a terminal login keys off.
check("an unexpected prompt is recognised as a reason to fall back, not an error to show",
Model.loginPromptRanOutOfInput
&& Model.loginPromptRanOutOfInput("", "Error [ERR_USE_AFTER_CLOSE]: readline was closed")
&& !Model.loginPromptRanOutOfInput("", "Username or password is incorrect. Try again."),
"inquirer's own failure is a fallback signal")
// An interactive bw echoes every keystroke back to stderr with the cursor
// movement to match, so the captured stream holds the code itself.
const noisy = "\u001b[2K\u001b[G? Enter OTP sent to login email: 913744\u001b[39D"
+ "\u001b[39C\r\nInvalid verification code. Try again.\u001b[?25h"
// Deliberately not on a prompt line: the prompt rule must not be what removes
// it, or redaction is untested.
const echoedElsewhere = "Verification failed for code 913744.\r\nTry again."
check("the echoed device code never survives into a message shown to the user",
Model.sanitizeInteractiveStderr
&& !Model.sanitizeInteractiveStderr(noisy, "913744").includes("913744")
&& !Model.sanitizeInteractiveStderr(echoedElsewhere, "913744").includes("913744")
&& Model.sanitizeInteractiveStderr(echoedElsewhere, "913744") === "Try again.",
JSON.stringify(Model.sanitizeInteractiveStderr && Model.sanitizeInteractiveStderr(echoedElsewhere, "913744")))
check("what bw actually had to say survives the sanitiser",
Model.sanitizeInteractiveStderr(noisy, "913744") === "Invalid verification code. Try again.",
JSON.stringify(Model.sanitizeInteractiveStderr(noisy, "913744")))
check("the sanitiser drops escape sequences and inquirer's own prompt lines",
!/\u001b/.test(Model.sanitizeInteractiveStderr(noisy, "913744"))
&& !Model.sanitizeInteractiveStderr(noisy, "913744").includes("Enter OTP"),
JSON.stringify(Model.sanitizeInteractiveStderr(noisy, "913744")))
check("a stack trace cannot flood the panel's error banner",
Model.sanitizeInteractiveStderr("x".repeat(9000), "").length <= 300,
String(Model.sanitizeInteractiveStderr("x".repeat(9000), "").length))
// --- the env vars the commands rely on --------------------------------------
check("the password env var is BW_PASSWORD, which bw reads via --passwordenv",
Model.passwordEnvVar() === "BW_PASSWORD", Model.passwordEnvVar())
check("the API key env vars are the ones bw reads natively",
Model.clientIdEnvVar() === "BW_CLIENTID" && Model.clientSecretEnvVar() === "BW_CLIENTSECRET",
Model.clientIdEnvVar() + " / " + Model.clientSecretEnvVar())
check("interaction is disabled through the environment, not the command line",
Model.noInteractionEnvVar() === "BW_NOINTERACTION"
&& !everyCommand.some(([, c]) => flat(c).includes("BW_NOINTERACTION=")),
Model.noInteractionEnvVar())
// --- the two-step code, the one exception, is still kept out of the shell ----
// bw has no environment option for --code, so the value reaches bw's argv. It
// must at least be expanded by the shell from the environment rather than
// written into the script, which outlives the login process.
check("a 2FA code is expanded from the environment, never inlined",
flat(emailFull).includes('--code "$' + Model.twoFactorCodeEnvVar() + '"')
&& !flat(emailFull).includes(CODE), flat(emailFull))
check("no --code flag at all when no code was entered",
!flat(emailPlain).includes("--code"), flat(emailPlain))
// --- the rest of the command shape still has to be right --------------------
check("email login passes the email address, which is not a secret",
flat(emailPlain).includes("bw login 'john@example.com'"), flat(emailPlain))
check("a custom server is configured before logging in",
emailFull[2].includes("bw config server '" + SERVER + "'")
&& emailFull[2].includes("&& bw login"), flat(emailFull))
check("no server config step when the default server is used",
!flat(emailPlain).includes("bw config server"), flat(emailPlain))
check("api key login authenticates and then unlocks, since --apikey does not unlock",
flat(apiKey).includes("bw login --apikey")
&& flat(apiKey).includes("bw unlock --passwordenv " + Model.passwordEnvVar()), flat(apiKey))
check("api key login honours a custom server too",
flat(apiKeyServer).includes("bw config server '" + SERVER + "'"), flat(apiKeyServer))
// Single quotes in a server URL or email must not break out of the script.
const injected = Model.emailLoginPrewarmCommand("a'; touch /tmp/pwned; '@b.c", false,
"https://x'; touch /tmp/pwned; '.com")
check("shell metacharacters in the email and server URL stay quoted",
!flat(injected).includes("; touch /tmp/pwned; ")
|| flat(injected).includes("'\\''"), flat(injected))
// --- what counts as a session key -------------------------------------------
// The handoff file and bw's own stdout both feed extractSessionToken, and
// whatever it returns is written to the keyring and treated as an unlocked
// vault. Anything not shaped like a key must come back empty instead.
const REAL_KEY = "Zm9vYmFyYmF6cXV1eDEyMzQ1Njc4OTBhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODk9PQ=="
check("a raw key is returned as-is",
Model.extractSessionToken(REAL_KEY) === REAL_KEY, Model.extractSessionToken(REAL_KEY))
check("an export line is unwrapped",
Model.extractSessionToken('export BW_SESSION="' + REAL_KEY + '"') === REAL_KEY,
Model.extractSessionToken('export BW_SESSION="' + REAL_KEY + '"'))
check("a key sharing the stream with other output is still found",
Model.extractSessionToken("Your vault is now unlocked!\n\n" + REAL_KEY) === REAL_KEY,
Model.extractSessionToken("Your vault is now unlocked!\n\n" + REAL_KEY))
// Each of these used to be returned verbatim and stored as a session.
const notKeys = [
["an error message", "You are not logged in."],
["a single word of prose", "Failed"],
["an empty file", ""],
["whitespace", " \n "],
["a short string of key-ish characters", "abc123=="],
["a path someone left in the handoff file", "/home/user/notes.txt"],
["a sentence with no spaces but wrong characters", "unlock.failed:invalid.master.password!"]
]
for (const [label, input] of notKeys) {
check(`${label} is not treated as a session key`,
Model.extractSessionToken(input) === "", JSON.stringify(Model.extractSessionToken(input)))
}
check("a BW_SESSION line carrying junk is rejected rather than unwrapped",
Model.extractSessionToken('BW_SESSION="not a key"') === "",
Model.extractSessionToken('BW_SESSION="not a key"'))
// --- logging out has to take the keyring with it ----------------------------
//
// Two of the three entries this plugin writes are the master password: once in
// the clear for fingerprint unlock, once encrypted under a short PIN. Both go
// to the default collection, which is a file on disk that PAM unlocks at every
// login, so both survive a reboot on purpose. Logging out used to leave the
// PIN blob there forever, and to clear the fingerprint copy only when the
// panel's own `fingerprintStored` flag happened to be true -- a flag that goes
// false when a reader is unplugged, when fprintd is uninstalled, and for the
// first moments of every shell start. Run against a stand-in secret-tool so
// what is checked is that the entries are gone, not that a string looks right.
const keyringStub = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-logout-"))
fs.writeFileSync(path.join(keyringStub, "secret-tool"), `#!/usr/bin/env bash
set -uo pipefail
cmd="\${1:-}"; shift || true
account=""
unlock=false
while [ $# -gt 0 ]; do
case "$1" in
account) account="\${2:-}"; shift 2 ;;
--unlock) unlock=true; shift ;;
*) shift ;;
esac
done
f="$STUB/entry-$account"
unlocked="$STUB/unlocked-$account"
case "$cmd" in
store) rm -f -- "$unlocked"; cat > "$f"; exit 0 ;;
lookup) [ -s "$f" ] || exit 1; cat "$f"; printf '\n'; exit 0 ;;
search)
if [ "\${FAIL_SEARCH_ACCOUNT:-}" = "$account" ] \
|| { [ "\${FAIL_SEARCH_WHEN_MISSING_ACCOUNT:-}" = "$account" ] && [ ! -e "$f" ]; }; then
printf '%s\n' 'keyring search unavailable' >&2
exit 3
fi
[ -e "$f" ] || exit 0
if $unlock && [ "\${LOCK_ACCOUNT:-}" = "$account" ] \
&& [ "\${DENY_UNLOCK_ACCOUNT:-}" != "$account" ]; then
: > "$unlocked"
fi
printf '[stub-item]\nlabel = stub\n'
if [ "\${LOCK_ACCOUNT:-}" != "$account" ] || [ -e "$unlocked" ]; then
printf 'secret = '; cat "$f"; printf '\n'
fi
exit 0
;;
clear)
if [ "\${FAIL_CLEAR_ACCOUNT:-}" = "$account" ]; then
printf '%s\n' 'keyring service unavailable' >&2
exit 2
fi
[ -e "$f" ] || exit 1
[ "\${LOCK_ACCOUNT:-}" != "$account" ] || [ -e "$unlocked" ] || exit 1
rm -f -- "$f" "$unlocked"
exit 0
;;
esac
exit 1
`)
fs.chmodSync(path.join(keyringStub, "secret-tool"), 0o755)
const keyringRun = (command, extraEnv) => execFileSync(command[0], command.slice(1), {
env: Object.assign({}, process.env,
{ PATH: `${keyringStub}:${process.env.PATH}`, STUB: keyringStub }, extraEnv || {}),
encoding: "utf8"
})
const keyringEntries = () => fs.readdirSync(keyringStub)
.filter(f => f.startsWith("entry-")).map(f => f.slice("entry-".length)).sort()
// secret-tool terminates lookup output with a newline. The lookup wrapper must
// remove that transport delimiter without trimming spaces that are actually
// part of the master password.
const SPACED_MASTER = " exact master password "
keyringRun(Model.keyringStoreMasterPasswordCommand(), {
[Model.keyringSecretEnvVar()]: SPACED_MASTER
})
check("fingerprint keyring lookup preserves leading and trailing password spaces",
keyringRun(Model.keyringLookupMasterPasswordCommand()) === SPACED_MASTER,
JSON.stringify(keyringRun(Model.keyringLookupMasterPasswordCommand())))
// The three accounts as the panel actually writes them, rather than a list
// copied into the test: a fourth secret added later must not slip past this.
keyringRun(["bash", "-c", "printf '%s' \"$QSBW_SECRET\" | secret-tool store --label=x"
+ " service 'qs-bitwarden-cli' account 'session'"], { QSBW_SECRET: "boot-id " + REAL_KEY })
keyringRun(Model.keyringStoreMasterPasswordCommand(), { [Model.keyringSecretEnvVar()]: MASTER })
keyringRun(Model.pinStoreCommand(), { [Model.keyringSecretEnvVar()]: MASTER, QSBW_PIN: "123456" })
check("the fixture leaves all three secrets in the keyring",
keyringEntries().join(",") === "master_password,pin_blob,session", keyringEntries().join(","))
keyringRun(Model.keyringClearAllCommand())
check("logging out clears every secret the plugin ever stored",
keyringEntries().length === 0, keyringEntries().join(","))
// Nothing stored is the ordinary case -- the user never enabled either
// feature -- and it must not read as a failure the panel then reports.
check("clearing an empty keyring succeeds",
keyringRun(Model.keyringClearAllCommand()) === "", "expected silence and exit 0")
check("no secret reaches the clear command's argv",
!Model.keyringClearAllCommand().join(" ").includes(MASTER),
Model.keyringClearAllCommand().join(" "))
keyringRun(["bash", "-c", "printf '%s' \"$QSBW_SECRET\" | secret-tool store --label=x"
+ " service 'qs-bitwarden-cli' account 'session'"], { QSBW_SECRET: "stale session" })
const failedClear = spawnSync(Model.keyringClearAllCommand()[0], Model.keyringClearAllCommand().slice(1), {
env: Object.assign({}, process.env,
{ PATH: `${keyringStub}:${process.env.PATH}`, STUB: keyringStub, FAIL_CLEAR_ACCOUNT: "session" }),
encoding: "utf8"
})
check("a real keyring deletion failure propagates out of the clear-all command",
failedClear.status !== 0 && keyringEntries().includes("session"),
`status ${failedClear.status}; entries ${keyringEntries().join(",")}`)
keyringRun(Model.keyringClearAllCommand())
keyringRun(["bash", "-c", "printf '%s' \"$QSBW_SECRET\" | secret-tool store --label=x"
+ " service 'qs-bitwarden-cli' account 'session'"], { QSBW_SECRET: "locked session" })
const lockedClear = spawnSync(Model.keyringClearAllCommand()[0], Model.keyringClearAllCommand().slice(1), {
env: Object.assign({}, process.env,
{ PATH: `${keyringStub}:${process.env.PATH}`, STUB: keyringStub,
LOCK_ACCOUNT: "session", DENY_UNLOCK_ACCOUNT: "session" }),
encoding: "utf8"
})
check("a matching credential in a locked collection cannot be mistaken for absence",
lockedClear.status !== 0 && keyringEntries().includes("session"),
`status ${lockedClear.status}; entries ${keyringEntries().join(",")}`)
keyringRun(Model.keyringClearAllCommand())
keyringRun(["bash", "-c", "printf '%s' \"$QSBW_SECRET\" | secret-tool store --label=x"
+ " service 'qs-bitwarden-cli' account 'session'"], { QSBW_SECRET: "unlockable session" })
keyringRun(Model.keyringClearAllCommand(), { LOCK_ACCOUNT: "session" })
check("clear-all unlocks and removes a matching credential from a locked collection",
!keyringEntries().includes("session"), keyringEntries().join(","))
const failedSearch = spawnSync(Model.keyringClearAllCommand()[0], Model.keyringClearAllCommand().slice(1), {
env: Object.assign({}, process.env,
{ PATH: `${keyringStub}:${process.env.PATH}`, STUB: keyringStub, FAIL_SEARCH_ACCOUNT: "session" }),
encoding: "utf8"
})
check("a pre-clear keyring search failure blocks logout cleanup",
failedSearch.status !== 0, `status ${failedSearch.status}`)
keyringRun(["bash", "-c", "printf '%s' \"$QSBW_SECRET\" | secret-tool store --label=x"
+ " service 'qs-bitwarden-cli' account 'session'"], { QSBW_SECRET: "post-search session" })
const failedPostSearch = spawnSync(Model.keyringClearAllCommand()[0], Model.keyringClearAllCommand().slice(1), {
env: Object.assign({}, process.env,
{ PATH: `${keyringStub}:${process.env.PATH}`, STUB: keyringStub,
FAIL_SEARCH_WHEN_MISSING_ACCOUNT: "session" }),
encoding: "utf8"
})
check("a post-clear verification failure cannot be reported as successful cleanup",
failedPostSearch.status !== 0 && !keyringEntries().includes("session"),
`status ${failedPostSearch.status}; entries ${keyringEntries().join(",")}`)
fs.rmSync(keyringStub, { recursive: true, force: true })
// The command is only half of it: the panel has to run it, and run it without
// first asking a flag for permission. Both gates below were the bug.
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 ""
}
const logout = bodyOf("logoutAccount")
const forget = bodyOf("forgetStoredCredentials")
const credentialStores = bodyOf("credentialStoresRunning")
const allCredentialClear = bodyOf("requestAllCredentialClear")
const loginEnv = bodyOf("loginProcessEnv")
const unlockSuccess = bodyOf("onUnlockSuccess")
const pinResult = bodyOf("onPinUnlockResult")
const fingerprintResult = bodyOf("onFingerprintPasswordRetrieved")
const submitLogin = bodyOf("submitLogin")
const loginOutput = bodyOf("onLoginOutput")
const abandonAuth = bodyOf("abandonAuthSecrets")
const resetSecondFactor = bodyOf("resetEmailLoginSecondFactor")
const prepareEmailLogin = bodyOf("prepareEmailLogin")
const chooseMethod = bodyOf("chooseTwoFactorMethod")
const loginSignature = bodyOf("emailLoginSignature")
const rememberMethod = bodyOf("rememberTwoFactorMethod")
const writerExited = bodyOf("onAuthPasswordWriterExited")
const resumeDeferred = bodyOf("resumeDeferredLogin")
const clearCollector = bodyOf("clearProcessCollectorSoon")
const loginExited = panelSrc.slice(panelSrc.indexOf("id: loginProc"),
panelSrc.indexOf("id: authPasswordWriterProc"))
const focusField = bodyOf("focusAppropriateField")
const statusFinished = bodyOf("onStatusFinished")
const pendingSecondFactor = bodyOf("pendingSecondFactorLogin")
const suspendPending = bodyOf("suspendPendingLogin")
const panelOpened = bodyOf("onPanelOpened")
const syncFields = bodyOf("syncLoginFieldsToState")
const submitDevice = bodyOf("submitDeviceVerification")
const startDevice = bodyOf("startDeviceVerificationLogin")
const loginFieldFocus = bodyOf("loginFieldHasFocus")
const resolvedLoginServer = bodyOf("resolvedLoginServerUrl")
const terminalLoginUi = panelSrc.slice(panelSrc.indexOf("// METHOD B: API Key"),
panelSrc.indexOf("// SCREEN 2: LOCKED VIEW"))
const emailLoginUi = panelSrc.slice(panelSrc.indexOf("// METHOD A: Email & Password"),
panelSrc.indexOf("// METHOD B: API Key"))
check("the login screen offers US, EU and Custom server choices to both login methods",
/text:\s*"US"[\s\S]{0,500}text:\s*"EU"[\s\S]{0,500}text:\s*"Custom"/.test(panelSrc)
&& panelSrc.indexOf('text: "US"') < panelSrc.indexOf("// METHOD A: Email & Password"),
"the shared region selector must appear before the method-specific forms")
check("the custom URL field appears only for the Custom server choice",
/id:\s*serverUrlField[\s\S]{0,160}visible:\s*root\.loginServerRegion\s*===\s*"custom"/.test(panelSrc),
"selecting US or EU must not expose a misleading self-hosted URL field")
check("all login paths resolve their server choice through the same mapping",
/Model\.loginServerUrlFor\(loginServerRegion,\s*loginServerUrl\)/.test(resolvedLoginServer)
&& (panelSrc.match(/resolvedLoginServerUrl\(\)/g) || []).length >= 4,
resolvedLoginServer || "resolvedLoginServerUrl() is missing")
// Email/password login is deliberately two-stage. Asking every user for a
// second factor up front makes an optional challenge look mandatory and
// collects a code before Bitwarden has said it needs one.
check("the email login initially hides the second-factor prompt",
/Column\s*\{\s*visible:\s*root\.show2faField[\s\S]{0,420}TWO-STEP VERIFICATION CODE/.test(emailLoginUi),
emailLoginUi)
check("each login stage replaces the controls before it instead of overflowing below them",
(emailLoginUi.match(/visible:\s*root\.loginCredentialsStage/g) || []).length >= 2
&& /visible:\s*root\.loginMethod\s*!==\s*"email"\s*\|\|\s*root\.loginCredentialsStage/.test(panelSrc)
&& /loginCredentialsStage:\s*!show2faField\s*&&\s*!show2faMethodPicker/.test(panelSrc)
&& /visible:\s*root\.show2faMethodPicker/.test(emailLoginUi)
&& /visible:\s*root\.show2faField/.test(emailLoginUi),
emailLoginUi)
check("only deliberate credential edits can return MFA login to the first stage",
/id:\s*emailField[\s\S]{0,700}onTextEdited:[\s\S]{0,300}resetEmailLoginSecondFactor\(\)/.test(emailLoginUi)
&& /id:\s*loginPassField[\s\S]{0,900}onTextEdited:\s*\{[\s\S]{0,180}if\s*\(root\.show2faField\)[\s\S]{0,180}resetEmailLoginSecondFactor\(\)/.test(emailLoginUi)
&& /id:\s*loginPassField[\s\S]{0,500}onTextChanged:\s*root\.loginPassword\s*=\s*text/.test(emailLoginUi),
emailLoginUi)
check("Enter on the password submits the first stage, then advances to the revealed code field",
/id:\s*loginPassField[\s\S]{0,1200}onAccepted:\s*root\.show2faField\s*\?\s*code2faField\.forceActiveFocus\(\)\s*:\s*root\.submitLogin\(\)/.test(emailLoginUi),
emailLoginUi)
check("the second stage cannot resubmit without a verification code",
/show2faField[\s\S]{0,180}login2faCode[\s\S]{0,220}code2faField\.forceActiveFocus\(\)[\s\S]{0,80}return/.test(submitLogin),
submitLogin)
check("a Bitwarden second-factor challenge reveals and focuses the code field",
/show2faField\s*=\s*true/.test(loginOutput)
&& /code2faField\.forceActiveFocus\(\)/.test(loginOutput),
loginOutput)
check("restarting email login clears both the second-factor stage and its code",
/show2faField\s*=\s*false/.test(resetSecondFactor)
&& /login2faCode\s*=\s*""/.test(resetSecondFactor)
&& /loginDeviceVerification\s*=\s*false/.test(resetSecondFactor),
resetSecondFactor)
// Fixes #4. The device-verification branch has to be reached first: the
// second-factor test below it matches the same message, so testing in the
// other order would re-prompt for a code bw is never going to read.
check("device verification is decided before the second-factor prompt is raised",
/loginNeedsDeviceVerification/.test(loginOutput)
&& loginOutput.indexOf("loginNeedsDeviceVerification")
< loginOutput.indexOf("loginNeedsSecondFactor"),
loginOutput)
check("the device-verification branch stops asking for a code and does not focus the field",
/loginNeedsDeviceVerification[\s\S]{0,400}resetEmailLoginSecondFactor\(\)[\s\S]{0,120}loginDeviceVerification\s*=\s*true[\s\S]{0,600}return/.test(loginOutput)
&& loginOutput.indexOf("code2faField.forceActiveFocus")
> loginOutput.indexOf("loginNeedsSecondFactor"),
loginOutput)
check("every email login attempt records whether it carried a code",
(submitLogin.match(/loginAttemptHadCode\s*=/g) || []).length >= 2
&& /loginAttemptHadCode\s*=\s*String\(login2faCode[\s\S]{0,200}emailLoginPrewarmCommand\(\s*\n?\s*email,\s*loginAttemptHadCode/.test(prepareEmailLogin),
prepareEmailLogin + "\n---\n" + submitLogin)
// --- a code is never sent without the method it belongs to -------------------
//
// bw only puts the two-step token on the wire when a provider came with it
// (TokenRequest.toIdentityToken requires provider != null). Without --method
// the code-carrying request is therefore a bare password grant, and for an
// email provider the server answers that challenge by issuing a fresh code --
// invalidating the one being submitted. Measured against bw 2026.2.0: the same
// command succeeds with --method and fails without it.
check("a code is not collected until the method it belongs to is known",
/!Model\.isTwoFactorMethod\(login2faMethod\)[\s\S]{0,400}show2faMethodPicker = true[\s\S]{0,300}return/.test(loginOutput),
loginOutput)
check("the method question is asked before the code field, not after",
loginOutput.indexOf("second-factor-needs-method")
< loginOutput.indexOf("secondFactorWasVisible"),
loginOutput)
check("a known method goes straight to the code field",
/var secondFactorWasVisible = show2faField/.test(loginOutput)
&& /show2faField = true/.test(loginOutput),
loginOutput)
check("every command that carries a code also carries a method",
(() => {
const withCode = Model.emailLoginPrewarmCommand("a@b.c", true, "", 1).join(" ")
const noMethod = Model.emailLoginPrewarmCommand("a@b.c", true, "", -1).join(" ")
// The builder still honours -1; it is the panel that must never reach it
// with a code. Assert the builder pairs them when asked to.
return withCode.includes("--method 1") && withCode.includes("--code")
&& !noMethod.includes("--method")
})(),
"the pairing is the panel's to enforce, and the builder must support it")
// --- a status check must never cancel the login it raced ---------------------
//
// `bw status` takes seconds and answers about the world as it was when it
// started. Landing mid-login it says "unauthenticated", truthfully for that
// moment, and the unauthenticated branch calls cancelAuthPrewarm() -- which
// SIGTERMs the login the user just submitted. It also clears isLoading on the
// way past, so the button dropped out of "Verifying..." with nothing shown.
check("a status result that raced a submitted login is ignored",
/if \(authAttemptInFlight\(\)\)[\s\S]{0,240}return/.test(statusFinished)
&& statusFinished.indexOf("authAttemptInFlight")
< statusFinished.indexOf("isLoading = false"),
statusFinished)
check("the guard covers both kinds of submitted authentication",
/loginSubmitted \|\| unlockSubmitted/.test(bodyOf("authAttemptInFlight")),
bodyOf("authAttemptInFlight"))
check("the guard sits ahead of every branch that cancels or drops state",
statusFinished.indexOf("authAttemptInFlight")
< statusFinished.indexOf("cancelAuthPrewarm"),
statusFinished)
// --- a cleared property must never leave a filled-in field ------------------
//
// Typing into a TextField assigns to its own `text`, which breaks the binding
// back to the property behind it. Clearing the property then leaves the field
// showing what was typed, while every submit reads the property -- so the panel
// sent a login with no code at all while the user looked at a filled-in code
// field, bw answered "Code is required.", and retyping the code repaired the
// property so the next click worked. That was the double Verify.
check("clearing a login field's property clears the field with it",
["code2faField", "deviceCodeField", "loginPassField",
"apiClientIdField", "apiClientSecretField"]
.every((f) => new RegExp(`${f}\\.text =`).test(syncFields)),
syncFields)
check("the fields are synced from the state, never the other way round",
/code2faField\.text = login2faCode/.test(syncFields)
&& !/login2faCode = code2faField/.test(syncFields),
syncFields)
check("every path that clears login state syncs the fields it is behind",
["suspendPendingLogin", "resetEmailLoginSecondFactor", "abandonAuthSecrets"]
.every((fn) => /syncLoginFieldsToState\(\)/.test(bodyOf(fn))),
"a clear that skips the sync reintroduces the desync")
check("locking and succeeding sync the fields too, so a later login opens clean",
(panelSrc.match(/syncLoginFieldsToState\(\)/g) || []).length >= 6, panelSrc)
// --- a login must never end without saying anything -------------------------
//
// bw exiting cleanly with no session used to be handed to the unlock path,
// which refuses it on the login screen and then fails silently two seconds
// later in a FIFO writer. The button went to "Verifying..." and back, and
// nothing was ever shown.
check("a clean exit with no session reports instead of falling through to unlock",
/logLogin\("clean-exit-no-session"[\s\S]{0,200}errorMessage = "Bitwarden reported no error/.test(loginOutput)
&& !/unlockVaultWithPassword\(loginPassword\)/.test(loginOutput),
loginOutput)
check("every branch of the login result says which one it was",
(loginOutput.match(/logLogin\("/g) || []).length >= 9, loginOutput)
// The diagnostic is the shape of an attempt, never its content: a session
// token is counted rather than printed.
check("the diagnostic counts the session rather than printing it",
Model.loginDiagnostic
&& Model.loginDiagnostic("a-real-looking-session-token==", "", 0, "success")
.includes("stdout=30b")
&& !Model.loginDiagnostic("a-real-looking-session-token==", "", 0, "success")
.includes("a-real-looking-session-token"),
Model.loginDiagnostic && Model.loginDiagnostic("a-real-looking-session-token==", "", 0, "success"))
check("the diagnostic carries what bw said, escape sequences and all removed",
Model.loginDiagnostic("", "\u001b[2KTwo-step token is invalid.", 1, "bw-error")
.includes("Two-step token is invalid.")
&& !/\u001b/.test(Model.loginDiagnostic("", "\u001b[2Kx", 1, "bw-error")),
Model.loginDiagnostic("", "\u001b[2KTwo-step token is invalid.", 1, "bw-error"))
check("the diagnostic is bounded, so a stack trace cannot fill the log",
Model.loginDiagnostic("", "x".repeat(9000), 1, "bw-error").length < 300,
String(Model.loginDiagnostic("", "x".repeat(9000), 1, "bw-error").length))
// --- a submit must never be swallowed by the buffer scrub -------------------
//
// The scrub is started from the login process's own exit handler and takes the
// process for a moment. A submit arriving in that moment ends up waiting on the
// scrub's exit rather than the login's, and the exit handler returned early for
// a scrub -- so the deferred submit was dropped and the click did nothing. The
// next click worked because by then nothing held the process. That was having
// to press Verify twice.
check("a scrub's exit still dispatches whatever submit was waiting on it",
/finishScrubRun\(loginProc\)\)\s*\{[\s\S]{0,120}resumeDeferredLogin\(false\)[\s\S]{0,40}return/.test(loginExited),
loginExited)
check("the ordinary exit dispatches through the same path",
/!root\.loginSubmitted\)\s*\{[\s\S]{0,420}root\.resumeDeferredLogin\(true\)/.test(loginExited),
loginExited)
check("a scrub cannot schedule another scrub",
/mayScrub\)\s*\{?\s*\n?\s*clearProcessCollectorSoon/.test(resumeDeferred)
&& /resumeDeferredLogin\(false\)/.test(loginExited),
resumeDeferred)
check("every deferred kind is dispatched, not just the submit",
/deviceVerificationPending/.test(resumeDeferred)
&& /loginSubmitAfterPrewarmStop/.test(resumeDeferred)
&& /loginPrepareAfterPrewarmStop/.test(resumeDeferred),
resumeDeferred)
check("a scrub is not started over a submit that is already waiting",
/proc === loginProc[\s\S]{0,220}loginSubmitAfterPrewarmStop[\s\S]{0,160}return/.test(clearCollector),
clearCollector)
// --- a login waiting on an emailed code must survive the panel closing ------
//
// A code that arrives by email cannot be read without leaving the panel, and
// closing used to call abandonAuthSecrets() -- so the password and the stage
// were gone by the time the user came back with the code. Email two-step and
// new-device verification were both unreachable by construction.
const MIN = 60 * 1000
check("a pending login survives the panel closing, for a bounded time",
Model.secondFactorWindowOpen
&& Model.secondFactorWindowOpen(1000, 1000)
&& Model.secondFactorWindowOpen(1000, 1000 + 4 * MIN)
&& !Model.secondFactorWindowOpen(1000, 1000 + 6 * MIN),
"the window has to be open long enough to read an email and no longer")
check("no window is open when no login was pending",
!Model.secondFactorWindowOpen(0, Date.now())
&& !Model.secondFactorWindowOpen(null, Date.now())
&& !Model.secondFactorWindowOpen("", Date.now()),
"0 means nothing is pending, not that everything is")
check("a clock stepped backwards closes the window rather than reopening it",
!Model.secondFactorWindowOpen(5000, 1000),
"negative elapsed time is evidence the clock moved, not that the login is recent")
check("only a login stopped on a challenge is kept, and only with a password to submit",
/show2faField && !showDeviceCodeField && !show2faMethodPicker[\s\S]{0,60}return false/.test(pendingSecondFactor)
&& /!String\(loginPassword \|\| ""\)[\s\S]{0,40}return false/.test(pendingSecondFactor)
&& /status !== "unauthenticated"[\s\S]{0,60}return false/.test(pendingSecondFactor)
&& /Model\.secondFactorWindowOpen\(secondFactorStartedAt/.test(pendingSecondFactor),
pendingSecondFactor)
check("both ways of closing the panel keep a pending login instead of wiping it",
(panelSrc.match(/if \(pendingSecondFactorLogin\(\)\) suspendPendingLogin\(\)\s*\n\s*else abandonAuthSecrets\(\)/g) || []).length === 2,
"close() and onOpenedChanged both call abandonAuthSecrets unconditionally otherwise")
check("a half-typed code is dropped, since it is not the code about to be read",
/login2faCode = ""/.test(suspendPending) && /loginDeviceCode = ""/.test(suspendPending)
&& !/loginPassword/.test(suspendPending),
suspendPending)
check("reopening past the window starts over rather than resuming",
/!Model\.secondFactorWindowOpen\(secondFactorStartedAt[\s\S]{0,80}abandonAuthSecrets\(\)/.test(panelOpened),
panelOpened)
check("reopening inside the window lands on the field that is waiting",
/showDeviceCodeField\) deviceCodeField\.forceActiveFocus\(\)/.test(focusField)
&& /show2faField\) code2faField\.forceActiveFocus\(\)/.test(focusField),
focusField)
check("the window expires on its own, even while the panel is not on screen",
/running:\s*root\.secondFactorStartedAt > 0[\s\S]{0,300}abandonAuthSecrets\(\)/.test(panelSrc),
"a closed panel still has to forget on time")
check("every stage that waits on a code starts the clock",
(panelSrc.match(/markSecondFactorStage\(\)/g) || []).length >= 5, panelSrc)
check("locking, logging out and succeeding all end the pending window",
(panelSrc.match(/secondFactorStartedAt = 0/g) || []).length >= 3, panelSrc)
// --- an unsynced vault is not an empty vault --------------------------------
//
// `bw login` calls fullSync() without allowThrowOnError, so a sync that throws
// is swallowed: login exits 0 and prints a working session onto a local vault
// with no ciphers in it. The item list is then empty and correct, and looks
// exactly like a vault with nothing in it.
check("an unlocked vault that has never synced is repaired rather than rendered empty",
/!st\.lastSync && session && !initialSyncAttempted && !isSyncing[\s\S]{0,160}syncVault\(\)/.test(statusFinished),
statusFinished)
check("the repair is attempted once, so a failing sync cannot loop against the status refresh",
/initialSyncAttempted = true[\s\S]{0,60}syncVault\(\)/.test(statusFinished)
&& statusFinished.indexOf("initialSyncAttempted = true")
< statusFinished.indexOf("syncVault()"),
statusFinished)
check("a new session gets a fresh attempt at the repair",
/initialSyncAttempted = false/.test(bodyOf("dropVaultState"))
&& /initialSyncAttempted = false/.test(unlockSuccess),
bodyOf("dropVaultState"))
check("bw's status already reports lastSync, so nothing new has to be parsed for it",
/lastSync:\s*String\(st\.lastSync/.test(
fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")),
"parseStatus must expose lastSync")
// --- new-device verification, in the panel ----------------------------------
check("a confirmed device challenge collects the code in the panel, not in a terminal",
/loginNeedsDeviceVerification[\s\S]{0,400}showDeviceCodeField\s*=\s*true/.test(loginOutput)
&& /deviceCodeField\.forceActiveFocus/.test(loginOutput),
loginOutput)
// The interactive login's output is a prompt session, not one of bw's one-line
// refusals. Letting the ordinary detectors read it would send a device
// challenge back round as a two-step prompt.
check("the interactive attempt's result is read before any other detector",
/if \(wasDeviceAttempt && !\(exitCode === 0 && out\.length > 10\)\)/.test(loginOutput)
&& loginOutput.includes("sanitizeInteractiveStderr")
&& loginOutput.indexOf("sanitizeInteractiveStderr")
< loginOutput.indexOf("Model.loginNeedsDeviceVerification"),
loginOutput)
check("the interactive flag is consumed once, so the next attempt is read normally",
/deviceVerificationAttempt\s*=\s*false/.test(loginOutput)
&& /var wasDeviceAttempt = deviceVerificationAttempt/.test(loginOutput),
loginOutput)
check("a timeout or an unanswerable prompt falls back to the terminal login",
/exitCode === 124 \|\| Model\.loginPromptRanOutOfInput\(out, err\)[\s\S]{0,200}showDeviceCodeField\s*=\s*false/.test(loginOutput),
loginOutput)
check("a rejected code keeps the field so it can be retried",
/showDeviceCodeField\s*=\s*true[\s\S]{0,300}deviceCodeField\.forceActiveFocus/.test(loginOutput),
loginOutput)
check("the failing code is dropped before its stderr is turned into a message",
loginOutput.indexOf("sanitizeInteractiveStderr(err, loginDeviceCode)")
< loginOutput.indexOf('loginDeviceCode = ""'),
loginOutput)
check("the interactive login carries no code of its own into the ordinary detectors",
/loginAttemptHadCode\s*=\s*false/.test(startDevice), startDevice)
check("the interactive flag is set before the process that reads it starts",
startDevice.includes("deviceVerificationAttempt = true")
&& startDevice.includes("loginProc.running = true")
&& startDevice.indexOf("deviceVerificationAttempt = true")
< startDevice.indexOf("loginProc.running = true"),
startDevice)
check("the password is delivered to the interactive login the same way as any other",
/writeAuthPassword\("login", loginPassword\)/.test(startDevice), startDevice)
check("a prewarmed ordinary login is stopped before the interactive one starts",
/loginProc\.running[\s\S]{0,200}deviceVerificationPending\s*=\s*true[\s\S]{0,200}return/.test(submitDevice)
&& /deviceVerificationPending[\s\S]{0,120}startDeviceVerificationLogin/.test(panelSrc),
submitDevice)
check("the device stage refuses to submit an empty code",
/if \(!code\)[\s\S]{0,160}return/.test(submitDevice), submitDevice)
// authEnv() sets BW_NOINTERACTION unconditionally, so this path must not use
// it -- that is the whole difference between this login and every other.
check("only the interactive login runs without BW_NOINTERACTION",
/deviceVerificationAttempt[\s\S]{0,260}deviceCodeEnvVar\(\)[\s\S]{0,80}return deviceEnv/.test(loginEnv)
&& !/deviceVerificationAttempt[\s\S]{0,200}authEnv\(/.test(loginEnv),
loginEnv)
check("the device stage has its own submit and its own way out",
/visible:\s*root\.showDeviceCodeField/.test(emailLoginUi)
&& /submitDeviceVerification\(\)/.test(emailLoginUi)
&& /Use Terminal Instead[\s\S]{0,300}launchTerminalLogin\(\)/.test(emailLoginUi),
emailLoginUi)
check("the shared submit button stays out of the stages that do not use it",
/visible:\s*!root\.show2faMethodPicker\s*&&\s*!root\.showDeviceCodeField/.test(emailLoginUi),
emailLoginUi)
// --- focus must never be taken off a field being typed into ------------------
//
// A logout sets the status itself, then confirms it with `bw status` seconds
// later. That confirmation used to re-focus the login screen while the master
// password was being typed, moving the rest of it into the unmasked email
// field -- which the next submit would have sent as an email address.
check("a login screen that already has the cursor keeps it",
/loginFieldHasFocus\(\)[\s\S]{0,40}return/.test(focusField)
&& /unlockFieldHasFocus\(\)[\s\S]{0,40}return/.test(focusField)
&& /!searchField\.activeFocus/.test(focusField),
focusField)
check("the guard covers every field on the login screen, not just the visible stage",
["emailField", "loginPassField", "code2faField", "serverUrlField",
"apiClientIdField", "apiClientSecretField", "apiMasterField"]
.every((f) => new RegExp(`\\b${f}\\.activeFocus`).test(loginFieldFocus)),
loginFieldFocus)
check("every field the guard names exists on the login screen",
["serverUrlField", "apiClientIdField", "apiClientSecretField", "apiMasterField"]
.every((f) => new RegExp(`id:\\s*${f}\\b`).test(panelSrc)),
"the guard must not silently reference a field that was never given an id")
check("closing the panel releases the cursor, so reopening is not read as typing",
/abandonAuthSecrets\(\)[\s\S]{0,220}keyCatcher\.forceActiveFocus\(\)/.test(panelSrc),
panelSrc.slice(panelSrc.indexOf("onOpenedChanged"), panelSrc.indexOf("onOpenedChanged") + 500))
// --- the two-step method question, in the panel -----------------------------
check("bw's provider question is answered by asking, before anything is guessed",
/loginNeedsMethodChoice/.test(loginOutput)
&& loginOutput.indexOf("loginNeedsMethodChoice")
< loginOutput.indexOf("loginNeedsSecondFactor")
&& /show2faMethodPicker\s*=\s*true/.test(loginOutput),
loginOutput)
check("the dead-end provider message is told apart from the answerable one",
/loginHasNoUsableProvider/.test(loginOutput)
&& loginOutput.indexOf("loginHasNoUsableProvider")
< loginOutput.indexOf("loginNeedsMethodChoice"),
loginOutput)
// shell.json holds one method for whichever account logged in last, so a
// remembered method that this account rejects is dropped and retried without,
// where one the user just chose is reported back to them.
check("a rejected method that was only remembered is dropped and retried untargeted",
/!login2faMethodConfirmed[\s\S]{0,260}forgetTwoFactorMethod\(\)[\s\S]{0,200}login2faMethod\s*=\s*-1[\s\S]{0,200}submitLogin[\s\S]{0,60}return/.test(loginOutput),
loginOutput)
check("a rejected method the user chose is reported as not configured, not retried",
/login2faMethodConfirmed\s*\n?\s*\?\s*Model\.twoFactorMethodLabel\(loginAttemptMethod\)/.test(loginOutput)
&& /does not have/.test(loginOutput),
loginOutput)
// The pick is sent on its own first. For Email that is what makes Bitwarden
// send the mail at all -- bw only posts the two-factor email when no token
// came with the request -- and for the others it validates the pick before
// anything is typed.
check("choosing a method submits it without a code",
/login2faMethod\s*=\s*method/.test(chooseMethod)
&& /login2faMethodConfirmed\s*=\s*true/.test(chooseMethod)
&& /login2faCode\s*=\s*""/.test(chooseMethod)
&& chooseMethod.indexOf('login2faCode = ""') < chooseMethod.indexOf("submitLogin()"),
chooseMethod)
check("only a method from the table can be chosen",
/Model\.isTwoFactorMethod\(method\)[\s\S]{0,40}return/.test(chooseMethod), chooseMethod)
// A prewarmed process was started with whatever method was set at the time.
// Reusing it after the method changed would send the old one.
check("a changed method restarts the prewarmed login instead of reusing it",
/login2faMethod/.test(loginSignature), loginSignature)
check("every email login attempt records the method it sent",
(submitLogin.match(/loginAttemptMethod\s*=/g) || []).length >= 2
&& /loginAttemptMethod\s*=\s*login2faMethod/.test(prepareEmailLogin),
prepareEmailLogin + "\n---\n" + submitLogin)
check("a method is remembered only once it has actually worked",
/rememberTwoFactorMethod\(login2faMethod\)/.test(loginOutput)
&& loginOutput.indexOf("rememberTwoFactorMethod") > loginOutput.indexOf("exitCode === 0")
&& /Model\.isTwoFactorMethod\(method\)[\s\S]{0,40}return/.test(rememberMethod),
loginOutput + "\n---\n" + rememberMethod)
check("the remembered method is written without the settings screen's flash",
/writeSettingQuietly\("twoFactorMethods", next, "json"\)/.test(rememberMethod)
&& !/settingsFlash/.test(bodyOf("writeSettingQuietly")),
rememberMethod)
check("the method is remembered against the account that just used it",
/rememberTwoFactorMethodIn\(twoFactorMethodStore, loginEmail, method\)/.test(rememberMethod)
&& /rememberedTwoFactorMethodFor\(twoFactorMethodStore, loginEmail\)/.test(panelSrc),
rememberMethod)
check("a stale method is forgotten only for the account that rejected it",
/forgetTwoFactorMethodIn\(twoFactorMethodStore, loginEmail\)/.test(bodyOf("forgetTwoFactorMethod")),
bodyOf("forgetTwoFactorMethod"))
// Unlock has always re-armed itself when the password could not be handed to
// bw; login left the button for the user to press again.
check("a failed password delivery retries the login once instead of asking for another click",
/!loginPasswordRetryUsed[\s\S]{0,120}loginPasswordRetryUsed = true[\s\S]{0,220}Qt\.callLater\([\s\S]{0,80}submitLogin\)[\s\S]{0,40}return/.test(writerExited)
&& /errorMessage = "Could not deliver/.test(writerExited),
writerExited)
check("the retry follows the login that was actually running",
/retryDevice \? submitDeviceVerification : submitLogin/.test(writerExited), writerExited)
check("a delivered password restores the retry, so the next login gets its own",
/exitCode === 0\)? \{[\s\S]{0,80}loginPasswordRetryUsed = false/.test(writerExited), writerExited)
check("the picker offers the model's methods rather than a list of its own",
/visible:\s*root\.show2faMethodPicker[\s\S]{0,1400}Repeater\s*\{\s*\n\s*model:\s*Model\.twoFactorMethods\(\)/.test(emailLoginUi)
&& /chooseTwoFactorMethod\(modelData\.method\)/.test(emailLoginUi),
emailLoginUi)
check("the code stage names the method it is collecting for and can go back to the question",
/root\.login2faMethodLabel/.test(emailLoginUi)
&& /Change method[\s\S]{0,300}reopenTwoFactorMethodPicker\(\)/.test(emailLoginUi),
emailLoginUi)
check("the terminal login stops being an aside once only it can finish the login",
/root\.loginDeviceVerification[\s\S]{0,600}launchTerminalLogin\(\)/.test(terminalLoginUi)
&& /selected:\s*root\.loginDeviceVerification/.test(terminalLoginUi),
terminalLoginUi)
check("abandoning credentials returns the next login to its first stage",
/show2faField\s*=\s*false/.test(abandonAuth), abandonAuth)
check("API credentials are not materialized in the process environment before submission",
/if\s*\(\s*!loginSubmitted\s*\)\s*return\s+authEnv\(\s*""\s*,\s*""\s*,\s*""\s*,\s*""\s*\)/.test(loginEnv)
&& loginEnv.indexOf("!loginSubmitted") < loginEnv.indexOf("loginClientSecret"),
loginEnv)
for (const prop of ["loginClientId", "loginClientSecret", "login2faCode"]) {
check(`successful authentication clears ${prop}`,
new RegExp(`\\b${prop}\\s*=\\s*""`).test(unlockSuccess), unlockSuccess)
}
check("PIN unlock preserves significant master-password whitespace",
pinResult !== "" && !/String\(password[^\n]+\)\.trim\(\)/.test(pinResult), pinResult)
check("fingerprint unlock preserves significant master-password whitespace",
fingerprintResult !== "" && !/String\(raw[^\n]+\)\.trim\(\)/.test(fingerprintResult), fingerprintResult)
check("logging out forgets the stored credentials",
/forgetStoredCredentials\(\)/.test(logout), logout)
check("the clear-all command is what it runs",
/requestAllCredentialClear\(\)/.test(forget)
&& /keyringClearAllProc\.running\s*=\s*true/.test(bodyOf("requestAllCredentialClear")), forget)
check("it does not ask fingerprintStored or pinConfigured for permission first",
forget !== "" && !/\bif\s*\(\s*(fingerprintStored|pinConfigured)\b/.test(forget), forget)
check("the panel declares a process for the clear-all command",
/id:\s*keyringClearAllProc[\s\S]{0,120}Model\.keyringClearAllCommand\(\)/.test(panelSrc),
"expected a keyringClearAllProc bound to Model.keyringClearAllCommand()")
check("logout keeps new authentication blocked until CLI and keyring cleanup both finish",
/logoutPending\s*=\s*true/.test(logout)
&& /logoutCliDone\s*=\s*false/.test(logout)
&& /logoutCredentialsDone\s*=\s*false/.test(logout)
&& /logoutPending/.test(bodyOf("submitLogin"))
&& /logoutPending/.test(bodyOf("prepareEmailLogin"))
&& /logoutPending/.test(bodyOf("launchTerminalLogin")),
logout + "\n" + bodyOf("submitLogin") + "\n" + bodyOf("prepareEmailLogin")
+ "\n" + bodyOf("launchTerminalLogin"))
check("logout completion is acknowledged by both asynchronous processes",
/onLogoutCredentialsFinished\(exitCode\)/.test(panelSrc.slice(panelSrc.indexOf("id: keyringClearAllProc"),
panelSrc.indexOf("id: keyringClearAllProc") + 420))
&& /onLogoutCliFinished\(exitCode\)/.test(panelSrc.slice(panelSrc.indexOf("id: logoutProc"),
panelSrc.indexOf("id: logoutProc") + 240)),
"logout cleanup processes are not serialized")
check("failed keyring cleanup keeps authentication blocked until an explicit retry succeeds",
/logoutCredentialsExitCode\s*=\s*exitCode/.test(bodyOf("onLogoutCredentialsFinished"))
&& /if\s*\(logoutCredentialsExitCode\s*!==\s*0\)[\s\S]*return/.test(bodyOf("finishLogoutIfReady"))
&& /requestAllCredentialClear\(\)/.test(bodyOf("retryLogoutCleanup"))
&& /logoutCleanupFailed\s*\?\s*root\.retryLogoutCleanup\(\)/.test(panelSrc),
bodyOf("onLogoutCredentialsFinished") + "\n" + bodyOf("finishLogoutIfReady")
+ "\n" + bodyOf("retryLogoutCleanup"))
check("logout's final keyring sweep waits for every credential writer",
["keyringStoreProc", "pinStoreProc", "keyringStoreMasterProc"].every(id =>
new RegExp(`\\b${id}\\.running`).test(credentialStores))
&& /credentialStoresRunning\(\)[\s\S]*allCredentialsClearPending\s*=\s*true[\s\S]*return/.test(allCredentialClear),
credentialStores + "\n" + allCredentialClear)
for (const id of ["keyringStoreProc", "pinStoreProc", "keyringStoreMasterProc"]) {
const start = panelSrc.indexOf(`id: ${id}`)
const processBlock = panelSrc.slice(start, start + 520)
check(`${id} resumes the deferred logout sweep after its write exits`,
/logoutPending[\s\S]*allCredentialsClearPending[\s\S]*requestAllCredentialClear/.test(processBlock),
processBlock)
}
// Turning fingerprint unlock off is the other place a flag used to decide
// whether the master password stayed behind.
const fpOff = panelSrc.slice(panelSrc.indexOf("onFingerprintUnlockChanged:"),
panelSrc.indexOf("onFingerprintUnlockChanged:") + 700)
check("disabling fingerprint unlock clears the keyring unconditionally",
/forgetFingerprintUnlock\(\)/.test(fpOff) && !/if\s*\(fingerprintStored\)\s*forgetFingerprintUnlock/.test(fpOff),
fpOff)
check("locking erases the remembered session whatever the setting now says",
/requestSessionCredentialClear\(\)/.test(bodyOf("lockVault"))
&& /keyringClearProc\.running\s*=\s*true/.test(bodyOf("requestSessionCredentialClear"))
&& !/if\s*\(rememberSession\)\s*\{\s*\n\s*requestSessionCredentialClear/.test(bodyOf("lockVault")),
bodyOf("lockVault"))
// --- and nothing the vault gave us outlives the lock ------------------------
// Every one of these is a secret that used to sit in the panel object until
// the shell exited: a generated password, a form left mid-compose, the payload
// JSON on its way to bw, the master password typed into a setup form.
const dropped = bodyOf("dropVaultSecrets")
check("locking drops the vault secrets",
/dropVaultState\(\)/.test(bodyOf("lockVault")) && /dropVaultSecrets\(\)/.test(bodyOf("dropVaultState")),
bodyOf("lockVault") + "\n" + bodyOf("dropVaultState"))
for (const prop of ["detailPassword", "liveTotp", "totpFollowupCode", "genValue",
"formPassword", "formTotp", "itemPayloadJson", "sendPayloadJson",
"sendFormText", "sendFormPassword", "loginPassword", "loginClientSecret",
"pinEntry", "pinSetupPin", "pinSetupMaster", "fpSetupMaster",
"masterToStore"]) {
check(`locking clears ${prop}`,
new RegExp(`\\b${prop}\\s*=\\s*""`).test(dropped), dropped)
}
check("the item payload is dropped once bw has taken it, as the Send one is",
/itemPayloadJson\s*=\s*""/.test(bodyOf("onSaveItemFinished")), bodyOf("onSaveItemFinished"))
// Cancel and Escape leave a setup form the same way, so the clearing sits on
// the screen change rather than on each of the ways out.
const screenChanged = panelSrc.slice(panelSrc.indexOf("onCurrentScreenChanged:"),
panelSrc.indexOf("onCurrentScreenChanged:") + 1200)
check("leaving the PIN form drops the master password it asked for",
/currentScreen !== "pin"[\s\S]{0,80}abandonPinSetup\(\)/.test(screenChanged)
&& /pinSetupMaster\s*=\s*""/.test(bodyOf("abandonPinSetup")), screenChanged)
check("leaving the fingerprint form drops the master password it asked for",
/currentScreen !== "fingerprint"[\s\S]{0,80}abandonFingerprintSetup\(\)/.test(screenChanged)
&& /fpSetupMaster\s*=\s*""/.test(bodyOf("abandonFingerprintSetup")), screenChanged)
console.log(`${pass} passed, ${failures.length} failed`)
if (failures.length) { console.error("\nFAILURES:\n " + failures.join("\n ")); process.exit(1) }