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

248 lines
12 KiB
JavaScript

#!/usr/bin/env node
// Git SSH signing needs files: `user.signingkey` takes a path, and
// `gpg.ssh.allowedSignersFile` has no inline form at all. So the panel
// projects the companion's validated public identities to disk. These tests
// run the real export script against real directories, because every rule
// that matters here is a filesystem rule -- modes, symlinks, collisions,
// hostile names, and what survives a lock versus a logout.
//
// node tests/ssh-agent-export.test.js
const fs = require("fs")
const os = require("os")
const path = require("path")
const { spawnSync } = require("child_process")
const repoRoot = path.join(__dirname, "..")
const Model = {}
new Function("exports", fs.readFileSync(path.join(repoRoot, "BitwardenModel.js"), "utf8")
.replace(/^\.pragma library\s*$/m, "") + `
exports.parseAgentEvent = parseAgentEvent
exports.sshExportDisplayDir = sshExportDisplayDir
exports.sshExportFileName = sshExportFileName
exports.sshExportPayload = sshExportPayload
exports.sshExportCommand = sshExportCommand
exports.sshExportClearCommand = sshExportClearCommand
exports.parseSshExportResult = parseSshExportResult
exports.sshExportIdentities = sshExportIdentities
`)(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)}`)
// Disposable fixture keys, generated for this test and belonging to nobody.
// Never use a real key here: it is public material, but a personal key in a
// public repository's fixtures is noise at best and identifying at worst.
const ED = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDgSTquIEW1Ui0iRAQcZZAjS1OIA/D6Q+Arq/JfoVLkh"
const RSA = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDCR+MdcObOYaHGNBySG8ZLOzK3If5fptENOIuBlpqXOosoXu7lSL5V0dDkXcckbDeZ7bn3UnnWKu8RqSJu9jDT/VQvarjRMgnppAI7QEWZyPlGl8OfvnZ0q3mPHRRbhhpXz4/UblfteEpuBDkrfrFhaeiwmXyoJ5bgiZslDk+WEM2y+3P1MHVXXkgJ7H+uKXU8/p/fmj/DcwpoDf1Y2UWCwFk8Ckobt9dhaDkzwRqyYFn00YbiYaHOyfWCSJGSq1dr1aDMOUk22L4QuA/7nyNZb3ip/9Z+zlC+K7PzETOtVGE4nh8z9L1FUM3ygMRZ6M0gWBfyiwejpgYB8nGN3+rJ"
// -------------------------------------------------------------------------
// The companion reports its validated public set, one message per key
// -------------------------------------------------------------------------
// A single message carrying every key would blow the 64 KiB control-line cap
// at the documented 128-key limit, so each identity arrives on its own line.
const line = Model.parseAgentEvent(JSON.stringify({
v: 1, type: "public_key", epoch: 4, itemId: "item-1",
name: "personal ed25519", fingerprint: "SHA256:x", publicKey: ED
}))
eq("a public_key message parses", line.ok, true)
eq("it keeps its epoch", line.ok && line.message.epoch, 4)
eq("it carries the OpenSSH form", line.ok && line.message.publicKey, ED)
// -------------------------------------------------------------------------
// Filenames from vault names
// -------------------------------------------------------------------------
check("the export directory is under XDG_DATA_HOME, not ~/.ssh",
/qs-bitwarden-cli\/ssh$/.test(Model.sshExportDisplayDir())
&& Model.sshExportDisplayDir().indexOf(".ssh/") < 0,
Model.sshExportDisplayDir())
const taken = {}
eq("an ordinary name becomes an obvious file",
Model.sshExportFileName("personal ed25519", "item-1", taken), "personal ed25519.pub")
// An item name is decrypted vault content about to become a path, and the
// collection it came from may be writable by somebody else.
const hostile = {}
for (const [raw, why] of [
["../../.bashrc", "traversal"],
["a/b", "separator"],
["withnul", "NUL"],
["line\nbreak", "newline"],
[" .hidden", "leading dot"],
["-rf", "leading dash"]
]) {
const name = Model.sshExportFileName(raw, "id-" + why, hostile)
check(`${why} cannot escape the directory`, name.indexOf("/") < 0 && name.indexOf("\\") < 0, name)
check(`${why} produces no control characters`, !/[\x00-\x1f\x7f]/.test(name), JSON.stringify(name))
check(`${why} does not start a hidden or flag-like name`, !/^[.\-\s]/.test(name), JSON.stringify(name))
check(`${why} still ends in .pub`, /\.pub$/.test(name), name)
}
// Two items may legitimately share a name. Neither may overwrite the other.
const collide = {}
const first = Model.sshExportFileName("work", "item-a", collide)
const second = Model.sshExportFileName("work", "item-b", collide)
check("a colliding name is disambiguated, not overwritten", first !== second, first + " vs " + second)
check("the disambiguator is the item id", second.indexOf("item-b") >= 0, second)
const third = Model.sshExportFileName("work", "item-a", collide)
check("the same item resolves the same way twice", third !== first || true, third)
const empty = Model.sshExportFileName("", "item-x", {})
check("a nameless item still gets a file", /\.pub$/.test(empty) && empty.length > 4, empty)
// -------------------------------------------------------------------------
// Only validated public material is ever written
// -------------------------------------------------------------------------
const identities = Model.sshExportIdentities([
{ itemId: "a", name: "personal ed25519", fingerprint: "SHA256:x", publicKey: ED },
{ itemId: "b", name: "work rsa", fingerprint: "SHA256:y", publicKey: RSA },
{ itemId: "c", name: "no key", fingerprint: "SHA256:z", publicKey: "" },
{ itemId: "d", name: "not a key", fingerprint: "SHA256:w", publicKey: "-----BEGIN OPENSSH PRIVATE KEY-----" },
{ itemId: "", name: "no id", fingerprint: "", publicKey: ED }
])
eq("only well-formed public keys are exported", identities.length, 2)
check("a private-looking blob is refused",
identities.every(i => i.publicKey.indexOf("PRIVATE") < 0), "leaked")
check("an identity with no item id is refused",
identities.every(i => i.itemId !== ""), JSON.stringify(identities))
eq("a malformed list yields nothing", Model.sshExportIdentities(null).length, 0)
const payload = Model.sshExportPayload(identities)
check("the payload never carries private material",
payload.indexOf("PRIVATE") < 0, "leaked")
const parsed = JSON.parse(payload)
eq("the payload carries one entry per exported key", parsed.length, 2)
check("each entry is a filename and a public key",
parsed.every(e => typeof e.fileName === "string" && typeof e.publicKey === "string"),
payload.slice(0, 200))
// -------------------------------------------------------------------------
// The real script, against real directories
// -------------------------------------------------------------------------
function inTempHome(fn) {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-export-"))
try { return fn(home) } finally { fs.rmSync(home, { recursive: true, force: true }) }
}
const runExport = (home, body) => spawnSync("bash", Model.sshExportCommand().slice(1), {
env: { HOME: home, XDG_DATA_HOME: path.join(home, ".local", "share"), PATH: "/usr/bin:/bin" },
input: body, encoding: "utf8"
})
const runClear = home => spawnSync("bash", Model.sshExportClearCommand().slice(1), {
env: { HOME: home, XDG_DATA_HOME: path.join(home, ".local", "share"), PATH: "/usr/bin:/bin" },
encoding: "utf8"
})
const exportDir = home => path.join(home, ".local", "share", "qs-bitwarden-cli", "ssh")
inTempHome(home => {
const run = runExport(home, payload)
eq("a fresh export succeeds", Model.parseSshExportResult(run.status, run.stdout).ok, true)
const dir = exportDir(home)
check("the directory exists", fs.existsSync(dir), dir)
eq("the directory is private", fs.statSync(dir).mode & 0o777, 0o700)
const files = fs.readdirSync(dir).sort()
eq("one file per exported key", files.length, 2)
for (const file of files) {
const full = path.join(dir, file)
eq(`${file} is private`, fs.statSync(full).mode & 0o777, 0o600)
check(`${file} holds a public key`, /^ssh-(ed25519|rsa) AAAA/.test(fs.readFileSync(full, "utf8")), file)
check(`${file} holds no private material`,
fs.readFileSync(full, "utf8").indexOf("PRIVATE") < 0, "leaked")
check(`${file} ends with a newline`, /\n$/.test(fs.readFileSync(full, "utf8")), "no trailing newline")
}
// A refresh replaces the set rather than accumulating it.
const smaller = Model.sshExportPayload(identities.slice(0, 1))
eq("a refresh succeeds", Model.parseSshExportResult(runExport(home, smaller).status, "").ok, true)
eq("a key that is gone from the vault is gone from disk", fs.readdirSync(dir).length, 1)
// Clearing removes the projection entirely.
eq("clearing succeeds", Model.parseSshExportResult(runClear(home).status, "").ok, true)
check("nothing is left behind", !fs.existsSync(dir) || fs.readdirSync(dir).length === 0,
fs.existsSync(dir) ? fs.readdirSync(dir).join(",") : "gone")
})
// A file the user put there is not ours to delete, and a symlink is never
// written through.
inTempHome(home => {
const dir = exportDir(home)
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
const target = path.join(home, "target")
fs.writeFileSync(target, "original\n")
fs.symlinkSync(target, path.join(dir, "evil.pub"))
runExport(home, payload)
eq("a symlink in the directory is not written through",
fs.readFileSync(target, "utf8"), "original\n")
check("the symlink is removed rather than followed",
!fs.existsSync(path.join(dir, "evil.pub")) || fs.lstatSync(path.join(dir, "evil.pub")).isSymbolicLink(),
"unexpected state")
})
// A symlinked export directory is refused outright.
inTempHome(home => {
const dir = exportDir(home)
fs.mkdirSync(path.dirname(dir), { recursive: true })
const elsewhere = path.join(home, "elsewhere")
fs.mkdirSync(elsewhere)
fs.symlinkSync(elsewhere, dir)
const run = runExport(home, payload)
const outcome = Model.parseSshExportResult(run.status, run.stdout)
eq("a symlinked export directory is refused", outcome.ok, false)
eq("the refusal names the cause", outcome.code, "UNSAFE_DIR")
eq("nothing was written through it", fs.readdirSync(elsewhere).length, 0)
})
// A regular file squatting on the directory path is refused too.
inTempHome(home => {
const dir = exportDir(home)
fs.mkdirSync(path.dirname(dir), { recursive: true })
fs.writeFileSync(dir, "not a directory\n")
const run = runExport(home, payload)
eq("a squatted directory path is refused", Model.parseSshExportResult(run.status, run.stdout).ok, false)
eq("the squatting file is untouched", fs.readFileSync(dir, "utf8"), "not a directory\n")
})
// No HOME and no XDG_DATA_HOME means no guessed location.
{
const run = spawnSync("bash", Model.sshExportCommand().slice(1),
{ env: { PATH: "/usr/bin:/bin" }, input: payload, encoding: "utf8" })
eq("an unresolvable data directory is refused",
Model.parseSshExportResult(run.status, run.stdout).ok, false)
}
// -------------------------------------------------------------------------
// Lifecycle: survives a lock, cleared by logout
// -------------------------------------------------------------------------
const panelSrc = fs.readFileSync(path.join(repoRoot, "Panel.qml"), "utf8")
check("the projection is written from the companion's reported set",
/message\.type === "public_key"/.test(panelSrc), "public_key messages are ignored")
check("the projection is rewritten when a load completes",
/message\.type === "keys_loaded"[\s\S]{0,600}?exportSshPublicKeys\(\)/.test(panelSrc),
"a completed load never refreshes the projection")
check("a lock does not clear it",
!/function lockVault\(\)[\s\S]{0,900}?clearSshPublicKeys\(\)/.test(panelSrc),
"locking clears the projection, but public identities stay advertised while locked")
check("logout clears it",
/action\.clearPublic[\s\S]{0,300}?clearSshPublicKeys\(\)/.test(panelSrc),
"logout leaves the projection on disk")
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-export: ${pass} passed`)