- 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
275 lines
15 KiB
JavaScript
275 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
// The vault's lifecycle drives the companion's. These tests pin the design's
|
|
// state table and the ordering rules around a lock: deny first, cancel work,
|
|
// drop private material, keep only the public projection, and never let the
|
|
// panel's own lock wait on a companion that will not answer.
|
|
//
|
|
// node tests/ssh-agent-lifecycle.test.js
|
|
|
|
const fs = require("fs")
|
|
const path = require("path")
|
|
|
|
const repoRoot = path.join(__dirname, "..")
|
|
const Model = {}
|
|
new Function("exports", fs.readFileSync(path.join(repoRoot, "BitwardenModel.js"), "utf8")
|
|
.replace(/^\.pragma library\s*$/m, "") + `
|
|
exports.sshAgentVaultState = sshAgentVaultState
|
|
exports.sshAgentIdentityPolicy = sshAgentIdentityPolicy
|
|
exports.sshAgentLifecycleTransition = sshAgentLifecycleTransition
|
|
exports.sshAgentLockAckTimeoutMs = sshAgentLockAckTimeoutMs
|
|
exports.sshAgentVaultLockedLine = sshAgentVaultLockedLine
|
|
exports.sshAgentLoggedOutLine = sshAgentLoggedOutLine
|
|
exports.sshAgentRevokeGrantsLine = sshAgentRevokeGrantsLine
|
|
`)(Model)
|
|
|
|
let pass = 0
|
|
const failures = []
|
|
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
|
const eq = (label, actual, expected) =>
|
|
check(label, actual === expected, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
|
|
|
|
const ctx = extra => Object.assign({
|
|
enabled: true, helperReady: true, loggedIn: true,
|
|
unlocked: true, loading: false, hasPublicCache: true
|
|
}, extra || {})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// The state table from the design, as one function
|
|
// -------------------------------------------------------------------------
|
|
|
|
eq("the feature off is its own state", Model.sshAgentVaultState(ctx({ enabled: false })), "disabled")
|
|
eq("a stopped companion is disabled too", Model.sshAgentVaultState(ctx({ helperReady: false })), "disabled")
|
|
eq("no account is logged out", Model.sshAgentVaultState(ctx({ loggedIn: false })), "logged-out")
|
|
eq("a load in flight is loading", Model.sshAgentVaultState(ctx({ loading: true })), "loading")
|
|
eq("an unlocked vault with keys is unlocked", Model.sshAgentVaultState(ctx()), "unlocked")
|
|
eq("locked with a cache keeps the cache",
|
|
Model.sshAgentVaultState(ctx({ unlocked: false })), "locked-cached")
|
|
eq("locked before any load is empty",
|
|
Model.sshAgentVaultState(ctx({ unlocked: false, hasPublicCache: false })), "locked-empty")
|
|
|
|
// Logged out outranks everything below it: an account change must not leave a
|
|
// public projection behind just because one was loaded a moment ago.
|
|
eq("logged out outranks a stale cache",
|
|
Model.sshAgentVaultState(ctx({ loggedIn: false, hasPublicCache: true })), "logged-out")
|
|
eq("disabled outranks logged out",
|
|
Model.sshAgentVaultState(ctx({ enabled: false, loggedIn: false })), "disabled")
|
|
|
|
const policy = state => Model.sshAgentIdentityPolicy(state)
|
|
|
|
for (const [state, publicIds, privateKeys, signing] of [
|
|
["disabled", false, false, "denied"],
|
|
["logged-out", false, false, "denied"],
|
|
["locked-empty", false, false, "needs-unlock"],
|
|
["loading", true, false, "denied"],
|
|
["unlocked", true, true, "allowed"],
|
|
["locked-cached", true, false, "needs-unlock"]
|
|
]) {
|
|
const p = policy(state)
|
|
eq(`${state} offers public identities: ${publicIds}`, p.publicIdentities, publicIds)
|
|
eq(`${state} holds private keys: ${privateKeys}`, p.privateKeys, privateKeys)
|
|
eq(`${state} signing is ${signing}`, p.signing, signing)
|
|
}
|
|
|
|
// Private keys exist in exactly one state, and it is the only one that signs.
|
|
const allStates = ["disabled", "logged-out", "locked-empty", "loading", "unlocked", "locked-cached"]
|
|
eq("private keys live in exactly one state",
|
|
allStates.filter(s => policy(s).privateKeys).length, 1)
|
|
eq("only that state signs without a further unlock",
|
|
allStates.filter(s => policy(s).signing === "allowed").join(","), "unlocked")
|
|
check("no state holds private keys without allowing signing",
|
|
allStates.every(s => !policy(s).privateKeys || policy(s).signing === "allowed"), "mismatch")
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Lifecycle transitions
|
|
// -------------------------------------------------------------------------
|
|
|
|
const at = (event, extra) => Model.sshAgentLifecycleTransition(event, ctx(extra))
|
|
|
|
// A lock denies first and asks for an acknowledgment it will not wait on.
|
|
const lock = at("lock", { loadActive: true })
|
|
check("lock tells the companion to lock",
|
|
lock.controlLines.indexOf(Model.sshAgentVaultLockedLine(ctx().epoch || 0)) >= 0
|
|
|| lock.controlLines.some(l => l.indexOf('"vault_locked"') >= 0),
|
|
JSON.stringify(lock.controlLines))
|
|
eq("lock cancels an in-flight load", lock.cancelLoad, true)
|
|
eq("lock starts no new load", lock.startLoad, false)
|
|
eq("lock waits for an acknowledgment", lock.awaitLockAck, true)
|
|
eq("lock keeps the public projection", lock.clearPublic, false)
|
|
eq("lock does not stop the helper", lock.stopHelper, false)
|
|
eq("the acknowledgment wait is bounded at two seconds", Model.sshAgentLockAckTimeoutMs(), 2000)
|
|
|
|
// Screen lock and suspend are locks. They are listed separately so the table
|
|
// says so, rather than leaving it to a reader to infer from the panel.
|
|
for (const event of ["screen-lock", "suspend"]) {
|
|
const t = at(event, { loadActive: true })
|
|
eq(`${event} locks the companion`, t.awaitLockAck, true)
|
|
eq(`${event} cancels an in-flight load`, t.cancelLoad, true)
|
|
eq(`${event} keeps the public projection`, t.clearPublic, false)
|
|
check(`${event} sends the same line a lock does`,
|
|
JSON.stringify(t.controlLines) === JSON.stringify(lock.controlLines), JSON.stringify(t.controlLines))
|
|
}
|
|
|
|
// Logout and account change clear the public projection too.
|
|
for (const event of ["logout", "account-change"]) {
|
|
const t = at(event, { loadActive: true })
|
|
eq(`${event} clears the public projection`, t.clearPublic, true)
|
|
eq(`${event} cancels an in-flight load`, t.cancelLoad, true)
|
|
check(`${event} tells the companion the account is gone`,
|
|
t.controlLines.some(l => l.indexOf('"vault_logged_out"') >= 0), JSON.stringify(t.controlLines))
|
|
check(`${event} does not merely lock`,
|
|
!t.controlLines.some(l => l.indexOf('"vault_locked"') >= 0), JSON.stringify(t.controlLines))
|
|
eq(`${event} waits for no acknowledgment`, t.awaitLockAck, false)
|
|
}
|
|
|
|
// Unlock and sync both ride the panel's existing read.
|
|
for (const event of ["unlock", "sync"]) {
|
|
const t = at(event)
|
|
eq(`${event} starts a key load`, t.startLoad, true)
|
|
eq(`${event} clears nothing`, t.clearPublic, false)
|
|
eq(`${event} sends no lifecycle line`, t.controlLines.length, 0)
|
|
}
|
|
|
|
// Startup into a vault the keyring already unlocked. A freshly started
|
|
// companion is in "locked, no cache yet" while the panel is unlocked, so
|
|
// startup is not evidence that the vault is locked.
|
|
const startup = at("startup", { unlocked: true, hasPublicCache: false })
|
|
eq("starting beside a remembered session loads keys", startup.startLoad, true)
|
|
const startupLocked = at("startup", { unlocked: false, hasPublicCache: false })
|
|
eq("starting into a locked vault loads nothing", startupLocked.startLoad, false)
|
|
|
|
// Disabling stops the companion outright; its socket and FIFO go with it.
|
|
const disabled = at("disable", { loadActive: true })
|
|
eq("disabling stops the helper", disabled.stopHelper, true)
|
|
eq("disabling cancels an in-flight load", disabled.cancelLoad, true)
|
|
eq("disabling clears the public projection", disabled.clearPublic, true)
|
|
|
|
const shutdown = at("shutdown", { loadActive: true })
|
|
eq("panel shutdown stops the helper", shutdown.stopHelper, true)
|
|
eq("panel shutdown cancels an in-flight load", shutdown.cancelLoad, true)
|
|
|
|
// Nothing is asked of a companion that is not there to answer.
|
|
for (const event of ["lock", "logout", "unlock", "sync", "screen-lock", "suspend"]) {
|
|
const t = Model.sshAgentLifecycleTransition(event, ctx({ enabled: false, helperReady: false }))
|
|
eq(`${event} sends nothing while disabled`, t.controlLines.length, 0)
|
|
eq(`${event} starts no load while disabled`, t.startLoad, false)
|
|
eq(`${event} waits for nothing while disabled`, t.awaitLockAck, false)
|
|
}
|
|
|
|
// A helper that has not finished its handshake cannot be sent lifecycle lines,
|
|
// but a lock must still cancel local work rather than quietly doing nothing.
|
|
const lockNoHelper = Model.sshAgentLifecycleTransition("lock", ctx({ helperReady: false, loadActive: true }))
|
|
eq("a lock with no live helper still cancels local work", lockNoHelper.cancelLoad, true)
|
|
eq("a lock with no live helper waits for no acknowledgment", lockNoHelper.awaitLockAck, false)
|
|
|
|
// -------------------------------------------------------------------------
|
|
// The panel's own lock is never blocked by the companion
|
|
// -------------------------------------------------------------------------
|
|
|
|
// The panel is three QML files now -- the SSH settings sections and the
|
|
// approval screen have their own. A check that reads only the largest one
|
|
// silently narrows as markup moves out of it.
|
|
const panelSrc = ["Panel.qml", "SshAgentSettings.qml", "SshApprovalScreen.qml"]
|
|
.map(file => fs.readFileSync(path.join(repoRoot, file), "utf8"))
|
|
.join("\n")
|
|
const lockVault = panelSrc.slice(panelSrc.indexOf("function lockVault()"),
|
|
panelSrc.indexOf("function lockVault()") + 1400)
|
|
|
|
check("locking runs bw lock without waiting on the companion",
|
|
/lockProc\.running = true/.test(lockVault) && !/await|\.wait\(/.test(lockVault), lockVault.slice(0, 300))
|
|
check("locking reports the vault locked on the panel's own schedule",
|
|
/status = "locked"/.test(lockVault), "lockVault never sets the locked status")
|
|
check("locking notifies the companion",
|
|
/applySshAgentLifecycle\("lock"\)|sshAgentVaultLockedLine/.test(lockVault),
|
|
"lockVault never tells the companion")
|
|
|
|
check("a lock acknowledgment timeout kills the helper",
|
|
/id: sshAgentLockAckTimer[\s\S]{0,400}?onTriggered:[\s\S]{0,200}?(sshAgentProc\.running = false|killSshAgentHelper)/
|
|
.test(panelSrc),
|
|
"no acknowledgment timeout kills the helper")
|
|
check("the acknowledgment timer uses the model's bound",
|
|
/id: sshAgentLockAckTimer[\s\S]{0,200}?interval: Model\.sshAgentLockAckTimeoutMs\(\)/.test(panelSrc),
|
|
"the acknowledgment timeout is not the model's")
|
|
check("a locked acknowledgment stops the timer",
|
|
/"locked"[\s\S]{0,300}?sshAgentLockAckTimer\.stop\(\)/.test(panelSrc),
|
|
"the locked acknowledgment never stops the kill timer")
|
|
|
|
check("logout tells the companion the account is gone",
|
|
/function logoutAccount\(\)[\s\S]{0,900}?applySshAgentLifecycle\("logout"\)/.test(panelSrc),
|
|
"logoutAccount never notifies the companion")
|
|
check("screen lock and suspend reach the companion through the lock path",
|
|
/function onScreenLockState[\s\S]{0,300}?lockVault\(\)/.test(panelSrc)
|
|
&& /function onSleepSignal[\s\S]{0,900}?lockVault\(\)/.test(panelSrc),
|
|
"screen lock or suspend does not lock the vault")
|
|
|
|
check("the gate opening arms a startup load",
|
|
/onSshAgentGateOpenChanged[\s\S]{0,1400}?sshAgentStartupLoadTimer\.restart\(\)/.test(panelSrc),
|
|
"the gate opening never arms a startup load")
|
|
check("a remembered unlocked session loads keys once the helper is ready",
|
|
/function maybeStartupLoad\(\)[\s\S]{0,1200}?applySshAgentLifecycle\("startup"\)/.test(panelSrc),
|
|
"nothing applies the startup transition")
|
|
// On a shell restart the handshake and the first `bw status` race, so waiting
|
|
// on only one of them loses the load whenever the other is second.
|
|
check("both edges of the startup race trigger the load",
|
|
/id: sshAgentStartupLoadTimer[\s\S]{0,200}?maybeStartupLoad\(\)/.test(panelSrc)
|
|
&& /onStatusChanged:[\s\S]{0,200}?maybeStartupLoad\(\)/.test(panelSrc),
|
|
"only one edge triggers the startup load")
|
|
// The panel's first read is launched before the helper handshakes, so the
|
|
// completion of that read is the third edge that can owe a key load.
|
|
check("a completed read re-checks whether a startup load is owed",
|
|
/function onListFinished\(rawJson\)[\s\S]{0,900}?maybeStartupLoad\(\)/.test(panelSrc),
|
|
"a finished read never re-checks for an owed startup load")
|
|
check("a startup attempt is recorded before it runs, not after",
|
|
/sshAgentLoadedForVaultEpoch = root\.vaultEpoch\s*\n\s*applySshAgentLifecycle\("startup"\)/.test(panelSrc),
|
|
"a failed startup load could relaunch itself")
|
|
check("the startup load happens once per vault epoch, not once per edge",
|
|
/function maybeStartupLoad\(\)[\s\S]{0,1200}?sshAgentLoadedForVaultEpoch === root\.vaultEpoch/.test(panelSrc),
|
|
"nothing stops the startup load repeating")
|
|
|
|
// The lock acknowledgment is what stops the kill timer, so it has to be
|
|
// consumed wherever the companion's messages are handled.
|
|
const messageHandler = panelSrc.slice(
|
|
panelSrc.indexOf("function onSshAgentMessage(message)"),
|
|
panelSrc.indexOf("function syncSshAgentSupervision()"))
|
|
check("the companion's own events are consumed rather than ignored",
|
|
/message\.type === "locked"/.test(messageHandler) && /message\.type === "keys_loaded"/.test(messageHandler),
|
|
"onSshAgentMessage ignores the lock acknowledgment or the load result")
|
|
|
|
// Turning the feature off stops the helper through the supervisor, which is a
|
|
// different path from the lifecycle table -- so the table's clearPublic has to
|
|
// be applied explicitly or the projection is left on disk by a feature that is
|
|
// no longer running.
|
|
check("disabling the feature clears the public projection",
|
|
/onSshAgentEnabledChanged[\s\S]{0,700}?applySshAgentLifecycle\("disable"\)/.test(panelSrc),
|
|
"disabling never applies the disable transition")
|
|
|
|
// A restarted helper is empty even when the vault epoch has not moved: the
|
|
// keystore lives in the helper's memory, not the vault's. Keying the
|
|
// startup-load guard on the vault epoch alone leaves a fresh helper keyless
|
|
// until something unrelated happens to bump it.
|
|
check("a new helper is always eligible for a load",
|
|
/onSshAgentGateOpenChanged[\s\S]{0,700}?sshAgentLoadedForVaultEpoch = -1/.test(panelSrc),
|
|
"a restarted helper inherits the old load bookkeeping and never loads")
|
|
check("a departed helper's key count is not left standing",
|
|
/onSshAgentGateOpenChanged[\s\S]{0,700}?sshAgentKeyCount = 0/.test(panelSrc),
|
|
"the panel keeps reporting keys a dead helper no longer holds")
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Control lines
|
|
// -------------------------------------------------------------------------
|
|
|
|
eq("revoke_grants is a versioned v1 line", Model.sshAgentRevokeGrantsLine(),
|
|
JSON.stringify({ v: 1, type: "revoke_grants" }) + "\n")
|
|
|
|
for (const line of [Model.sshAgentVaultLockedLine(3), Model.sshAgentLoggedOutLine(),
|
|
Model.sshAgentRevokeGrantsLine()]) {
|
|
check("no lifecycle line carries key material or a session token",
|
|
line.indexOf("BW_SESSION") < 0 && line.indexOf("privateKey") < 0 && line.indexOf("PRIVATE") < 0, line)
|
|
}
|
|
|
|
if (failures.length) {
|
|
console.error(`\n${failures.length} failed, ${pass} passed\n`)
|
|
failures.forEach(f => console.error(` FAIL ${f}`))
|
|
process.exit(1)
|
|
}
|
|
console.log(`ssh-agent-lifecycle: ${pass} passed`)
|