- 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
373 lines
21 KiB
JavaScript
373 lines
21 KiB
JavaScript
#!/usr/bin/env node
|
|
// Tests for the setup wizard's dependency probe and the settings writer.
|
|
//
|
|
// The interesting cases are the ones that cannot be exercised on a machine
|
|
// where everything is already installed: a missing required tool, and fprintd
|
|
// being present but having no enrolled finger.
|
|
//
|
|
// node tests/setup-settings.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.parseDependencies = parseDependencies
|
|
exports.missingRequired = missingRequired
|
|
exports.dependencyCheckCommand = dependencyCheckCommand
|
|
exports.vaultListMode = vaultListMode
|
|
exports.vaultListBlockedMessage = vaultListBlockedMessage
|
|
exports.vaultListFailureMessage = vaultListFailureMessage
|
|
exports.sshCliMinVersion = sshCliMinVersion
|
|
exports.sshCliSupport = typeof sshCliSupport === "function" ? sshCliSupport : null
|
|
exports.sshUiAvailable = sshUiAvailable
|
|
exports.settingWriteCommand = settingWriteCommand
|
|
exports.boolSetting = boolSetting
|
|
exports.installPackagesCommand = installPackagesCommand
|
|
exports.SETTINGS_SCHEMA = SETTINGS_SCHEMA
|
|
exports.DEPENDENCIES = DEPENDENCIES
|
|
exports.groupedSettings = groupedSettings
|
|
exports.SETTINGS_GROUPS = SETTINGS_GROUPS
|
|
exports.validatePin = validatePin
|
|
exports.pinMinLength = pinMinLength
|
|
exports.pinRecommendedLength = pinRecommendedLength
|
|
exports.pinWeakWarning = pinWeakWarning
|
|
exports.isPinWeak = isPinWeak
|
|
exports.pinStoreCommand = pinStoreCommand
|
|
exports.pinUnlockCommand = pinUnlockCommand
|
|
`)(Model)
|
|
|
|
let pass = 0
|
|
const failures = []
|
|
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
|
const byKey = (deps, k) => deps.items.find(d => d.key === k)
|
|
const dependencyProbe = Model.dependencyCheckCommand()[2]
|
|
const sshCliSupport = (version) => typeof Model.sshCliSupport === "function"
|
|
? Model.sshCliSupport(version)
|
|
: "__missing__"
|
|
|
|
// --- everything present -----------------------------------------------------
|
|
const all = Model.parseDependencies(
|
|
"bw=1\nbw_version=2025.1.2\njq=1\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=1\nfingerprint_ready=1\nomarchy=1")
|
|
check("all present: nothing required is missing",
|
|
Model.missingRequired(all).length === 0,
|
|
`got [${Model.missingRequired(all).map(d => d.key)}]`)
|
|
check("all present: fprintd reported ready", byKey(all, "fprintd").ready === true, "expected ready")
|
|
check("jq is a required dependency alongside bw",
|
|
byKey(all, "jq") && byKey(all, "jq").required === true && byKey(all, "jq").pkg === "jq",
|
|
JSON.stringify(byKey(all, "jq")))
|
|
check("the dependency probe checks for jq before vault reads",
|
|
dependencyProbe.includes("command -v jq"),
|
|
dependencyProbe)
|
|
check("the dependency probe captures the bw CLI version for SSH gating",
|
|
/bw\s+--version|bw\s+-v/.test(dependencyProbe),
|
|
dependencyProbe)
|
|
check("sshCliMinVersion reports the verified floor",
|
|
Model.sshCliMinVersion() === "2025.1.2",
|
|
String(Model.sshCliMinVersion()))
|
|
check("sshCliSupport marks 2025.1.2 as supported",
|
|
sshCliSupport("2025.1.2") === "supported",
|
|
JSON.stringify(sshCliSupport("2025.1.2")))
|
|
check("sshCliSupport marks 2025.1.1 as unsupported",
|
|
sshCliSupport("2025.1.1") === "unsupported",
|
|
JSON.stringify(sshCliSupport("2025.1.1")))
|
|
check("sshCliSupport treats malformed versions as unknown",
|
|
sshCliSupport("development-build") === "unknown",
|
|
JSON.stringify(sshCliSupport("development-build")))
|
|
check("sshCliSupport treats a missing version as unknown",
|
|
sshCliSupport("") === "unknown",
|
|
JSON.stringify(sshCliSupport("")))
|
|
check("SSH surfaces stay hidden until the probe confirms a supported CLI",
|
|
Model.sshUiAvailable(all, true) === true
|
|
&& Model.sshUiAvailable(all, false) === false,
|
|
JSON.stringify({ checked: Model.sshUiAvailable(all, true), unchecked: Model.sshUiAvailable(all, false) }))
|
|
const oldCli = Model.parseDependencies(
|
|
"bw=1\nbw_version=2025.1.1\njq=1\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=1\nfingerprint_ready=1\nomarchy=1")
|
|
check("an unsupported CLI hides SSH but still reports why on the bw row",
|
|
Model.sshUiAvailable(oldCli, true) === false
|
|
&& byKey(oldCli, "bw").note.includes("2025.1.2")
|
|
&& byKey(oldCli, "bw").note.includes("2025.1.1"),
|
|
JSON.stringify(byKey(oldCli, "bw")))
|
|
const unreadableCli = Model.parseDependencies(
|
|
"bw=1\nbw_version=development-build\njq=1\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=1\nfingerprint_ready=1\nomarchy=1")
|
|
check("an unreadable CLI version hides SSH rather than assuming support",
|
|
Model.sshUiAvailable(unreadableCli, true) === false
|
|
&& unreadableCli.sshCliStatus === "unknown"
|
|
&& byKey(unreadableCli, "bw").note !== "",
|
|
JSON.stringify({ status: unreadableCli.sshCliStatus, bw: byKey(unreadableCli, "bw") }))
|
|
|
|
check("bw version metadata is preserved for feature gating",
|
|
byKey(all, "bw") && byKey(all, "bw").version === "2025.1.2" && all.sshCliStatus === "supported",
|
|
JSON.stringify({ bw: byKey(all, "bw"), sshCliStatus: all.sshCliStatus }))
|
|
|
|
// --- the case that matters: a required tool is absent -----------------------
|
|
const noBw = Model.parseDependencies(
|
|
"bw=0\njq=1\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=1\nfingerprint_ready=1\nomarchy=1")
|
|
const missing = Model.missingRequired(noBw)
|
|
check("missing bw is reported as required",
|
|
missing.length === 1 && missing[0].key === "bw" && missing[0].pkg === "bitwarden-cli",
|
|
`got [${missing.map(d => d.key + ":" + d.pkg)}]`)
|
|
check("missing bw is not marked installed", byKey(noBw, "bw").installed === false, "expected false")
|
|
const noJq = Model.parseDependencies(
|
|
"bw=1\nbw_version=2025.1.2\njq=0\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=1\nfingerprint_ready=1\nomarchy=1")
|
|
const missingNoJq = Model.missingRequired(noJq)
|
|
check("missing jq is reported as required",
|
|
missingNoJq.length === 1 && missingNoJq[0].key === "jq" && missingNoJq[0].pkg === "jq",
|
|
`got [${missingNoJq.map(d => d.key + ":" + d.pkg)}]`)
|
|
check("missing jq does not alter optional dependency semantics",
|
|
byKey(noJq, "fprintd").required === false,
|
|
JSON.stringify(byKey(noJq, "fprintd")))
|
|
check("supported bw without jq blocks the vault list until setup finishes",
|
|
Model.vaultListMode(noJq) === "blocked"
|
|
&& Model.vaultListBlockedMessage(noJq).includes("jq"),
|
|
`${Model.vaultListMode(noJq)} / ${Model.vaultListBlockedMessage(noJq)}`)
|
|
|
|
// A whole-list failure on a CLI that predates the malformed-SSH-item fix is the
|
|
// one case where the panel can say something useful about a read it cannot
|
|
// repair. The attribution comes from the probed version, never from the failed
|
|
// read's own output, which can quote decrypted vault material.
|
|
const rawCliFailure = "TypeError: Cannot read properties of null (reading 'keyFingerprint') for item work-ssh"
|
|
check("a list failure on a pre-2026.8.0 CLI names the release that fixes it",
|
|
Model.vaultListFailureMessage(rawCliFailure, all, "sanitized").includes("2026.8.0"),
|
|
Model.vaultListFailureMessage(rawCliFailure, all, "sanitized"))
|
|
check("the failure message never echoes raw CLI output",
|
|
!Model.vaultListFailureMessage(rawCliFailure, all, "sanitized").includes("keyFingerprint")
|
|
&& !Model.vaultListFailureMessage(rawCliFailure, all, "sanitized").includes("TypeError")
|
|
&& !Model.vaultListFailureMessage(rawCliFailure, all, "sanitized").includes("work-ssh"),
|
|
Model.vaultListFailureMessage(rawCliFailure, all, "sanitized"))
|
|
const fixedCli = Model.parseDependencies(
|
|
"bw=1\nbw_version=2026.8.0\njq=1\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=1\nfingerprint_ready=1\nomarchy=1")
|
|
check("a list failure on a fixed CLI does not blame the SSH-item bug",
|
|
!Model.vaultListFailureMessage(rawCliFailure, fixedCli, "sanitized").includes("2026.8.0"),
|
|
Model.vaultListFailureMessage(rawCliFailure, fixedCli, "sanitized"))
|
|
check("a blocked list reports the missing tool instead of the SSH hint",
|
|
Model.vaultListFailureMessage(rawCliFailure, noJq, "blocked").includes("jq")
|
|
&& !Model.vaultListFailureMessage(rawCliFailure, noJq, "blocked").includes("2026.8.0"),
|
|
Model.vaultListFailureMessage(rawCliFailure, noJq, "blocked"))
|
|
|
|
// An optional tool going missing must not trigger the blocking wizard.
|
|
const noFprintd = Model.parseDependencies(
|
|
"bw=1\nbw_version=2025.1.2\njq=1\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=0\nfingerprint_ready=0\nomarchy=1")
|
|
check("missing optional tool does not block setup",
|
|
Model.missingRequired(noFprintd).length === 0,
|
|
`got [${Model.missingRequired(noFprintd).map(d => d.key)}]`)
|
|
|
|
// --- fprintd installed but no finger enrolled -------------------------------
|
|
const noFinger = Model.parseDependencies(
|
|
"bw=1\nbw_version=2025.1.2\njq=1\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=1\nfingerprint_ready=0\nomarchy=1")
|
|
check("fprintd on PATH without an enrolled finger is installed-but-not-ready",
|
|
byKey(noFinger, "fprintd").installed === true && byKey(noFinger, "fprintd").ready === false,
|
|
`installed=${byKey(noFinger, "fprintd").installed} ready=${byKey(noFinger, "fprintd").ready}`)
|
|
|
|
const oldBw = Model.parseDependencies(
|
|
"bw=1\nbw_version=2025.1.1\njq=1\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=1\nfingerprint_ready=1\nomarchy=1")
|
|
check("older bw versions remain installed but are marked unsupported for SSH",
|
|
byKey(oldBw, "bw").installed === true
|
|
&& byKey(oldBw, "bw").version === "2025.1.1"
|
|
&& oldBw.sshCliStatus === "unsupported"
|
|
&& byKey(oldBw, "bw").note.includes(Model.sshCliMinVersion()),
|
|
JSON.stringify({ bw: byKey(oldBw, "bw"), sshCliStatus: oldBw.sshCliStatus }))
|
|
check("older bw with jq still uses the sanitized list path for ordinary items",
|
|
Model.vaultListMode(oldBw) === "sanitized",
|
|
JSON.stringify({ mode: Model.vaultListMode(oldBw), deps: oldBw }))
|
|
|
|
const unknownBw = Model.parseDependencies(
|
|
"bw=1\nbw_version=development-build\njq=1\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=1\nfingerprint_ready=1\nomarchy=1")
|
|
check("unknown bw versions are reported separately from unsupported ones",
|
|
byKey(unknownBw, "bw").installed === true
|
|
&& byKey(unknownBw, "bw").version === ""
|
|
&& unknownBw.sshCliStatus === "unknown",
|
|
JSON.stringify({ bw: byKey(unknownBw, "bw"), sshCliStatus: unknownBw.sshCliStatus }))
|
|
check("unknown bw with jq still uses the sanitized list path and never falls back to a raw legacy read",
|
|
Model.vaultListMode(unknownBw) === "sanitized",
|
|
JSON.stringify({ mode: Model.vaultListMode(unknownBw), deps: unknownBw }))
|
|
|
|
// --- malformed / empty probe output -----------------------------------------
|
|
for (const [label, raw] of [["empty", ""], ["garbage", "???\n=\nbw\n"]]) {
|
|
const d = Model.parseDependencies(raw)
|
|
check(`${label} probe output degrades to all-missing`,
|
|
d.items.length === Model.DEPENDENCIES.length && d.items.every(i => !i.installed),
|
|
`got ${d.items.length} items, installed=[${d.items.filter(i => i.installed).map(i => i.key)}]`)
|
|
}
|
|
|
|
// --- settings writer --------------------------------------------------------
|
|
// Values must reach shell.json as real JSON types, not strings, or `setting()`
|
|
// hands the panel a string where it expects a number or a bool.
|
|
// The writer runs through bash so its diagnostic stderr can be capped, so the
|
|
// assertions read the script rather than an argv list.
|
|
const writeScript = (k, v, t) => Model.settingWriteCommand(k, v, t)[2]
|
|
|
|
// --- colorized menu-bar icon setting ----------------------------------------
|
|
const colorizeIcon = Model.SETTINGS_SCHEMA.find(e => e.key === "colorizeIcon")
|
|
check("colorized icon setting is declared in General", !!colorizeIcon
|
|
&& colorizeIcon.group === "general"
|
|
&& colorizeIcon.type === "bool",
|
|
JSON.stringify(colorizeIcon))
|
|
check("colorized icon defaults off", !!colorizeIcon && colorizeIcon.defaultValue === false,
|
|
JSON.stringify(colorizeIcon))
|
|
check("colorized icon accepts only actual booleans",
|
|
Model.boolSetting("colorizeIcon", true) === true
|
|
&& Model.boolSetting("colorizeIcon", false) === false
|
|
&& Model.boolSetting("colorizeIcon", "true") === false
|
|
&& Model.boolSetting("colorizeIcon", 1) === false,
|
|
"malformed colorizeIcon input was accepted")
|
|
|
|
check("boolean settings accept actual JSON booleans",
|
|
Model.boolSetting("fingerprintUnlock", true) === true
|
|
&& Model.boolSetting("fingerprintUnlock", false) === false,
|
|
"actual booleans were not preserved")
|
|
check("malformed strings cannot enable opt-in credential storage",
|
|
Model.boolSetting("fingerprintUnlock", "false") === false
|
|
&& Model.boolSetting("pinUnlock", "true") === false,
|
|
"a string enabled an opt-in unlock method")
|
|
check("malformed lock settings fail back to their secure defaults",
|
|
Model.boolSetting("lockOnScreenLock", "false") === true
|
|
&& Model.boolSetting("lockOnSuspend", 0) === true,
|
|
"a malformed setting disabled locking")
|
|
|
|
check("int setting is written with --json",
|
|
writeScript("autoLockMinutes", 15, "int")
|
|
.includes("omarchy bar set io.github.elevate08.qs-bitwarden-cli 'autoLockMinutes' '15' --json"),
|
|
writeScript("autoLockMinutes", 15, "int"))
|
|
|
|
for (const [v, want] of [[true, "true"], [false, "false"]]) {
|
|
const script = writeScript("closeOnCopy", v, "bool")
|
|
check(`bool ${v} is written as ${want}`,
|
|
script.includes(`'closeOnCopy' '${want}' --json`), `got ${script}`)
|
|
}
|
|
check("a zero int is written as 0, not dropped",
|
|
writeScript("autoLockMinutes", 0, "int").includes("'autoLockMinutes' '0' --json"),
|
|
writeScript("autoLockMinutes", 0, "int"))
|
|
|
|
// stderr from `omarchy bar set` is collected by the panel, so it needs the same
|
|
// producer-side cap as every other stream the long-lived shell buffers.
|
|
check("setting writer caps its diagnostic stderr",
|
|
writeScript("autoLockMinutes", 15, "int").includes("exec 2> >(head -c 8192 >&2)"),
|
|
writeScript("autoLockMinutes", 15, "int"))
|
|
|
|
// Every schema key must exist in the manifest, or the settings screen would
|
|
// write a key the plugin never reads.
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "manifest.json"), "utf8"))
|
|
const manifestKeys = new Set(manifest.barWidget.schema.map(e => e.key))
|
|
for (const entry of Model.SETTINGS_SCHEMA) {
|
|
check(`schema key '${entry.key}' exists in manifest.json`,
|
|
manifestKeys.has(entry.key), `manifest has [${[...manifestKeys]}]`)
|
|
}
|
|
const colorizeManifest = manifest.barWidget.schema.find(e => e.key === "colorizeIcon")
|
|
check("manifest colorized icon schema matches the model contract",
|
|
!!colorizeManifest
|
|
&& colorizeManifest.type === "boolean"
|
|
&& colorizeManifest.label === colorizeIcon.label
|
|
&& colorizeManifest.description === colorizeIcon.description
|
|
&& colorizeManifest.defaultValue === false
|
|
&& manifest.barWidget.defaults.colorizeIcon === false,
|
|
JSON.stringify({ model: colorizeIcon, manifest: colorizeManifest }))
|
|
|
|
// --- install command --------------------------------------------------------
|
|
check("no packages yields no command", Model.installPackagesCommand([]) === null, "expected null")
|
|
const inst = Model.installPackagesCommand(["bitwarden-cli", "wl-clipboard"])
|
|
check("install goes through Omarchy's own floating-terminal installer",
|
|
inst.slice(0, 3).join(" ") === "omarchy install app" && inst[4] === "bitwarden-cli wl-clipboard",
|
|
inst.join(" "))
|
|
|
|
// The package list lands in an unquoted expansion inside omarchy-install-app,
|
|
// so anything that is not a plain package name must not reach it.
|
|
check("install refuses a package name that is not one",
|
|
Model.installPackagesCommand(["bitwarden-cli; rm -rf /"]) === null,
|
|
JSON.stringify(Model.installPackagesCommand(["bitwarden-cli; rm -rf /"])))
|
|
|
|
// The probe must be a single process, not one per tool.
|
|
check("dependency probe is one shell invocation",
|
|
Model.dependencyCheckCommand()[0] === "bash" && Model.dependencyCheckCommand().length === 3,
|
|
JSON.stringify(Model.dependencyCheckCommand().slice(0, 2)))
|
|
|
|
|
|
// --- settings grouping ------------------------------------------------------
|
|
const grouped = Model.groupedSettings()
|
|
check("grouping keeps every setting",
|
|
grouped.length === Model.SETTINGS_SCHEMA.length,
|
|
`${grouped.length} vs ${Model.SETTINGS_SCHEMA.length}`)
|
|
check("exactly one header per group",
|
|
grouped.filter(e => e.groupLabel !== "").length === Model.SETTINGS_GROUPS.length,
|
|
`got ${grouped.filter(e => e.groupLabel !== "").length} headers`)
|
|
check("entries are contiguous within a group",
|
|
JSON.stringify(grouped.map(e => e.group)) ===
|
|
JSON.stringify(grouped.map(e => e.group).slice().sort(
|
|
(a, b) => Model.SETTINGS_GROUPS.findIndex(g => g.id === a) - Model.SETTINGS_GROUPS.findIndex(g => g.id === b))),
|
|
grouped.map(e => e.group).join(","))
|
|
check("grouping does not mutate the schema",
|
|
Model.SETTINGS_SCHEMA.every(e => e.groupLabel === undefined), "schema was mutated")
|
|
|
|
// --- PIN validation ---------------------------------------------------------
|
|
check("minimum PIN length is 4", Model.pinMinLength() === 4, String(Model.pinMinLength()))
|
|
check("recommended PIN length is 6", Model.pinRecommendedLength() === 6, String(Model.pinRecommendedLength()))
|
|
|
|
// A short PIN is allowed -- the point is that it is flagged, not blocked.
|
|
check("a 4-digit PIN still validates", Model.validatePin("1234", "1234") === "", Model.validatePin("1234", "1234"))
|
|
check("a 5-digit PIN still validates", Model.validatePin("12345", "12345") === "", Model.validatePin("12345", "12345"))
|
|
check("but 4 digits is flagged weak", Model.isPinWeak("1234"), "expected weak")
|
|
check("and 5 digits is flagged weak", Model.isPinWeak("12345"), "expected weak")
|
|
check("6 digits is not flagged", !Model.isPinWeak("123456"), Model.pinWeakWarning("123456"))
|
|
check("longer than 6 is not flagged", !Model.isPinWeak("1234567890"), Model.pinWeakWarning("1234567890"))
|
|
|
|
// No warning while still typing towards a good PIN, or it would flash on
|
|
// every keystroke from the first digit onwards.
|
|
check("nothing is flagged before the floor is even reached",
|
|
!Model.isPinWeak("") && !Model.isPinWeak("1") && !Model.isPinWeak("123"),
|
|
"expected no warning below the minimum")
|
|
|
|
// The warning has to carry the actual number, not a vague 'weak'.
|
|
check("the warning names the search space for 4 digits",
|
|
Model.pinWeakWarning("1234").includes("10,000") && Model.pinWeakWarning("1234").includes("4-digit"),
|
|
Model.pinWeakWarning("1234"))
|
|
check("the warning names the search space for 5 digits",
|
|
Model.pinWeakWarning("12345").includes("100,000"), Model.pinWeakWarning("12345"))
|
|
check("the warning points at the recommendation",
|
|
Model.pinWeakWarning("1234").includes("6 or more"), Model.pinWeakWarning("1234"))
|
|
for (const [pin, confirm, wantErr] of [
|
|
["123", "123", true], // too short
|
|
["1234", "1234", false], // the minimum is accepted
|
|
["12345678901234", "12345678901234", false], // longer is allowed, no upper bound
|
|
["12a4", "12a4", true], // non-digits refused
|
|
["", "", true],
|
|
["1234", "4321", true], // mismatch
|
|
]) {
|
|
const err = Model.validatePin(pin, confirm)
|
|
check(`validatePin(${JSON.stringify(pin)}, ${JSON.stringify(confirm)})`,
|
|
(err !== "") === wantErr, `err=${JSON.stringify(err)}`)
|
|
}
|
|
check("confirm is optional when omitted", Model.validatePin("1234") === "", Model.validatePin("1234"))
|
|
|
|
// --- PIN crypto command shape ----------------------------------------------
|
|
// The whole point of PIN unlock over fingerprint unlock is that the keyring
|
|
// holds ciphertext, not the master password. Guard that property.
|
|
const store = Model.pinStoreCommand()[2]
|
|
check("store derives a key from the PIN rather than saving it",
|
|
store.includes("openssl enc") && store.includes("-pbkdf2") && store.includes("env:QSBW_PIN"), store)
|
|
check("store uses a high iteration count",
|
|
/-iter\s+(\d+)/.test(store) && Number(store.match(/-iter\s+(\d+)/)[1]) >= 600000, store)
|
|
check("store pins PBKDF2 to SHA-256 instead of relying on an OpenSSL default",
|
|
store.includes("-md sha256"), store)
|
|
check("store salts the ciphertext", store.includes("-salt"), store)
|
|
check("store reports encryption failures instead of saving an empty blob",
|
|
store.includes("set -o pipefail"), store)
|
|
check("store pipes straight into the keyring, never through argv",
|
|
store.includes("secret-tool store") && !store.includes("$QSBW_SECRET\" secret-tool"), store)
|
|
check("neither PIN nor secret appears as a literal argument",
|
|
!store.includes("--pass ") && store.includes("-pass env:"), store)
|
|
|
|
const unlock = Model.pinUnlockCommand()[2]
|
|
check("unlock decrypts with the PIN-derived key",
|
|
unlock.includes("openssl enc -d") && unlock.includes("env:QSBW_PIN"), unlock)
|
|
check("unlock fails loudly when the lookup fails (pipefail)",
|
|
unlock.includes("set -o pipefail"), unlock)
|
|
check("unlock iteration count matches store",
|
|
unlock.match(/-iter\s+(\d+)/)[1] === store.match(/-iter\s+(\d+)/)[1],
|
|
`${unlock.match(/-iter\s+(\d+)/)[1]} vs ${store.match(/-iter\s+(\d+)/)[1]}`)
|
|
check("unlock uses the same explicit PBKDF2 digest as store",
|
|
unlock.includes("-md sha256"), unlock)
|
|
|
|
console.log(`${pass} passed, ${failures.length} failed`)
|
|
if (failures.length) { console.error("\nFAILURES:\n " + failures.join("\n ")); process.exit(1) }
|